simplify remotes
This commit is contained in:
1 parent
02017d470f
commit
998d9f603d
6 files changed
+137
-133
No files matched your search
+1
-1
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user