Skip to content

Commit 071b2f7

Browse files
authored
facts.server: add AuthorizedKeys, make user_authorized_keys idempotent (#1670)
1 parent 97c7cd9 commit 071b2f7

13 files changed

Lines changed: 259 additions & 18 deletions

File tree

src/pyinfra/facts/server.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -810,6 +810,39 @@ def process(self, output):
810810
return users
811811

812812

813+
class AuthorizedKeys(FactBase[List[str]]):
814+
"""
815+
Returns the SSH public keys listed in a user's ``~/.ssh/authorized_keys`` file as a
816+
list of full key strings. Empty lines and lines starting with ``#`` are skipped; the
817+
file's order is preserved.
818+
819+
.. code:: python
820+
821+
[
822+
"ssh-ed25519 AAAAC3Nz... user@host",
823+
"ssh-rsa AAAAB3Nz... other@host",
824+
]
825+
"""
826+
827+
default = list
828+
829+
@override
830+
def command(self, user: str, path: Optional[str] = None) -> str:
831+
# Tilde expansion resolves the user's home without another fact round-trip.
832+
target = path if path is not None else "~{0}/.ssh/authorized_keys".format(user)
833+
return "cat {0} 2>/dev/null || true".format(target)
834+
835+
@override
836+
def process(self, output: Iterable[str]) -> List[str]:
837+
keys: List[str] = []
838+
for raw in output:
839+
line = raw.strip()
840+
if not line or line.startswith("#"):
841+
continue
842+
keys.append(line)
843+
return keys
844+
845+
813846
class LinuxDistributionDict(TypedDict):
814847
name: Optional[str]
815848
major: Optional[int]

src/pyinfra/operations/server.py

Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from pyinfra.connectors.util import remove_any_sudo_askpass_file
1818
from pyinfra.facts.files import Directory, FindInFile, Link
1919
from pyinfra.facts.server import (
20+
AuthorizedKeys,
2021
Groups,
2122
Home,
2223
Hostname,
@@ -801,34 +802,49 @@ def read_any_pub_key_file(key):
801802

802803
authorized_key_file = f"{authorized_key_directory}/{authorized_key_filename}"
803804

804-
if delete_keys:
805-
# Create a whole new authorized_keys file
806-
keys_file = StringIO(
807-
"{0}\n".format(
808-
"\n".join(public_keys),
809-
),
810-
)
805+
# Pull the currently installed keys once; individual files.line calls otherwise
806+
# issue one FindInFile fact per key, which dominates the cost for users with many
807+
# keys.
808+
current_keys = host.get_fact(AuthorizedKeys, user=user, path=authorized_key_file)
811809

812-
# And ensure it exists
813-
yield from files.put._inner(
814-
src=keys_file,
815-
dest=authorized_key_file,
816-
user=user,
817-
group=group or user,
818-
mode=600,
819-
)
810+
if delete_keys:
811+
if current_keys == public_keys:
812+
# Still ensure the file and its ownership/mode stay correct.
813+
yield from files.file._inner(
814+
path=authorized_key_file,
815+
user=user,
816+
group=group or user,
817+
mode=600,
818+
)
819+
else:
820+
keys_file = StringIO(
821+
"{0}\n".format(
822+
"\n".join(public_keys),
823+
),
824+
)
825+
yield from files.put._inner(
826+
src=keys_file,
827+
dest=authorized_key_file,
828+
user=user,
829+
group=group or user,
830+
mode=600,
831+
)
820832

821833
else:
822-
# Ensure authorized_keys exists
834+
# Ensure authorized_keys exists with the right ownership and mode.
823835
yield from files.file._inner(
824836
path=authorized_key_file,
825837
user=user,
826838
group=group or user,
827839
mode=600,
828840
)
829841

830-
# And every public key is present
842+
# Only append the keys that the fact says are missing; an empty fact result
843+
# also covers the "file does not exist yet" case.
844+
current_key_set = set(current_keys)
831845
for key in public_keys:
846+
if key in current_key_set:
847+
continue
832848
yield from files.line._inner(path=authorized_key_file, line=key, ensure_newline=True)
833849

834850

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"arg": {
3+
"user": "alice",
4+
"path": "/etc/ssh/keys/alice"
5+
},
6+
"command": "cat /etc/ssh/keys/alice 2>/dev/null || true",
7+
"output": [],
8+
"fact": []
9+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"arg": {
3+
"user": "alice"
4+
},
5+
"command": "cat ~alice/.ssh/authorized_keys 2>/dev/null || true",
6+
"output": [
7+
"# some comment",
8+
"",
9+
"ssh-ed25519 AAAAC3Nz...key1 user@host",
10+
" ",
11+
"ssh-rsa AAAAB3Nz...key2 other@host"
12+
],
13+
"fact": [
14+
"ssh-ed25519 AAAAC3Nz...key1 user@host",
15+
"ssh-rsa AAAAB3Nz...key2 other@host"
16+
]
17+
}

tests/operations/server.user/key_files.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@
4343
"interpolate_variables=False, path=homedir/.ssh/authorized_keys, pattern=^.*somekeydata.*$": ["somekeydata"],
4444
"interpolate_variables=False, path=homedir/.ssh/authorized_keys, pattern=^.*someotherkeydata.*$": []
4545
},
46+
"server.AuthorizedKeys": {
47+
"path=homedir/.ssh/authorized_keys, user=someuser": ["somekeydata"]
48+
},
4649
"server.Groups": {}
4750
},
4851
"commands": [

tests/operations/server.user/keys.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@
3434
"files.FindInFile": {
3535
"interpolate_variables=False, path=homedir/.ssh/authorized_keys, pattern=^.*abc.*$": []
3636
},
37+
"server.AuthorizedKeys": {
38+
"path=homedir/.ssh/authorized_keys, user=someuser": []
39+
},
3740
"server.Groups": {}
3841
},
3942
"commands": [

tests/operations/server.user/keys_delete.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,10 @@
4545
"path=homedir/.ssh/authorized_keys": null
4646
},
4747
"files.FileContents": {
48-
"path=homedir/.ssh/authorized_keys": null
48+
"path=homedir/.ssh/authorized_keys": null
49+
},
50+
"server.AuthorizedKeys": {
51+
"path=homedir/.ssh/authorized_keys, user=someuser": []
4952
},
5053
"server.Groups": {}
5154
},

