simplify remotes

This commit is contained in:
abrown38 committed 2026-09-30 17:47:41 +00:00
1 parent 02017d470f
commit 998d9f603d
6 files changed
+137 -133

No files matched your search

+104 -99
View File
@@ -14,7 +14,7 @@
# the instance to the destination's folders and free ports.
#
# Security: the daemon's certificate is self-signed. A computer joins with a
# one-time token from 'terractl daemon trust add', which holds the certificate's
# one-time token from 'terractl daemon enable' or 'daemon token', which holds the certificate's
# fingerprint and a secret. The client checks that fingerprint on every
# connection, so nothing in between can pose as the daemon, and trades the
# secret for a random key of its own, sent with each request. The daemon stores
@@ -27,7 +27,6 @@ import base64
import codecs
import errno
import fcntl
import getpass
import hashlib
import hmac
import http.client
@@ -212,7 +211,7 @@ def open_state(cmdline):
class StateLock:
"""Serialises changes to trust.json between the daemon and 'terractl daemon trust'."""
"""Serialises changes to trust.json between the daemon and 'terractl daemon token/revoke'."""
def __enter__(self):
self.fd = os.open(state_path(".lock"), os.O_RDWR | os.O_CREAT, 0o600)
@@ -427,6 +426,15 @@ def extract_upload(tar_path, dest):
raise HttpError(400, f"the files sent aren't a valid archive ({e})")
def unique_client_name(wanted, clients):
base = wanted[:60] if isinstance(wanted, str) and CLIENT_RE.fullmatch(wanted[:60]) else "computer"
taken = {c.get("name") for c in clients}
name, n = base, 2
while name in taken:
name, n = f"{base}-{n}", n + 1
return name
class Handler(http.server.BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
server_version = "terractld"
@@ -579,7 +587,8 @@ class Handler(http.server.BaseHTTPRequestHandler):
# ---- endpoints
def join(self):
secret = self.read_json().get("secret")
req = self.read_json()
secret, wanted, old_key = req.get("secret"), req.get("name"), req.get("old_key")
if not isinstance(secret, str):
raise HttpError(400, "bad request")
digest = sha256(secret)
@@ -593,11 +602,20 @@ class Handler(http.server.BaseHTTPRequestHandler):
save_trust(trust)
dlog(f"{self.client_address[0]}: refused a join (unknown, used or expired token)")
raise HttpError(403, "this token is not valid (it may have been used already, or "
"expired); create a new one there: sudo terractl daemon trust add <name>")
"expired); create a new one there: sudo terractl daemon token")
trust["tokens"].remove(token)
key = secrets.token_urlsafe(32)
name = token["name"]
trust["clients"] = [c for c in trust["clients"] if c.get("name") != name]
# A computer that's trusted already (it sends its old key) keeps its
# entry and name; a new one is named after its hostname
old = None
if isinstance(old_key, str):
old = next((c for c in trust["clients"]
if hmac.compare_digest(c.get("key_sha256", ""), sha256(old_key))), None)
if old:
name = old["name"]
trust["clients"].remove(old)
else:
name = unique_client_name(wanted, trust["clients"])
trust["clients"].append({"name": name, "key_sha256": sha256(key),
"added": time.strftime("%Y-%m-%d %H:%M"),
"from": self.client_address[0]})
@@ -969,11 +987,12 @@ def daemon_enable():
if not wait_listening(host, port, 10):
die(f"terractld isn't listening on {describe_listen(host, port)}; see: journalctl -u terractld")
print(f"terractld is running and starts at boot, listening on {describe_listen(host, port)}.")
print(f"Certificate fingerprint (SHA-256): {cert_fingerprint()}")
print()
print("To let a computer manage this host, create a token for it:")
print(" sudo terractl daemon trust add <name for that computer>")
print(f"If a firewall is active, allow TCP port {port} (e.g. sudo ufw allow {port}/tcp).")
print()
if load_trust()["clients"]:
print("To add another computer: sudo terractl daemon token")
else:
print_token()
return 0
@@ -983,7 +1002,7 @@ def daemon_disable():
if proc.returncode != 0:
die(f"stopping terractld failed: {proc.stderr.strip()}")
print("terractld is stopped and won't start at boot, so other hosts can't manage this one.")
print("The computers it trusts are kept for next time (terractl daemon trust list).")
print("The computers it trusts are kept for next time (sudo terractl daemon status).")
return 0
@@ -1001,76 +1020,54 @@ def daemon_status():
except PermissionError:
print("trusted: (run with sudo to see which computers)")
trust = None
if trust is not None:
names = ", ".join(c["name"] for c in trust["clients"]) or "none yet"
print(f"trusted: {names}")
waiting = [t["name"] for t in trust["tokens"] if t["expires"] > time.time()]
if waiting:
print(f"tokens for: {', '.join(waiting)} (not used yet)")
if trust is not None and not trust["clients"]:
print("trusted: none yet (add one: sudo terractl daemon token)")
elif trust is not None:
print()
print(f"{'TRUSTED':<24} {'ADDED':<17} FROM")
for c in trust["clients"]:
print(f"{c['name']:<24} {c.get('added', '-'):<17} {c.get('from', '-')}")
unused = [t for t in trust["tokens"] if t["expires"] > time.time()]
if unused:
print(f"(and {len(unused)} unused token{'s' if len(unused) > 1 else ''})")
if active != "active":
print("Start it with: sudo terractl daemon enable")
return 0
def trust_add(name):
if not CLIENT_RE.fullmatch(name):
die(f"invalid name '{name}' (letters, digits and _ . - @, up to 64)")
open_state(f"daemon trust add {name}")
def print_token():
"""Creates a one-time token and prints the command that uses it."""
open_state("daemon token")
ensure_cert()
secret = secrets.token_hex(32)
with StateLock():
trust = load_trust()
now = time.time()
trust["tokens"] = [t for t in trust["tokens"] if t["expires"] > now and t["name"] != name]
trust["tokens"].append({"name": name, "secret_sha256": sha256(secret),
"expires": int(now + TOKEN_HOURS * 3600)})
trust["tokens"] = [t for t in trust["tokens"] if t["expires"] > now]
trust["tokens"].append({"secret_sha256": sha256(secret), "expires": int(now + TOKEN_HOURS * 3600)})
save_trust(trust)
known = any(c["name"] == name for c in trust["clients"])
host, port = listen_setting()
token = make_token({"v": 1, "fp": cert_fingerprint(), "s": secret, "a": host_addresses(host, port)})
fields = {"v": 1, "fp": cert_fingerprint(), "s": secret, "a": host_addresses(host, port)}
here = (socket.gethostname() or "").split(".")[0]
if not NAME_RE.fullmatch(here):
here = "<name>"
print(f"On '{name}', run this to add this host as a remote called '{here}':")
if NAME_RE.fullmatch(here):
fields["n"] = here # the remote's name, unless the other computer picks one
print(f"To manage this host from another computer, run this there (works once, within {TOKEN_HOURS} hours):")
print()
print(f" terractl remote add {here} {token}")
print(f" terractl remote add {make_token(fields)}")
print()
print(f"The token works once, within {TOKEN_HOURS} hours.")
if known:
print(f"'{name}' is trusted already; joining again gives it a new key and the old one stops working.")
if systemctl("is-active", DAEMON_UNIT).stdout.strip() != "active":
print("The daemon isn't running yet; start it with: sudo terractl daemon enable")
return 0
def trust_list():
try:
trust = load_trust()
except PermissionError:
die("this needs root: sudo terractl daemon trust list")
if not trust["clients"]:
print("No computers are trusted yet. Add one with: sudo terractl daemon trust add <name>")
else:
print(f"{'NAME':<24} {'ADDED':<17} FROM")
for c in trust["clients"]:
print(f"{c['name']:<24} {c.get('added', '-'):<17} {c.get('from', '-')}")
now = time.time()
for t in trust["tokens"]:
if t["expires"] > now:
hours = max(1, round((t["expires"] - now) / 3600))
print(f"Token for '{t['name']}' not used yet (expires in {hours} h)")
return 0
def trust_remove(name):
open_state(f"daemon trust remove {name}")
def daemon_revoke(name):
open_state(f"daemon revoke {name}")
with StateLock():
trust = load_trust()
clients = [c for c in trust["clients"] if c["name"] != name]
tokens = [t for t in trust["tokens"] if t["name"] != name]
if len(clients) == len(trust["clients"]) and len(tokens) == len(trust["tokens"]):
die(f"'{name}' isn't trusted (see: terractl daemon trust list)")
trust["clients"], trust["tokens"] = clients, tokens
if len(clients) == len(trust["clients"]):
die(f"no computer called '{name}' is trusted (see: sudo terractl daemon status)")
trust["clients"] = clients
save_trust(trust)
print(f"'{name}' can't manage this host any more.")
return 0
@@ -1078,20 +1075,13 @@ def trust_remove(name):
def daemon_main(argv):
sub, rest = (argv[0], argv[1:]) if argv else ("", [])
if sub == "trust":
what = rest[0] if rest else "list"
if what == "add" and len(rest) == 2:
return trust_add(rest[1])
if what == "list" and len(rest) <= 1:
return trust_list()
if what == "remove" and len(rest) == 2:
return trust_remove(rest[1])
die("usage: terractl daemon trust add <name> | trust list | trust remove <name>")
if sub == "revoke" and len(rest) == 1:
return daemon_revoke(rest[0])
commands = {"enable": daemon_enable, "disable": daemon_disable, "status": daemon_status,
"serve": daemon_serve}
"token": print_token, "serve": daemon_serve}
if sub in commands and not rest:
return commands[sub]()
die("usage: terractl daemon enable | disable | status | trust ...")
die("usage: terractl daemon enable | disable | status | token | revoke <name>")
# --------------------------------------------------------------- client ----
@@ -1244,9 +1234,8 @@ class Remote:
except (ValueError, KeyError, TypeError):
message = f"HTTP error {resp.status}"
if resp.status == 401:
die(f"{self.label} doesn't trust this computer (any more). On {self.label}, run "
f"'sudo terractl daemon trust add <name for this computer>', then here: terractl "
f"remote remove {self.name} && terractl remote add {self.name} {self.addr} <token>")
die(f"{self.label} doesn't trust this computer (any more). Run 'sudo terractl daemon "
f"token' there, then here: terractl remote add <token>")
die(f"{self.label}: {message}")
return resp
@@ -1384,40 +1373,50 @@ def find_remote(spec):
if host and conf["address"].lower() == host.lower() and int(conf["port"]) == port:
return Remote(name, conf, via=f"--host {spec}")
die(f"'{spec}' isn't one of your remotes (see: terractl remote list). To add it, run "
f"'sudo terractl daemon trust add <name for this computer>' there, then here: "
f"terractl remote add <name> <token>")
f"'sudo terractl daemon token' there, then here: terractl remote add <token>")
def remote_add(args):
if len(args) not in (2, 3):
die("usage: terractl remote add <name> <token>, or: terractl remote add <name> <address> [<token>]")
name = args[0]
usage = "usage: terractl remote add [<name>] <token> [--address <host>[:<port>]]"
address, rest = None, []
args = iter(args)
for arg in args:
if arg == "--address":
address = next(args, None) or die(usage)
elif arg.startswith("--address="):
address = arg.split("=", 1)[1]
elif arg.startswith("-"):
die(f"unknown option '{arg}'")
else:
rest.append(arg)
if len(rest) not in (1, 2):
die(usage)
name, token = (None, rest[0]) if len(rest) == 1 else rest
fields = read_token(token)
remotes = load_remotes()
# Adding a host that's a remote already (same certificate) replaces that
# remote: after its access was revoked, say, or to rename it
old = next((n for n, c in sorted(remotes.items()) if c["fingerprint"] == fields["fp"]), None)
name = name or old or fields.get("n")
if not name:
die("give the remote a name: terractl remote add <name> <token>")
if not NAME_RE.fullmatch(name):
die(f"invalid remote name '{name}' (letters, digits and _ . -, not starting with . or -)")
remotes = load_remotes()
if name in remotes:
die(f"there's a remote called '{name}' already (to replace it: terractl remote remove {name})")
if len(args) == 3:
address, token = args[1], args[2]
elif args[1].startswith(TOKEN_PREFIX):
address, token = None, args[1]
else:
address = args[1]
if not sys.stdin.isatty():
die("no token given")
token = getpass.getpass("Token (from 'sudo terractl daemon trust add' on that host): ")
fields = read_token(token)
if name in remotes and name != old:
die(f"'{name}' is already the name of another remote; choose another: terractl remote add <name> <token>")
candidates = [split_hostport(address)] if address else [split_hostport(a) for a in fields.get("a", [])]
if not candidates:
die("the token doesn't say where the host is; give its address: "
"terractl remote add <name> <address> <token>")
die("the token doesn't say where the host is; add --address <host>")
join = {"secret": fields["s"], "name": (socket.gethostname() or "").split(".")[0]}
if old:
join["old_key"] = remotes[old].get("key", "")
problems = []
for host, port in candidates:
remote = Remote(name, {"address": host, "port": port, "fingerprint": fields["fp"]})
remote.joining = True
remote.label = remote.addr
try:
reply = remote.call("POST", "/1.0/trust/join", {"secret": fields["s"]}, auth=False, timeout=5)
reply = remote.call("POST", "/1.0/trust/join", join, auth=False, timeout=5)
break
except Unreachable as e:
problems.append(str(e))
@@ -1425,7 +1424,9 @@ def remote_add(args):
if len(problems) == 1:
die(problems[0])
die("couldn't reach the host at any address in the token:\n " + "\n ".join(problems) +
"\nGive an address that works from here: terractl remote add <name> <address> <token>")
"\nGive an address that works from here: terractl remote add <token> --address <host>")
if old:
del remotes[old]
remotes[name] = {"address": host, "port": port, "fingerprint": fields["fp"], "key": reply["key"],
"client_name": reply["name"], "added": time.strftime("%Y-%m-%d %H:%M")}
save_remotes(remotes)
@@ -1437,7 +1438,7 @@ def remote_add(args):
about = f"{info['hostname']} at {remote.addr}, terractl {info['version']}"
except Fail:
about = remote.addr
print(f"added remote '{name}' ({about}); it knows this computer as '{reply['name']}'")
print(f"{'updated' if old else 'added'} remote '{name}' ({about}); it knows this computer as '{reply['name']}'")
print(f"Try: terractl --host {name} list")
return 0
@@ -1447,7 +1448,7 @@ def remote_list(args):
die("usage: terractl remote list")
remotes = load_remotes()
if not remotes:
print("No remotes yet. Add one with: terractl remote add <name> <token>")
print("No remotes yet. Run 'sudo terractl daemon token' on a host, then here: terractl remote add <token>")
return 0
print(f"{'NAME':<20} {'ADDRESS':<30} FINGERPRINT")
for name, conf in sorted(remotes.items()):
@@ -1463,8 +1464,8 @@ def remote_remove(args):
if conf is None:
die(f"there's no remote called '{args[0]}' (see: terractl remote list)")
save_remotes(remotes)
print(f"removed remote '{args[0]}'. It still lists this computer as '{conf.get('client_name', '?')}'; "
f"to revoke that too, run there: sudo terractl daemon trust remove {conf.get('client_name', '<name>')}")
print(f"removed remote '{args[0]}'. It still trusts this computer; to revoke that too, run there: "
f"sudo terractl daemon revoke {conf.get('client_name', '<name>')}")
return 0
@@ -1940,6 +1941,10 @@ if __name__ == "__main__":
except Fail as e:
print(f"terractl: {e}", file=sys.stderr)
sys.exit(1)
except BrokenPipeError:
# Output went into something that stopped reading (e.g. head)
os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
sys.exit(1)
except OSError as e:
where = f": {e.filename}" if e.filename else ""
print(f"terractl: {e.strerror or e}{where}", file=sys.stderr)