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

+1 -1
View File
@@ -11,7 +11,7 @@
- `list` has a new `BOOT` column showing whether each server starts at boot (`enabled`, `disabled`, or e.g. `masked`). - `list` has a new `BOOT` column showing whether each server starts at boot (`enabled`, `disabled`, or e.g. `masked`).
- Manage other hosts, like LXD/Incus remotes: - Manage other hosts, like LXD/Incus remotes:
- `sudo terractl daemon enable` starts `terractld.service`, a daemon other hosts connect to (HTTPS, TCP port 7776; `TERRACTL_LISTEN` in `/etc/default/terractl` changes it). It's off until enabled. `daemon disable` and `daemon status` turn it off and show its state. - `sudo terractl daemon enable` starts `terractld.service`, a daemon other hosts connect to (HTTPS, TCP port 7776; `TERRACTL_LISTEN` in `/etc/default/terractl` changes it). It's off until enabled. `daemon disable` and `daemon status` turn it off and show its state.
- `sudo terractl daemon trust add <name>` creates a one-time token for a computer, valid for 24 hours; `daemon trust list` and `daemon trust remove <name>` show and revoke access. On that computer, `terractl remote add <remote> <token>` adds the host; `remote list` and `remote remove` manage remotes, which are stored per user. - `daemon enable` prints a command with a one-time token (valid for 24 hours); running it on another computer, `terractl remote add <token>`, adds the host as a remote named after its hostname (or `remote add <name> <token>`). `sudo terractl daemon token` makes tokens for more computers, `daemon status` lists the trusted ones, and `daemon revoke <name>` removes one. Adding a host again replaces the old remote, e.g. after a revoke. `remote list` and `remote remove` manage remotes, which are stored per user.
- `terractl --host <remote> <command>` runs any command on a remote, including `attach`, `watch` and `export`/`import`/`mods add` with files from your computer. The daemon runs it as root there, with messages adjusted to `terractl --host <remote> ...`. - `terractl --host <remote> <command>` runs any command on a remote, including `attach`, `watch` and `export`/`import`/`mods add` with files from your computer. The daemon runs it as root there, with messages adjusted to `terractl --host <remote> ...`.
- `terractl copy` and `terractl move` copy, move or rename instances, on one host or between hosts (`[<remote>:]<name>`). Paths and ports follow the destination's settings, and a missing server zip is copied along. `move` stops the source first, starts it again if the transfer fails, and starts the moved instance where it was running before. - `terractl copy` and `terractl move` copy, move or rename instances, on one host or between hosts (`[<remote>:]<name>`). Paths and ports follow the destination's settings, and a missing server zip is copied along. `move` stops the source first, starts it again if the transfer fails, and starts the moved instance where it was running before.
- Security: the token carries the daemon's certificate fingerprint, which the client checks on every connection; each computer gets its own key, and the daemon stores only hashes. The daemon runs as root with systemd sandboxing. - Security: the token carries the daemon's certificate fingerprint, which the client checks on every connection; each computer gets its own key, and the daemon stores only hashes. The daemon runs as root with systemd sandboxing.
+4 -5
View File
@@ -96,13 +96,12 @@ Other hosts:
copy an instance, on this host or between hosts copy an instance, on this host or between hosts
move [<remote>:]<name> [<remote>:][<new name>] [--with-server] move [<remote>:]<name> [<remote>:][<new name>] [--with-server]
move (or rename) an instance; stops it while moving move (or rename) an instance; stops it while moving
remote add <name> <token> add a host to manage (token from 'daemon trust add') remote add [<name>] <token> [--address <host>[:<port>]]
remote add <name> <address> [<token>] ... when the token's addresses don't work from here add a host to manage (token from its 'daemon enable')
remote list | remote remove <name> show or forget remotes remote list | remote remove <name> show or forget remotes
daemon enable|disable|status let other hosts manage this one (terractld.service) daemon enable|disable|status let other hosts manage this one (terractld.service)
daemon trust add <client> create a one-time token for a computer to join daemon token a one-time token for another computer to add this host
daemon trust list | trust remove <client> daemon revoke <name> take a computer's access away
show or revoke computers that may manage this host
Used by terraria@.service: Used by terraria@.service:
journal-access let the terraria group read the servers' journal journal-access let the terraria group read the servers' journal
+1 -3
View File
@@ -153,9 +153,7 @@ _terractl() {
return ;; return ;;
daemon) daemon)
if (( cword == 2 )); then if (( cword == 2 )); then
_terractl_reply "enable disable status trust" _terractl_reply "enable disable status token revoke"
elif (( cword == 3 )) && [[ ${words[2]} == trust ]]; then
_terractl_reply "add list remove"
fi fi
return ;; return ;;
esac esac
+26 -24
View File
@@ -670,7 +670,7 @@ sudo terractl move calamity nas: # this host → nas
terractl copy nas:calamity pi:test # nas → pi, as 'test' terractl copy nas:calamity pi:test # nas → pi, as 'test'
``` ```
### Turning on the daemon ### Setting it up
On the host to be managed: On the host to be managed:
@@ -678,29 +678,31 @@ On the host to be managed:
sudo terractl daemon enable sudo terractl daemon enable
``` ```
This starts `terractld.service` and makes it start at boot. It listens on **TCP port 7776**, on all addresses. If a firewall is active, allow that port (for example `sudo ufw allow 7776/tcp`), ideally only from the computers that will manage the host. To listen on one address or on another port, set `TERRACTL_LISTEN` in `/etc/default/terractl` (e.g. `TERRACTL_LISTEN=10.0.10.77:7776`) and run `sudo systemctl restart terractld`. It prints a command with a one-time token. Run that command on the computer that should manage the host:
`sudo terractl daemon disable` turns it off again. `terractl daemon status` shows whether it's running, where it listens and its certificate's fingerprint, and with `sudo`, which computers it trusts. ```bash
terractl remote add tctl1_eyJ2IjoxLCJmcCI6...
```
That's all: the host is now a remote called by its hostname (say `nas`), and `terractl --host nas list` works. To call it something else, put a name before the token: `terractl remote add nas tctl1_...`. The host, in turn, knows the computer by the computer's hostname.
For each further computer, run `sudo terractl daemon token` on the host for a new token. A token works **once**, **within 24 hours**.
### The daemon
`daemon enable` starts `terractld.service` and makes it start at boot. It listens on **TCP port 7776**, on all addresses. If a firewall is active, allow that port (for example `sudo ufw allow 7776/tcp`), ideally only from the computers that will manage the host. To listen on one address or on another port, set `TERRACTL_LISTEN` in `/etc/default/terractl` (e.g. `TERRACTL_LISTEN=10.0.10.77:7776`) and run `sudo systemctl restart terractld`.
`sudo terractl daemon disable` turns it off again. `terractl daemon status` shows whether it's running, where it listens and its certificate's fingerprint, and with `sudo`, which computers it trusts. `sudo terractl daemon revoke <name>` takes a computer's access away.
The daemon is an ordinary systemd service: systemd starts it at boot, restarts it if it crashes, and keeps its log. `journalctl -u terractld` lists every command it ran and for which computer. The daemon is an ordinary systemd service: systemd starts it at boot, restarts it if it crashes, and keeps its log. `journalctl -u terractld` lists every command it ran and for which computer.
### Adding a remote ### Remotes
Each computer that should manage the host needs a one-time token. On the host with the daemon: The token contains the host's addresses, so usually `remote add` needs nothing else. If none of them works from the computer (behind NAT, for example), add one that does: `terractl remote add tctl1_... --address 203.0.113.5`, or `--address nas.example.com:7800` for another port.
```bash Adding a host that's already a remote replaces that remote, keeping its name unless you give a new one. So if a remote stops working because its access was revoked, just get a new token and add it again.
sudo terractl daemon trust add laptop
```
`laptop` is a name for the computer, used in the host's log and to revoke it later. The command prints what to run on that computer: Remotes are kept per user in `~/.config/terractl/remotes.json`, readable only by you. Under `sudo`, the remotes of the person who ran sudo are used.
```bash
terractl remote add nas tctl1_eyJ2IjoxLCJmcCI6...
```
`nas` is the name you'll use for the host there; choose any. The token contains the host's addresses, so usually nothing else is needed. If none of them works from the computer (behind NAT, for example), add the address: `terractl remote add nas 203.0.113.5 tctl1_...`, or `nas.example.com:7800` for another port.
A token works **once**, **within 24 hours**. Remotes are kept per user in `~/.config/terractl/remotes.json`, readable only by you. Under `sudo`, the remotes of the person who ran sudo are used.
```bash ```bash
terractl remote list # your remotes terractl remote list # your remotes
@@ -757,7 +759,7 @@ On this host, copying *from* it works for members of the `terraria` group. Copyi
- **Encrypted and pinned.** Traffic is HTTPS (TLS 1.2 or newer). The daemon's certificate is self-signed, the token carries its fingerprint, and every connection checks it, so nothing in between can pose as the daemon. If the fingerprint ever changes, `terractl` refuses to connect and says so. - **Encrypted and pinned.** Traffic is HTTPS (TLS 1.2 or newer). The daemon's certificate is self-signed, the token carries its fingerprint, and every connection checks it, so nothing in between can pose as the daemon. If the fingerprint ever changes, `terractl` refuses to connect and says so.
- **Keys, not passwords.** Joining trades the token for a random key for that computer. The daemon keeps only hashes of keys and tokens, in `/var/lib/terractl/trust.json` (root only). - **Keys, not passwords.** Joining trades the token for a random key for that computer. The daemon keeps only hashes of keys and tokens, in `/var/lib/terractl/trust.json` (root only).
- **A trusted computer is an admin of the host's servers.** It can do what `sudo terractl` can: create, delete, import, start and stop instances, and install mods or server files, which run as the `terraria` user. It can't change the daemon or run anything other than `terractl`. Give tokens only to computers you'd trust with `sudo terractl`. - **A trusted computer is an admin of the host's servers.** It can do what `sudo terractl` can: create, delete, import, start and stop instances, and install mods or server files, which run as the `terraria` user. It can't change the daemon or run anything other than `terractl`. Give tokens only to computers you'd trust with `sudo terractl`.
- **Revoking.** `sudo terractl daemon trust list` shows the trusted computers, and `sudo terractl daemon trust remove laptop` takes one's access away immediately. - **Revoking.** `sudo terractl daemon status` shows the trusted computers, and `sudo terractl daemon revoke laptop` takes one's access away immediately.
- **Firewall.** The daemon doesn't need to be reachable from the internet. Limit port 7776 to your LAN or VPN, or set `TERRACTL_LISTEN` to a LAN or VPN address. - **Firewall.** The daemon doesn't need to be reachable from the internet. Limit port 7776 to your LAN or VPN, or set `TERRACTL_LISTEN` to a LAN or VPN address.
The daemon runs as root, because it creates and removes instances and starts services, but with systemd's protections it can't write to `/usr`, `/boot`, `/etc` or home folders. Its certificate, key and trust list are in `/var/lib/terractl/`. Files in transit are kept in `/var/lib/terractl/transfers/`, and removed once used, after 6 hours unused, or when the daemon restarts. The daemon runs as root, because it creates and removes instances and starts services, but with systemd's protections it can't write to `/usr`, `/boot`, `/etc` or home folders. Its certificate, key and trust list are in `/var/lib/terractl/`. Files in transit are kept in `/var/lib/terractl/transfers/`, and removed once used, after 6 hours unused, or when the daemon restarts.
@@ -798,7 +800,7 @@ The servers always run as the `terraria` user. Administration is simplest with `
| `import` | root (or `terraria`) | Run as root, it gives the new instance to `terraria`. | | `import` | root (or `terraria`) | Run as root, it gives the new instance to `terraria`. |
| `prepare`, `run` | Normally only the service | Run as root, they automatically switch to the `terraria` user, so no files end up owned by root. Any other user is refused. | | `prepare`, `run` | Normally only the service | Run as root, they automatically switch to the `terraria` user, so no files end up owned by root. Any other user is refused. |
| `service-stop` | Only the service | Needs the server's process ID from systemd. | | `service-stop` | Only the service | Needs the server's process ID from systemd. |
| `daemon enable`, `daemon disable`, `daemon trust ...` | root only | | | `daemon enable`, `disable`, `token`, `revoke` | root only | |
| `daemon status` | anyone | Shows the trusted computers only with `sudo`. | | `daemon status` | anyone | Shows the trusted computers only with `sudo`. |
| `remote ...`, `--host ...` | anyone | Uses your own remotes. On the remote, the daemon runs the command as root. | | `remote ...`, `--host ...` | anyone | Uses your own remotes. On the remote, the daemon runs the command as root. |
| `copy`, `move` | depends on this host's part | Copying from this host: the group is enough. Copying to it, or moving away from it: root. | | `copy`, `move` | depends on this host's part | Copying from this host: the group is enough. Copying to it, or moving away from it: root. |
@@ -918,7 +920,7 @@ The daemon isn't running on that host (check with `terractl daemon status` there
The daemon has a different certificate than when you added it. That's expected if `/var/lib/terractl` was deleted there, or the package was purged and installed again; remove the remote and add it again. If nothing like that happened, don't work around it: something may be intercepting the connection. The daemon has a different certificate than when you added it. That's expected if `/var/lib/terractl` was deleted there, or the package was purged and installed again; remove the remote and add it again. If nothing like that happened, don't work around it: something may be intercepting the connection.
**`doesn't trust this computer (any more)`.** **`doesn't trust this computer (any more)`.**
Its access was revoked with `daemon trust remove`, or that computer was added again under the same name elsewhere. Get a new token. Its access was revoked with `daemon revoke`, or the host's `/var/lib/terractl` was reset. Run `sudo terractl daemon token` there and `terractl remote add <token>` here; that replaces the old remote.
--- ---
@@ -972,12 +974,12 @@ terractl copy [<remote>:]<name> [<remote>:][<new name>] [--with-server]
Copy an instance, here or between hosts Copy an instance, here or between hosts
terractl move [<remote>:]<name> [<remote>:][<new name>] [--with-server] terractl move [<remote>:]<name> [<remote>:][<new name>] [--with-server]
Move or rename an instance Move or rename an instance
terractl remote add <name> <token> Add a remote (token from 'daemon trust add') terractl remote add [<name>] <token> [--address <host>[:<port>]]
terractl remote add <name> <address> [<token>] ... at an address the token doesn't list Add (or replace) a remote
terractl remote list | remove <name> Show or forget remotes terractl remote list | remove <name> Show or forget remotes
terractl daemon enable|disable|status Let other hosts manage this one terractl daemon enable|disable|status Let other hosts manage this one
terractl daemon trust add|remove <name> Create a token for a computer, or revoke one terractl daemon token A one-time token for another computer
terractl daemon trust list Show trusted computers and unused tokens terractl daemon revoke <name> Take a computer's access away
Used by the service: Used by the service:
terractl prepare <name> Create dirs/configs, unpack zip if needed terractl prepare <name> Create dirs/configs, unpack zip if needed
+104 -99
View File
@@ -14,7 +14,7 @@
# the instance to the destination's folders and free ports. # the instance to the destination's folders and free ports.
# #
# Security: the daemon's certificate is self-signed. A computer joins with a # 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 # fingerprint and a secret. The client checks that fingerprint on every
# connection, so nothing in between can pose as the daemon, and trades the # 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 # secret for a random key of its own, sent with each request. The daemon stores
@@ -27,7 +27,6 @@ import base64
import codecs import codecs
import errno import errno
import fcntl import fcntl
import getpass
import hashlib import hashlib
import hmac import hmac
import http.client import http.client
@@ -212,7 +211,7 @@ def open_state(cmdline):
class StateLock: 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): def __enter__(self):
self.fd = os.open(state_path(".lock"), os.O_RDWR | os.O_CREAT, 0o600) 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})") 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): class Handler(http.server.BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1" protocol_version = "HTTP/1.1"
server_version = "terractld" server_version = "terractld"
@@ -579,7 +587,8 @@ class Handler(http.server.BaseHTTPRequestHandler):
# ---- endpoints # ---- endpoints
def join(self): 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): if not isinstance(secret, str):
raise HttpError(400, "bad request") raise HttpError(400, "bad request")
digest = sha256(secret) digest = sha256(secret)
@@ -593,11 +602,20 @@ class Handler(http.server.BaseHTTPRequestHandler):
save_trust(trust) save_trust(trust)
dlog(f"{self.client_address[0]}: refused a join (unknown, used or expired token)") 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 " 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) trust["tokens"].remove(token)
key = secrets.token_urlsafe(32) key = secrets.token_urlsafe(32)
name = token["name"] # A computer that's trusted already (it sends its old key) keeps its
trust["clients"] = [c for c in trust["clients"] if c.get("name") != name] # 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), trust["clients"].append({"name": name, "key_sha256": sha256(key),
"added": time.strftime("%Y-%m-%d %H:%M"), "added": time.strftime("%Y-%m-%d %H:%M"),
"from": self.client_address[0]}) "from": self.client_address[0]})
@@ -969,11 +987,12 @@ def daemon_enable():
if not wait_listening(host, port, 10): if not wait_listening(host, port, 10):
die(f"terractld isn't listening on {describe_listen(host, port)}; see: journalctl -u terractld") 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"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(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 return 0
@@ -983,7 +1002,7 @@ def daemon_disable():
if proc.returncode != 0: if proc.returncode != 0:
die(f"stopping terractld failed: {proc.stderr.strip()}") 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("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 return 0
@@ -1001,76 +1020,54 @@ def daemon_status():
except PermissionError: except PermissionError:
print("trusted: (run with sudo to see which computers)") print("trusted: (run with sudo to see which computers)")
trust = None trust = None
if trust is not None: if trust is not None and not trust["clients"]:
names = ", ".join(c["name"] for c in trust["clients"]) or "none yet" print("trusted: none yet (add one: sudo terractl daemon token)")
print(f"trusted: {names}") elif trust is not None:
waiting = [t["name"] for t in trust["tokens"] if t["expires"] > time.time()] print()
if waiting: print(f"{'TRUSTED':<24} {'ADDED':<17} FROM")
print(f"tokens for: {', '.join(waiting)} (not used yet)") 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": if active != "active":
print("Start it with: sudo terractl daemon enable") print("Start it with: sudo terractl daemon enable")
return 0 return 0
def trust_add(name): def print_token():
if not CLIENT_RE.fullmatch(name): """Creates a one-time token and prints the command that uses it."""
die(f"invalid name '{name}' (letters, digits and _ . - @, up to 64)") open_state("daemon token")
open_state(f"daemon trust add {name}")
ensure_cert() ensure_cert()
secret = secrets.token_hex(32) secret = secrets.token_hex(32)
with StateLock(): with StateLock():
trust = load_trust() trust = load_trust()
now = time.time() now = time.time()
trust["tokens"] = [t for t in trust["tokens"] if t["expires"] > now and t["name"] != name] trust["tokens"] = [t for t in trust["tokens"] if t["expires"] > now]
trust["tokens"].append({"name": name, "secret_sha256": sha256(secret), trust["tokens"].append({"secret_sha256": sha256(secret), "expires": int(now + TOKEN_HOURS * 3600)})
"expires": int(now + TOKEN_HOURS * 3600)})
save_trust(trust) save_trust(trust)
known = any(c["name"] == name for c in trust["clients"])
host, port = listen_setting() 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] here = (socket.gethostname() or "").split(".")[0]
if not NAME_RE.fullmatch(here): if NAME_RE.fullmatch(here):
here = "<name>" fields["n"] = here # the remote's name, unless the other computer picks one
print(f"On '{name}', run this to add this host as a remote called '{here}':") print(f"To manage this host from another computer, run this there (works once, within {TOKEN_HOURS} hours):")
print() print()
print(f" terractl remote add {here} {token}") print(f" terractl remote add {make_token(fields)}")
print() 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": if systemctl("is-active", DAEMON_UNIT).stdout.strip() != "active":
print("The daemon isn't running yet; start it with: sudo terractl daemon enable") print("The daemon isn't running yet; start it with: sudo terractl daemon enable")
return 0 return 0
def trust_list(): def daemon_revoke(name):
try: open_state(f"daemon revoke {name}")
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}")
with StateLock(): with StateLock():
trust = load_trust() trust = load_trust()
clients = [c for c in trust["clients"] if c["name"] != name] 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"]):
if len(clients) == len(trust["clients"]) and len(tokens) == len(trust["tokens"]): die(f"no computer called '{name}' is trusted (see: sudo terractl daemon status)")
die(f"'{name}' isn't trusted (see: terractl daemon trust list)") trust["clients"] = clients
trust["clients"], trust["tokens"] = clients, tokens
save_trust(trust) save_trust(trust)
print(f"'{name}' can't manage this host any more.") print(f"'{name}' can't manage this host any more.")
return 0 return 0
@@ -1078,20 +1075,13 @@ def trust_remove(name):
def daemon_main(argv): def daemon_main(argv):
sub, rest = (argv[0], argv[1:]) if argv else ("", []) sub, rest = (argv[0], argv[1:]) if argv else ("", [])
if sub == "trust": if sub == "revoke" and len(rest) == 1:
what = rest[0] if rest else "list" return daemon_revoke(rest[0])
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>")
commands = {"enable": daemon_enable, "disable": daemon_disable, "status": daemon_status, 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: if sub in commands and not rest:
return commands[sub]() return commands[sub]()
die("usage: terractl daemon enable | disable | status | trust ...") die("usage: terractl daemon enable | disable | status | token | revoke <name>")
# --------------------------------------------------------------- client ---- # --------------------------------------------------------------- client ----
@@ -1244,9 +1234,8 @@ class Remote:
except (ValueError, KeyError, TypeError): except (ValueError, KeyError, TypeError):
message = f"HTTP error {resp.status}" message = f"HTTP error {resp.status}"
if resp.status == 401: if resp.status == 401:
die(f"{self.label} doesn't trust this computer (any more). On {self.label}, run " die(f"{self.label} doesn't trust this computer (any more). Run 'sudo terractl daemon "
f"'sudo terractl daemon trust add <name for this computer>', then here: terractl " f"token' there, then here: terractl remote add <token>")
f"remote remove {self.name} && terractl remote add {self.name} {self.addr} <token>")
die(f"{self.label}: {message}") die(f"{self.label}: {message}")
return resp return resp
@@ -1384,40 +1373,50 @@ def find_remote(spec):
if host and conf["address"].lower() == host.lower() and int(conf["port"]) == port: if host and conf["address"].lower() == host.lower() and int(conf["port"]) == port:
return Remote(name, conf, via=f"--host {spec}") 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 " 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"'sudo terractl daemon token' there, then here: terractl remote add <token>")
f"terractl remote add <name> <token>")
def remote_add(args): def remote_add(args):
if len(args) not in (2, 3): usage = "usage: terractl remote add [<name>] <token> [--address <host>[:<port>]]"
die("usage: terractl remote add <name> <token>, or: terractl remote add <name> <address> [<token>]") address, rest = None, []
name = args[0] 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): if not NAME_RE.fullmatch(name):
die(f"invalid remote name '{name}' (letters, digits and _ . -, not starting with . or -)") die(f"invalid remote name '{name}' (letters, digits and _ . -, not starting with . or -)")
remotes = load_remotes() if name in remotes and name != old:
if name in remotes: die(f"'{name}' is already the name of another remote; choose another: terractl remote add <name> <token>")
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)
candidates = [split_hostport(address)] if address else [split_hostport(a) for a in fields.get("a", [])] candidates = [split_hostport(address)] if address else [split_hostport(a) for a in fields.get("a", [])]
if not candidates: if not candidates:
die("the token doesn't say where the host is; give its address: " die("the token doesn't say where the host is; add --address <host>")
"terractl remote add <name> <address> <token>") join = {"secret": fields["s"], "name": (socket.gethostname() or "").split(".")[0]}
if old:
join["old_key"] = remotes[old].get("key", "")
problems = [] problems = []
for host, port in candidates: for host, port in candidates:
remote = Remote(name, {"address": host, "port": port, "fingerprint": fields["fp"]}) remote = Remote(name, {"address": host, "port": port, "fingerprint": fields["fp"]})
remote.joining = True remote.joining = True
remote.label = remote.addr remote.label = remote.addr
try: 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 break
except Unreachable as e: except Unreachable as e:
problems.append(str(e)) problems.append(str(e))
@@ -1425,7 +1424,9 @@ def remote_add(args):
if len(problems) == 1: if len(problems) == 1:
die(problems[0]) die(problems[0])
die("couldn't reach the host at any address in the token:\n " + "\n ".join(problems) + 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"], remotes[name] = {"address": host, "port": port, "fingerprint": fields["fp"], "key": reply["key"],
"client_name": reply["name"], "added": time.strftime("%Y-%m-%d %H:%M")} "client_name": reply["name"], "added": time.strftime("%Y-%m-%d %H:%M")}
save_remotes(remotes) save_remotes(remotes)
@@ -1437,7 +1438,7 @@ def remote_add(args):
about = f"{info['hostname']} at {remote.addr}, terractl {info['version']}" about = f"{info['hostname']} at {remote.addr}, terractl {info['version']}"
except Fail: except Fail:
about = remote.addr 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") print(f"Try: terractl --host {name} list")
return 0 return 0
@@ -1447,7 +1448,7 @@ def remote_list(args):
die("usage: terractl remote list") die("usage: terractl remote list")
remotes = load_remotes() remotes = load_remotes()
if not 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 return 0
print(f"{'NAME':<20} {'ADDRESS':<30} FINGERPRINT") print(f"{'NAME':<20} {'ADDRESS':<30} FINGERPRINT")
for name, conf in sorted(remotes.items()): for name, conf in sorted(remotes.items()):
@@ -1463,8 +1464,8 @@ def remote_remove(args):
if conf is None: if conf is None:
die(f"there's no remote called '{args[0]}' (see: terractl remote list)") die(f"there's no remote called '{args[0]}' (see: terractl remote list)")
save_remotes(remotes) save_remotes(remotes)
print(f"removed remote '{args[0]}'. It still lists this computer as '{conf.get('client_name', '?')}'; " print(f"removed remote '{args[0]}'. It still trusts this computer; to revoke that too, run there: "
f"to revoke that too, run there: sudo terractl daemon trust remove {conf.get('client_name', '<name>')}") f"sudo terractl daemon revoke {conf.get('client_name', '<name>')}")
return 0 return 0
@@ -1940,6 +1941,10 @@ if __name__ == "__main__":
except Fail as e: except Fail as e:
print(f"terractl: {e}", file=sys.stderr) print(f"terractl: {e}", file=sys.stderr)
sys.exit(1) 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: except OSError as e:
where = f": {e.filename}" if e.filename else "" where = f": {e.filename}" if e.filename else ""
print(f"terractl: {e.strerror or e}{where}", file=sys.stderr) print(f"terractl: {e.strerror or e}{where}", file=sys.stderr)
+1 -1
View File
@@ -8,7 +8,7 @@ Wants=network-online.target
Type=simple Type=simple
# Listens on TERRACTL_LISTEN from /etc/default/terractl (default: all addresses, # Listens on TERRACTL_LISTEN from /etc/default/terractl (default: all addresses,
# port 7776). Its certificate and the list of trusted computers are kept in # port 7776). Its certificate and the list of trusted computers are kept in
# /var/lib/terractl. Manage it with: terractl daemon enable|disable|status|trust # /var/lib/terractl. Manage it with: terractl daemon enable|disable|status|token|revoke
ExecStart=@BINDIR@/terractl daemon serve ExecStart=@BINDIR@/terractl daemon serve
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5