tests/operations/server.user/keys_nohome.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@
3333
"files.FindInFile": {
3434
"interpolate_variables=False, path=/root/.ssh/authorized_keys, pattern=^.*abc.*$": []
3535
},
36+
"server.AuthorizedKeys": {
37+
"path=/root/.ssh/authorized_keys, user=root": []
38+
},
3639
"server.Groups": {}
3740
},
3841
"commands": [

tests/operations/server.user/keys_single.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@
3434
"files.FindInFile": {
3535
"interpolate_variables=False, path=homedir/.ssh/authorized_keys, pattern=^.*abc.*$": []
3636
},
37+
"server.AuthorizedKeys": {
38+
"path=homedir/.ssh/authorized_keys, user=someuser": []
39+
},
3740
"server.Groups": {}
3841
},
3942
"commands": [
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
{
2+
"args": ["someuser"],
3+
"kwargs": {
4+
"public_keys": ["ssh-ed25519 AAAAkey1 alice", "ssh-rsa AAAAkey2 bob"]
5+
},
6+
"facts": {
7+
"server.Home": {
8+
"user=someuser": "/home/someuser"
9+
},
10+
"files.Directory": {
11+
"path=/home/someuser/.ssh": {
12+
"user": "someuser",
13+
"group": "someuser",
14+
"mode": 700
15+
}
16+
},
17+
"files.File": {
18+
"path=/home/someuser/.ssh/authorized_keys": {
19+
"user": "someuser",
20+
"group": "someuser",
21+
"mode": 600
22+
}
23+
},
24+
"server.AuthorizedKeys": {
25+
"path=/home/someuser/.ssh/authorized_keys, user=someuser": [
26+
"ssh-ed25519 AAAAkey1 alice",
27+
"ssh-rsa AAAAkey2 bob"
28+
]
29+
}
30+
},
31+
"commands": [],
32+
"noop_description": "file /home/someuser/.ssh/authorized_keys already exists"
33+
}

0 commit comments

Comments
 (0)