release / deb (push) Successful in 17s
- doctor [<name>] [--fix]: checks the system (user, programs, folders, zips, units and leftover copies in /etc, sandbox access to moved folders, journal, polkit, daemon, orphaned boot links and schedules) and each instance (ownership, server zip, config paths, mods, ports, failed starts/backups), with a fix for each. --fix applies the safe ones and re-checks them. - backup enable|disable|now|list: a systemd timer per instance (terraria-backup@.timer, Persistent=), saving the world first, into /var/lib/terraria/backups/<name>/ (group-readable), pruning only scheduled backups. rename carries the schedule, delete removes it. upgrade's backups move there too. - fetch vanilla|tmodloader [<version>] [--list] [--upgrade <name>]: downloads from terraria.org / tModLoader's GitHub releases, checks GitHub's sha256 and the zip, names it with its version. - terractld may write /etc/systemd/system, for backup enable and doctor --fix over --host. - Units, tmpfiles, packaging, completion, docs and changelog. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1955 lines
75 KiB
Python
Executable File
1955 lines
75 KiB
Python
Executable File
#!/usr/bin/python3
|
|
# terractl-remote - manage Terraria servers on other hosts (terraria-server-manager)
|
|
#
|
|
# Run by terractl (never directly) for:
|
|
# terractl daemon ... the daemon other hosts connect to (terractld.service)
|
|
# terractl remote ... the hosts this user manages ("remotes")
|
|
# terractl --host <remote> ... any terractl command, run on a remote
|
|
# terractl copy|move ... instances between hosts, or within one
|
|
#
|
|
# How it fits together: the daemon is a small HTTPS server that does everything
|
|
# by running terractl on its own host, so paths, checks and file ownership are
|
|
# exactly as if someone had run terractl there. Copy and move are 'export' on
|
|
# the source, a file transfer, and 'import' on the destination; import adapts
|
|
# 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 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
|
|
# only SHA-256 hashes of keys and secrets. A trusted computer can do what
|
|
# 'sudo terractl' can do on that host, except change the daemon itself.
|
|
#
|
|
# Python standard library only (3.9 or newer).
|
|
|
|
import base64
|
|
import codecs
|
|
import errno
|
|
import fcntl
|
|
import hashlib
|
|
import hmac
|
|
import http.client
|
|
import http.server
|
|
import json
|
|
import os
|
|
import pwd
|
|
import re
|
|
import secrets
|
|
import select
|
|
import shlex
|
|
import shutil
|
|
import signal
|
|
import socket
|
|
import socketserver
|
|
import ssl
|
|
import stat
|
|
import subprocess
|
|
import sys
|
|
import tarfile
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import traceback
|
|
import urllib.parse
|
|
from pathlib import PurePosixPath
|
|
|
|
API = 1 # changes only if old and new versions can't talk
|
|
DEFAULT_PORT = 7776 # one below the first game port terractl hands out
|
|
DAEMON_UNIT = "terractld.service"
|
|
TOKEN_PREFIX = "tctl1_"
|
|
TOKEN_HOURS = 24
|
|
TRANSFER_HOURS = 6 # files sent to the daemon but never used are removed after this
|
|
CHUNK = 1024 * 1024
|
|
PING_SECONDS = 20 # a quiet stream sends a ping this often...
|
|
STREAM_TIMEOUT = 90 # ...so hearing nothing for this long means the connection is gone
|
|
IO_TIMEOUT = 120
|
|
MAX_CONNECTIONS = 64
|
|
|
|
TERRACTL = os.environ.get("TERRACTL_BIN") or "terractl"
|
|
VERSION = os.environ.get("TERRACTL_VERSION") or "development"
|
|
STATE_DIR = os.environ.get("TERRACTL_STATE_DIR") or "/var/lib/terractl"
|
|
CERT, KEY, TRUST = "server.crt", "server.key", "trust.json"
|
|
|
|
NAME_RE = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_.-]*") # instances and remotes (valid_name)
|
|
CLIENT_RE = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_.@-]{0,63}")
|
|
ZIP_RE = re.compile(r"[A-Za-z0-9_][A-Za-z0-9._+-]*\.zip")
|
|
OUTPUT_RE = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_.-]*\.(tar\.gz|tgz)")
|
|
VIA_RE = re.compile(r"--host [A-Za-z0-9_.:\[\]%-]{1,200}")
|
|
|
|
|
|
class Fail(Exception):
|
|
"""Shown to the user as 'terractl: <message>'; exit status 1."""
|
|
|
|
|
|
class Unreachable(Fail):
|
|
"""Couldn't connect to a daemon, or it wasn't the expected one."""
|
|
|
|
|
|
def die(message):
|
|
raise Fail(message)
|
|
|
|
|
|
def log(message):
|
|
print(f"terractl: {message}", file=sys.stderr, flush=True)
|
|
|
|
|
|
def human_size(n):
|
|
if n < 1000:
|
|
return f"{n} bytes"
|
|
for unit in ("KB", "MB", "GB", "TB"):
|
|
n /= 1000
|
|
if n < 1000 or unit == "TB":
|
|
return f"{n:.1f} {unit}"
|
|
|
|
|
|
def sha256(text):
|
|
return hashlib.sha256(text.encode()).hexdigest()
|
|
|
|
|
|
def split_hostport(text, default_port=DEFAULT_PORT):
|
|
"""'host', 'host:port', '[v6addr]:port', 'v6addr' or 'port' -> (host, port).
|
|
An empty host means all addresses."""
|
|
text = text.strip()
|
|
host, port = text, ""
|
|
if text.startswith("["):
|
|
host, sep, rest = text[1:].partition("]")
|
|
if not sep or (rest and not rest.startswith(":")):
|
|
die(f"invalid address '{text}'")
|
|
port = rest[1:]
|
|
elif text.isdigit():
|
|
host, port = "", text
|
|
elif text.count(":") == 1:
|
|
host, port = text.split(":")
|
|
if port == "":
|
|
return host, default_port
|
|
if not port.isdigit() or not 0 < int(port) < 65536:
|
|
die(f"invalid port in '{text}'")
|
|
return host, int(port)
|
|
|
|
|
|
def join_hostport(host, port):
|
|
return f"[{host}]:{port}" if ":" in host else f"{host}:{port}"
|
|
|
|
|
|
def describe_listen(host, port):
|
|
if host in ("", "::"):
|
|
return f"all addresses, port {port}"
|
|
if host == "0.0.0.0":
|
|
return f"all IPv4 addresses, port {port}"
|
|
return join_hostport(host, port)
|
|
|
|
|
|
def listen_setting():
|
|
"""Where the daemon listens: TERRACTL_LISTEN from /etc/default/terractl."""
|
|
return split_hostport(os.environ.get("TERRACTL_LISTEN", ""))
|
|
|
|
|
|
def parse_info(text):
|
|
"""'terractl info' output -> {key: [values]} (some keys repeat)."""
|
|
info = {}
|
|
for line in text.splitlines():
|
|
key, sep, value = line.partition("=")
|
|
if sep:
|
|
info.setdefault(key, []).append(value)
|
|
return info
|
|
|
|
|
|
def one(info, key, default=""):
|
|
values = info.get(key)
|
|
return values[-1] if values else default
|
|
|
|
|
|
def terractl(args, capture=False, show_stdout=True):
|
|
"""Runs terractl on this host. Its messages go straight to the terminal."""
|
|
if capture:
|
|
stdout = subprocess.PIPE
|
|
else:
|
|
stdout = None if show_stdout else subprocess.DEVNULL
|
|
try:
|
|
return subprocess.run([TERRACTL, *args], stdout=stdout, text=True)
|
|
except OSError as e:
|
|
die(f"can't run {TERRACTL}: {e.strerror}")
|
|
|
|
|
|
def systemctl(*args):
|
|
try:
|
|
return subprocess.run(["systemctl", *args], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
except OSError:
|
|
return subprocess.CompletedProcess(args, 1, "", "systemctl not found")
|
|
|
|
|
|
def big_tempdir():
|
|
"""A private folder for archives, on disk rather than in memory (/tmp may be tmpfs)."""
|
|
try:
|
|
return tempfile.mkdtemp(prefix="terractl-", dir="/var/tmp")
|
|
except OSError:
|
|
return tempfile.mkdtemp(prefix="terractl-")
|
|
|
|
|
|
# ---------------------------------------------------------- daemon state ----
|
|
# <STATE_DIR>/server.crt, server.key the daemon's certificate
|
|
# /trust.json trusted computers and waiting tokens (hashed)
|
|
# /transfers/ files in transit (cleared when the daemon starts)
|
|
|
|
def state_path(*parts):
|
|
return os.path.join(STATE_DIR, *parts)
|
|
|
|
|
|
def need_root(cmdline):
|
|
if os.geteuid() != 0:
|
|
die(f"this needs root: sudo terractl {cmdline}")
|
|
|
|
|
|
def open_state(cmdline):
|
|
try:
|
|
os.makedirs(STATE_DIR, mode=0o755, exist_ok=True)
|
|
except PermissionError:
|
|
die(f"this needs root: sudo terractl {cmdline}")
|
|
if not os.access(STATE_DIR, os.W_OK):
|
|
die(f"this needs root: sudo terractl {cmdline}")
|
|
|
|
|
|
class StateLock:
|
|
"""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)
|
|
fcntl.flock(self.fd, fcntl.LOCK_EX)
|
|
|
|
def __exit__(self, *exc):
|
|
os.close(self.fd)
|
|
|
|
|
|
def ensure_cert():
|
|
crt, key = state_path(CERT), state_path(KEY)
|
|
if os.path.exists(crt) and os.path.exists(key):
|
|
return
|
|
with StateLock():
|
|
if os.path.exists(crt) and os.path.exists(key):
|
|
return
|
|
host = socket.gethostname() or "localhost"
|
|
new_crt, new_key = crt + ".new", key + ".new"
|
|
old_umask = os.umask(0o077)
|
|
try:
|
|
proc = subprocess.run(
|
|
["openssl", "req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1",
|
|
"-sha256", "-nodes", "-days", "3650", "-subj", f"/CN=terractld@{host}",
|
|
"-keyout", new_key, "-out", new_crt],
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True)
|
|
except OSError:
|
|
die("openssl is not installed (sudo apt install openssl)")
|
|
finally:
|
|
os.umask(old_umask)
|
|
if proc.returncode != 0:
|
|
die(f"creating the daemon's certificate failed: {proc.stderr.strip()}")
|
|
os.chmod(new_crt, 0o644)
|
|
os.replace(new_key, key)
|
|
os.replace(new_crt, crt)
|
|
|
|
|
|
def cert_fingerprint():
|
|
with open(state_path(CERT)) as f:
|
|
return hashlib.sha256(ssl.PEM_cert_to_DER_cert(f.read())).hexdigest()
|
|
|
|
|
|
def load_trust():
|
|
try:
|
|
with open(state_path(TRUST)) as f:
|
|
trust = json.load(f)
|
|
except FileNotFoundError:
|
|
trust = {}
|
|
except ValueError as e:
|
|
die(f"{state_path(TRUST)} is damaged ({e})")
|
|
trust.setdefault("clients", [])
|
|
trust.setdefault("tokens", [])
|
|
return trust
|
|
|
|
|
|
def save_trust(trust):
|
|
tmp = state_path(TRUST + ".new")
|
|
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
with os.fdopen(fd, "w") as f:
|
|
json.dump(trust, f, indent=2)
|
|
f.write("\n")
|
|
os.replace(tmp, state_path(TRUST))
|
|
|
|
|
|
def make_token(fields):
|
|
raw = json.dumps(fields, separators=(",", ":")).encode()
|
|
return TOKEN_PREFIX + base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
|
|
|
|
|
def read_token(text):
|
|
text = text.strip()
|
|
if not text.startswith(TOKEN_PREFIX):
|
|
die(f"that isn't a terractl token (they start with {TOKEN_PREFIX})")
|
|
body = text[len(TOKEN_PREFIX):]
|
|
try:
|
|
fields = json.loads(base64.urlsafe_b64decode(body + "=" * (-len(body) % 4)))
|
|
except ValueError:
|
|
fields = None
|
|
if (not isinstance(fields, dict) or not isinstance(fields.get("s"), str)
|
|
or not re.fullmatch(r"[0-9a-f]{64}", str(fields.get("fp", "")))):
|
|
die("the token is damaged; copy it again (it's one long line)")
|
|
return fields
|
|
|
|
|
|
def host_addresses(listen_host, port):
|
|
"""Addresses to put in a token, likeliest to be reachable first."""
|
|
if listen_host not in ("", "::", "0.0.0.0"):
|
|
return [join_hostport(listen_host, port)]
|
|
found = []
|
|
try:
|
|
# The address of the default route; nothing is actually sent
|
|
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
|
s.connect(("192.0.2.1", 9))
|
|
found.append(s.getsockname()[0])
|
|
except OSError:
|
|
pass
|
|
try:
|
|
found += subprocess.run(["hostname", "-I"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
|
text=True, timeout=5).stdout.split()
|
|
except (OSError, subprocess.SubprocessError):
|
|
pass
|
|
result = []
|
|
for addr in found:
|
|
if addr.startswith(("127.", "fe80:")) or addr == "::1" or addr in result:
|
|
continue
|
|
if listen_host == "0.0.0.0" and ":" in addr:
|
|
continue
|
|
result.append(addr)
|
|
return [join_hostport(a, port) for a in result]
|
|
|
|
|
|
# --------------------------------------------------------------- daemon ----
|
|
|
|
EXEC_COMMANDS = {"list", "info", "new", "start", "stop", "restart", "enable", "disable", "cmd", "watch",
|
|
"mods", "reinstall", "upgrade", "fix-perms", "doctor", "backup", "fetch", "delete", "rename", "export", "import",
|
|
"-V", "--version", "version", "-h", "--help", "help"}
|
|
|
|
|
|
class HttpError(Exception):
|
|
def __init__(self, status, message):
|
|
super().__init__(message)
|
|
self.status = status
|
|
|
|
|
|
def dlog(message):
|
|
"""The daemon's log (the journal, under systemd)."""
|
|
print(message, file=sys.stderr, flush=True)
|
|
|
|
|
|
def child_env(via=""):
|
|
"""Environment for terractl run by the daemon. Minimal, and in particular
|
|
without systemd's INVOCATION_ID, which 'terractl stop' would take to mean
|
|
it's being run as terraria@.service's ExecStop."""
|
|
env = {"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
|
"LANG": "C.UTF-8", "HOME": os.environ.get("HOME", "/root"), "TERM": "dumb",
|
|
"SYSTEMD_COLORS": "0", "SYSTEMD_PAGER": "", "PAGER": "cat"}
|
|
env.update((k, v) for k, v in os.environ.items() if k.startswith("TERRARIA_"))
|
|
if via:
|
|
env["TERRACTL_VIA"] = via
|
|
return env
|
|
|
|
|
|
def local_host_info(env=None):
|
|
proc = subprocess.run([TERRACTL, "info"], stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
text=True, cwd="/", env=env)
|
|
if proc.returncode != 0:
|
|
raise RuntimeError(f"terractl info failed: {proc.stderr.strip()}")
|
|
return parse_info(proc.stdout)
|
|
|
|
|
|
class Transfers:
|
|
"""Files sent to the daemon, and exports waiting to be fetched. Each lives in
|
|
its own folder under <state>/transfers and is used once."""
|
|
|
|
def __init__(self, root):
|
|
self.root = root
|
|
self.lock = threading.Lock()
|
|
self.items = {}
|
|
shutil.rmtree(root, ignore_errors=True) # left over from before a restart
|
|
os.makedirs(root, mode=0o700)
|
|
|
|
def new_dir(self):
|
|
return tempfile.mkdtemp(dir=self.root)
|
|
|
|
def add(self, path):
|
|
tid = secrets.token_hex(16)
|
|
with self.lock:
|
|
self.items[tid] = {"path": path, "dir": os.path.dirname(path), "time": time.time()}
|
|
return tid
|
|
|
|
def take(self, tid):
|
|
with self.lock:
|
|
item = self.items.pop(tid, None) if isinstance(tid, str) else None
|
|
if item is None:
|
|
raise HttpError(404, f"that file is gone (unused files are removed after {TRANSFER_HOURS} hours)")
|
|
return item
|
|
|
|
@staticmethod
|
|
def remove(item):
|
|
shutil.rmtree(item["dir"], ignore_errors=True)
|
|
|
|
def expire(self):
|
|
cutoff = time.time() - TRANSFER_HOURS * 3600
|
|
with self.lock:
|
|
old = [tid for tid, item in self.items.items() if item["time"] < cutoff]
|
|
gone = [self.items.pop(tid) for tid in old]
|
|
for item in gone:
|
|
self.remove(item)
|
|
|
|
|
|
def extract_upload(tar_path, dest):
|
|
"""Unpacks the files and folders sent for 'mods add'. Only plain files and
|
|
folders are accepted, and nothing may land outside dest."""
|
|
try:
|
|
with tarfile.open(tar_path) as tar:
|
|
for member in tar:
|
|
parts = PurePosixPath(member.name).parts
|
|
if not parts:
|
|
continue
|
|
if parts[0] == "/" or ".." in parts:
|
|
raise HttpError(400, f"unsafe path in the files sent: {member.name}")
|
|
target = os.path.join(dest, *parts)
|
|
if member.isdir():
|
|
os.makedirs(target, mode=0o755, exist_ok=True)
|
|
elif member.isfile():
|
|
os.makedirs(os.path.dirname(target), mode=0o755, exist_ok=True)
|
|
with tar.extractfile(member) as src, open(target, "xb") as out:
|
|
shutil.copyfileobj(src, out, CHUNK)
|
|
os.chmod(target, 0o644)
|
|
else:
|
|
raise HttpError(400, f"the files sent include a link or special file: {member.name}")
|
|
except (tarfile.TarError, FileExistsError) as 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):
|
|
protocol_version = "HTTP/1.1"
|
|
server_version = "terractld"
|
|
sys_version = ""
|
|
timeout = IO_TIMEOUT
|
|
|
|
def log_message(self, format, *args):
|
|
pass # requests are logged by what they do
|
|
|
|
def do_GET(self):
|
|
self.dispatch()
|
|
|
|
do_POST = do_PUT = do_DELETE = do_GET
|
|
|
|
def dispatch(self):
|
|
self.who = None
|
|
self.started = self.streaming = self.body_read = False
|
|
try:
|
|
if "Transfer-Encoding" in self.headers:
|
|
raise HttpError(411, "send a Content-Length")
|
|
path = urllib.parse.urlsplit(self.path).path
|
|
parts = [urllib.parse.unquote(p) for p in path.strip("/").split("/")]
|
|
if parts[0] != "1.0":
|
|
raise HttpError(404, "not found")
|
|
self.route(self.command, parts[1:])
|
|
except HttpError as e:
|
|
self.fail(e.status, str(e))
|
|
except (ConnectionError, TimeoutError, ssl.SSLError):
|
|
self.close_connection = True
|
|
except OSError as e:
|
|
self.fail(500, e.strerror or str(e))
|
|
except Exception:
|
|
dlog(f"{self.who or self.client_address[0]}: {self.command} {self.path} failed:\n"
|
|
f"{traceback.format_exc()}")
|
|
self.fail(500, "internal error; see the daemon's log: journalctl -u terractld")
|
|
|
|
def route(self, method, p):
|
|
if method == "POST" and p == ["trust", "join"]:
|
|
return self.join()
|
|
self.who = self.authenticate()
|
|
if method == "GET" and p == []:
|
|
return self.send_json(200, self.server.host_info())
|
|
if method == "POST" and p == ["exec"]:
|
|
return self.run_command()
|
|
if method == "PUT" and p == ["files"]:
|
|
return self.put_file()
|
|
if len(p) == 2 and p[0] == "files" and method in ("GET", "DELETE"):
|
|
item = self.server.transfers.take(p[1])
|
|
try:
|
|
if method == "GET":
|
|
self.send_file(item["path"])
|
|
else:
|
|
self.send_json(200, {})
|
|
finally:
|
|
self.server.transfers.remove(item)
|
|
return
|
|
if len(p) == 2 and p[0] == "console" and method == "POST":
|
|
return self.console(p[1])
|
|
if len(p) == 2 and p[0] == "dist" and method in ("GET", "PUT"):
|
|
return self.get_zip(p[1]) if method == "GET" else self.put_zip(p[1])
|
|
raise HttpError(404, "not found")
|
|
|
|
# ---- plumbing
|
|
|
|
def authenticate(self):
|
|
auth = self.headers.get("Authorization", "")
|
|
if auth.startswith("Bearer "):
|
|
digest = sha256(auth[7:].strip())
|
|
for client in load_trust()["clients"]:
|
|
if hmac.compare_digest(client.get("key_sha256", ""), digest):
|
|
return client["name"]
|
|
dlog(f"{self.client_address[0]}: refused (not a trusted key)")
|
|
raise HttpError(401, "not a trusted key")
|
|
|
|
def fail(self, status, message):
|
|
self.close_connection = True
|
|
try:
|
|
if self.streaming:
|
|
self.frame({"error": message})
|
|
elif not self.started:
|
|
# Closing with unread data would reset the connection before the
|
|
# client reads the error; small bodies are read first (big ones
|
|
# are uploads, which clients only start once they know they're in)
|
|
length = self.content_length() if not self.body_read else 0
|
|
if length <= 1 << 20:
|
|
self.rfile.read(length)
|
|
self.send_json(status, {"error": message}, close=True)
|
|
except (OSError, HttpError):
|
|
pass
|
|
|
|
def send_json(self, status, obj, close=False):
|
|
body = (json.dumps(obj) + "\n").encode()
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
if close:
|
|
self.send_header("Connection", "close")
|
|
self.close_connection = True
|
|
self.end_headers()
|
|
self.started = True
|
|
self.wfile.write(body)
|
|
|
|
def content_length(self):
|
|
try:
|
|
length = int(self.headers.get("Content-Length", "0"))
|
|
except ValueError:
|
|
length = -1
|
|
if length < 0:
|
|
raise HttpError(400, "bad Content-Length")
|
|
return length
|
|
|
|
def read_json(self):
|
|
length = self.content_length()
|
|
if length > 1 << 20:
|
|
raise HttpError(413, "request too large")
|
|
self.body_read = True
|
|
try:
|
|
data = json.loads(self.rfile.read(length) or b"{}")
|
|
except ValueError:
|
|
data = None
|
|
if not isinstance(data, dict):
|
|
raise HttpError(400, "bad request")
|
|
return data
|
|
|
|
def receive(self, path, length):
|
|
"""Saves the request body to a new file."""
|
|
self.body_read = True
|
|
with open(path, "xb") as f:
|
|
left = length
|
|
while left:
|
|
data = self.rfile.read(min(CHUNK, left))
|
|
if not data:
|
|
raise HttpError(400, "the upload was cut off")
|
|
f.write(data)
|
|
left -= len(data)
|
|
|
|
def send_file(self, path):
|
|
with open(path, "rb") as f:
|
|
size = os.fstat(f.fileno()).st_size
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "application/octet-stream")
|
|
self.send_header("Content-Length", str(size))
|
|
self.end_headers()
|
|
self.started = True
|
|
shutil.copyfileobj(f, self.wfile, CHUNK)
|
|
|
|
def frame(self, obj):
|
|
self.wfile.write((json.dumps(obj) + "\n").encode())
|
|
|
|
# ---- endpoints
|
|
|
|
def join(self):
|
|
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)
|
|
with StateLock():
|
|
trust = load_trust()
|
|
now = time.time()
|
|
trust["tokens"] = [t for t in trust["tokens"] if t.get("expires", 0) > now]
|
|
token = next((t for t in trust["tokens"]
|
|
if hmac.compare_digest(t.get("secret_sha256", ""), digest)), None)
|
|
if token is None:
|
|
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 token")
|
|
trust["tokens"].remove(token)
|
|
key = secrets.token_urlsafe(32)
|
|
# 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]})
|
|
save_trust(trust)
|
|
dlog(f"{self.client_address[0]}: joined as '{name}'")
|
|
self.send_json(200, {"name": name, "key": key})
|
|
|
|
def resolve(self, args, cleanup):
|
|
"""The command line to run from a client's arguments. Where terractl takes
|
|
file paths, only files sent to the daemon are accepted, never paths on
|
|
this host: import <name> <file>, export <name> [file], mods <name> add
|
|
<files or folders>."""
|
|
if not isinstance(args, list) or not args or not all(isinstance(a, (str, dict)) for a in args):
|
|
raise HttpError(400, "bad request")
|
|
command = args[0]
|
|
if not isinstance(command, str) or command not in EXEC_COMMANDS:
|
|
raise HttpError(400, f"'{command}' can't be run from another host")
|
|
mods_add = command == "mods" and len(args) > 2 and args[2] == "add"
|
|
argv, shown, output = [command], [command], None
|
|
for i, arg in enumerate(args[1:], start=1):
|
|
if isinstance(arg, str):
|
|
takes_path = ((command == "import" and i == 2)
|
|
or (command == "export" and i >= 2 and arg != "--with-server")
|
|
or (mods_add and i >= 3))
|
|
if takes_path or "\0" in arg:
|
|
raise HttpError(400, f"'{command}' takes files sent along with the request, "
|
|
"not paths on the daemon's host")
|
|
argv.append(arg)
|
|
shown.append(arg)
|
|
elif command == "import" and i == 2 and set(arg) == {"file"}:
|
|
item = self.server.transfers.take(arg["file"])
|
|
cleanup.append(item["dir"])
|
|
argv.append(item["path"])
|
|
shown.append("<file>")
|
|
elif mods_add and i >= 3 and set(arg) == {"dir"}:
|
|
item = self.server.transfers.take(arg["dir"])
|
|
cleanup.append(item["dir"])
|
|
dest = self.server.transfers.new_dir()
|
|
cleanup.append(dest)
|
|
extract_upload(item["path"], dest)
|
|
entries = sorted(os.listdir(dest))
|
|
if not entries:
|
|
raise HttpError(400, "no files were sent")
|
|
argv += [os.path.join(dest, e) for e in entries]
|
|
shown += entries
|
|
elif command == "export" and i >= 2 and set(arg) == {"output"} and output is None:
|
|
if not isinstance(arg["output"], str) or not OUTPUT_RE.fullmatch(arg["output"]):
|
|
raise HttpError(400, "bad output file name")
|
|
dest = self.server.transfers.new_dir()
|
|
cleanup.append(dest)
|
|
output = os.path.join(dest, arg["output"])
|
|
argv.append(output)
|
|
shown.append("<file>")
|
|
else:
|
|
raise HttpError(400, "bad request")
|
|
return argv, shown, output
|
|
|
|
def run_command(self):
|
|
"""POST /1.0/exec: runs terractl and streams its output as JSON lines:
|
|
{"o": stdout text}, {"e": stderr text}, {"p": 1} (keepalive), and last
|
|
{"x": exit status, "file": {...}} (an export to fetch, if any)."""
|
|
req = self.read_json()
|
|
via = req.get("via") or ""
|
|
if not isinstance(via, str) or (via and not VIA_RE.fullmatch(via)):
|
|
raise HttpError(400, "bad request")
|
|
cleanup = []
|
|
try:
|
|
argv, shown, output = self.resolve(req.get("args"), cleanup)
|
|
proc = subprocess.Popen([TERRACTL, *argv], stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE, cwd="/", env=child_env(via),
|
|
start_new_session=True)
|
|
self.server.track(proc)
|
|
dlog(f"{self.who}: terractl {shlex.join(shown)}")
|
|
try:
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "application/x-ndjson")
|
|
self.send_header("Connection", "close")
|
|
self.end_headers()
|
|
self.close_connection = True
|
|
self.started = self.streaming = True
|
|
if not self.pump(proc):
|
|
dlog(f"{self.who}: went away; stopped terractl {shown[0]}")
|
|
return
|
|
final = {"x": proc.wait()}
|
|
if output and final["x"] == 0 and os.path.isfile(output):
|
|
cleanup.remove(os.path.dirname(output))
|
|
final["file"] = {"id": self.server.transfers.add(output),
|
|
"name": os.path.basename(output), "size": os.path.getsize(output)}
|
|
self.frame(final)
|
|
finally:
|
|
self.server.untrack(proc)
|
|
finally:
|
|
for path in cleanup:
|
|
shutil.rmtree(path, ignore_errors=True)
|
|
|
|
def pump(self, proc):
|
|
"""Sends a process's output as it comes. False if the client went away."""
|
|
streams = {proc.stdout.fileno(): ["o", codecs.getincrementaldecoder("utf-8")("replace")],
|
|
proc.stderr.fileno(): ["e", codecs.getincrementaldecoder("utf-8")("replace")]}
|
|
sock = self.connection
|
|
quiet_since = time.monotonic()
|
|
while streams:
|
|
ready, _, _ = select.select([*streams, sock], [], [], PING_SECONDS)
|
|
if sock in ready and self.client_left():
|
|
return False
|
|
for fd in ready:
|
|
if fd is sock:
|
|
continue
|
|
key, decoder = streams[fd]
|
|
data = os.read(fd, 65536)
|
|
text = decoder.decode(data, final=not data)
|
|
if text:
|
|
self.frame({key: text})
|
|
quiet_since = time.monotonic()
|
|
if not data:
|
|
del streams[fd]
|
|
if time.monotonic() - quiet_since >= PING_SECONDS:
|
|
self.frame({"p": 1})
|
|
quiet_since = time.monotonic()
|
|
return True
|
|
|
|
def client_left(self):
|
|
sock = self.connection
|
|
sock.settimeout(0)
|
|
try:
|
|
return sock.recv(4096) == b""
|
|
except (ssl.SSLWantReadError, BlockingIOError):
|
|
return False
|
|
except OSError:
|
|
return True
|
|
finally:
|
|
sock.settimeout(IO_TIMEOUT)
|
|
|
|
def put_file(self):
|
|
length = self.content_length()
|
|
dest = self.server.transfers.new_dir()
|
|
path = os.path.join(dest, "upload")
|
|
try:
|
|
self.receive(path, length)
|
|
except BaseException:
|
|
shutil.rmtree(dest, ignore_errors=True)
|
|
raise
|
|
self.send_json(200, {"id": self.server.transfers.add(path)})
|
|
|
|
def console(self, name):
|
|
"""A line for a server's console (attach), or {"check": true} to ask
|
|
whether the console is there."""
|
|
if not NAME_RE.fullmatch(name):
|
|
raise HttpError(400, f"invalid instance name '{name}'")
|
|
req = self.read_json()
|
|
fifo = os.path.join(self.server.run_dir, f"{name}.stdin")
|
|
try:
|
|
is_fifo = stat.S_ISFIFO(os.stat(fifo).st_mode)
|
|
except FileNotFoundError:
|
|
is_fifo = False
|
|
if not is_fifo:
|
|
raise HttpError(409, f"{fifo} not found - is terraria@{name} running?")
|
|
if req.get("check"):
|
|
return self.send_json(200, {})
|
|
line = req.get("line")
|
|
if not isinstance(line, str) or "\n" in line or "\r" in line or len(line) > 4096:
|
|
raise HttpError(400, "bad console line")
|
|
try:
|
|
fd = os.open(fifo, os.O_WRONLY | os.O_NONBLOCK)
|
|
except OSError as e:
|
|
if e.errno == errno.ENXIO:
|
|
raise HttpError(409, f"nothing reads {fifo}; is terraria@{name} running?")
|
|
raise
|
|
try:
|
|
os.write(fd, (line + "\n").encode())
|
|
finally:
|
|
os.close(fd)
|
|
self.send_json(200, {})
|
|
|
|
def zip_path(self, name):
|
|
if not ZIP_RE.fullmatch(name):
|
|
raise HttpError(400, f"invalid zip name '{name}'")
|
|
dist = one(self.server.info(), "dist_dir")
|
|
return dist, os.path.join(dist, name)
|
|
|
|
def get_zip(self, name):
|
|
dist, path = self.zip_path(name)
|
|
if not os.path.isfile(path):
|
|
raise HttpError(404, f"no {name} in {dist}")
|
|
self.send_file(path)
|
|
|
|
def put_zip(self, name):
|
|
dist, path = self.zip_path(name)
|
|
if os.path.exists(path):
|
|
raise HttpError(409, f"{name} is already in {dist}")
|
|
length = self.content_length()
|
|
part = os.path.join(dist, f".{name}.{secrets.token_hex(4)}.part")
|
|
try:
|
|
self.receive(part, length)
|
|
os.chmod(part, 0o644)
|
|
try:
|
|
os.link(part, path) # never replaces an existing zip
|
|
except FileExistsError:
|
|
raise HttpError(409, f"{name} is already in {dist}")
|
|
finally:
|
|
try:
|
|
os.unlink(part)
|
|
except FileNotFoundError:
|
|
pass
|
|
dlog(f"{self.who}: added {path}")
|
|
self.send_json(200, {})
|
|
|
|
|
|
class Daemon(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
|
daemon_threads = True
|
|
|
|
def __init__(self, sock, context):
|
|
super().__init__(sock.getsockname()[:2], Handler, bind_and_activate=False)
|
|
self.socket.close()
|
|
self.socket = sock
|
|
self.context = context
|
|
self.slots = threading.BoundedSemaphore(MAX_CONNECTIONS)
|
|
self.transfers = Transfers(state_path("transfers"))
|
|
self.children = set()
|
|
self.children_lock = threading.Lock()
|
|
try:
|
|
self.run_dir = one(self.info(), "run_dir") or "/run/terraria"
|
|
except RuntimeError as e:
|
|
die(str(e))
|
|
|
|
def info(self):
|
|
return local_host_info(child_env())
|
|
|
|
def host_info(self):
|
|
info = self.info()
|
|
return {"api": API, "version": one(info, "version"), "hostname": one(info, "host"),
|
|
"base_dir": one(info, "base_dir"), "dist_dir": one(info, "dist_dir"),
|
|
"dist_zips": info.get("dist_zip", []), "instances": info.get("instance", [])}
|
|
|
|
def process_request(self, request, client_address):
|
|
if not self.slots.acquire(blocking=False):
|
|
self.shutdown_request(request)
|
|
return
|
|
try:
|
|
super().process_request(request, client_address)
|
|
except BaseException:
|
|
self.slots.release()
|
|
raise
|
|
|
|
def process_request_thread(self, request, client_address):
|
|
try:
|
|
super().process_request_thread(request, client_address)
|
|
finally:
|
|
self.slots.release()
|
|
|
|
def finish_request(self, request, client_address):
|
|
# TLS is set up here, in the connection's own thread, so a slow or
|
|
# silent client can't hold up everyone else
|
|
request.settimeout(30)
|
|
request.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
|
|
for option, value in (("TCP_KEEPIDLE", 60), ("TCP_KEEPINTVL", 15), ("TCP_KEEPCNT", 4)):
|
|
if hasattr(socket, option):
|
|
request.setsockopt(socket.IPPROTO_TCP, getattr(socket, option), value)
|
|
try:
|
|
conn = self.context.wrap_socket(request, server_side=True)
|
|
except (OSError, ValueError):
|
|
return
|
|
try:
|
|
self.RequestHandlerClass(conn, client_address, self)
|
|
except (ConnectionError, TimeoutError, ssl.SSLError):
|
|
pass # the client hung up
|
|
finally:
|
|
conn.close()
|
|
|
|
def handle_error(self, request, client_address):
|
|
dlog(f"{client_address[0]}: {sys.exc_info()[1]!r}")
|
|
|
|
def track(self, proc):
|
|
with self.children_lock:
|
|
self.children.add(proc)
|
|
|
|
def untrack(self, proc):
|
|
"""Stops a command whose client went away, with everything it started."""
|
|
if proc.poll() is None:
|
|
for sig, wait in ((signal.SIGTERM, 10), (signal.SIGKILL, 5)):
|
|
try:
|
|
os.killpg(proc.pid, sig)
|
|
proc.wait(wait)
|
|
break
|
|
except ProcessLookupError:
|
|
break
|
|
except subprocess.TimeoutExpired:
|
|
continue
|
|
for pipe in (proc.stdout, proc.stderr):
|
|
pipe.close()
|
|
with self.children_lock:
|
|
self.children.discard(proc)
|
|
|
|
def stop_children(self):
|
|
with self.children_lock:
|
|
children = list(self.children)
|
|
for proc in children:
|
|
self.untrack(proc)
|
|
|
|
|
|
def listen_socket(host, port):
|
|
if host in ("", "::"):
|
|
if socket.has_dualstack_ipv6():
|
|
try:
|
|
return socket.create_server(("", port), family=socket.AF_INET6, dualstack_ipv6=True, backlog=64)
|
|
except OSError as e:
|
|
if e.errno not in (errno.EAFNOSUPPORT, errno.EADDRNOTAVAIL):
|
|
raise
|
|
return socket.create_server(("", port), backlog=64)
|
|
family, _, _, _, addr = socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM)[0]
|
|
return socket.create_server(addr[:2], family=family, backlog=64)
|
|
|
|
|
|
def daemon_serve():
|
|
open_state("daemon serve")
|
|
ensure_cert()
|
|
host, port = listen_setting()
|
|
try:
|
|
sock = listen_socket(host, port)
|
|
except OSError as e:
|
|
hint = ""
|
|
if e.errno == errno.EADDRINUSE:
|
|
hint = "; another program uses it (set TERRACTL_LISTEN in /etc/default/terractl to change the port)"
|
|
die(f"can't listen on {describe_listen(host, port)}: {e.strerror}{hint}")
|
|
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
|
context.minimum_version = ssl.TLSVersion.TLSv1_2
|
|
context.load_cert_chain(state_path(CERT), state_path(KEY))
|
|
server = Daemon(sock, context)
|
|
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
|
|
|
|
def expire():
|
|
while True:
|
|
time.sleep(600)
|
|
server.transfers.expire()
|
|
|
|
threading.Thread(target=expire, daemon=True).start()
|
|
dlog(f"terractld {VERSION} listening on {describe_listen(host, port)}; "
|
|
f"certificate SHA-256 {cert_fingerprint()}")
|
|
try:
|
|
server.serve_forever()
|
|
except KeyboardInterrupt:
|
|
pass
|
|
finally:
|
|
server.server_close()
|
|
server.stop_children()
|
|
return 0
|
|
|
|
|
|
def wait_listening(host, port, seconds):
|
|
target = {"": "127.0.0.1", "0.0.0.0": "127.0.0.1", "::": "::1"}.get(host, host)
|
|
deadline = time.monotonic() + seconds
|
|
while time.monotonic() < deadline:
|
|
try:
|
|
socket.create_connection((target, port), 1).close()
|
|
return True
|
|
except OSError:
|
|
time.sleep(0.3)
|
|
return False
|
|
|
|
|
|
def daemon_enable():
|
|
need_root("daemon enable")
|
|
open_state("daemon enable")
|
|
ensure_cert()
|
|
proc = systemctl("enable", "--now", DAEMON_UNIT)
|
|
if proc.returncode != 0:
|
|
die(f"starting terractld failed: {proc.stderr.strip()} (details: journalctl -u terractld)")
|
|
host, port = listen_setting()
|
|
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"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
|
|
|
|
|
|
def daemon_disable():
|
|
need_root("daemon disable")
|
|
proc = systemctl("disable", "--now", DAEMON_UNIT)
|
|
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 (sudo terractl daemon status).")
|
|
return 0
|
|
|
|
|
|
def daemon_status():
|
|
active = systemctl("is-active", DAEMON_UNIT).stdout.strip() or "unknown"
|
|
enabled = systemctl("is-enabled", DAEMON_UNIT).stdout.strip()
|
|
host, port = listen_setting()
|
|
boot = "starts at boot" if enabled == "enabled" else "doesn't start at boot"
|
|
print(f"terractld: {active}, {boot}")
|
|
print(f"listening on: {describe_listen(host, port)}")
|
|
if os.path.exists(state_path(CERT)):
|
|
print(f"certificate: SHA-256 {cert_fingerprint()}")
|
|
try:
|
|
trust = load_trust()
|
|
except PermissionError:
|
|
print("trusted: (run with sudo to see which computers)")
|
|
trust = None
|
|
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 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]
|
|
trust["tokens"].append({"secret_sha256": sha256(secret), "expires": int(now + TOKEN_HOURS * 3600)})
|
|
save_trust(trust)
|
|
host, port = listen_setting()
|
|
fields = {"v": 1, "fp": cert_fingerprint(), "s": secret, "a": host_addresses(host, port)}
|
|
here = (socket.gethostname() or "").split(".")[0]
|
|
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 {make_token(fields)}")
|
|
print()
|
|
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 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]
|
|
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
|
|
|
|
|
|
def daemon_main(argv):
|
|
sub, rest = (argv[0], argv[1:]) if argv else ("", [])
|
|
if sub == "revoke" and len(rest) == 1:
|
|
return daemon_revoke(rest[0])
|
|
commands = {"enable": daemon_enable, "disable": daemon_disable, "status": daemon_status,
|
|
"token": print_token, "serve": daemon_serve}
|
|
if sub in commands and not rest:
|
|
return commands[sub]()
|
|
die("usage: terractl daemon enable | disable | status | token | revoke <name>")
|
|
|
|
|
|
# --------------------------------------------------------------- client ----
|
|
|
|
def config_location():
|
|
"""This user's settings folder, and under sudo the account that owns it:
|
|
remotes belong to the person, even when they use sudo."""
|
|
if os.environ.get("TERRACTL_CONFIG_DIR"):
|
|
return os.environ["TERRACTL_CONFIG_DIR"], None
|
|
user = os.environ.get("SUDO_USER")
|
|
if os.geteuid() == 0 and user and user != "root":
|
|
try:
|
|
pw = pwd.getpwnam(user)
|
|
return os.path.join(pw.pw_dir, ".config", "terractl"), pw
|
|
except KeyError:
|
|
pass
|
|
base = os.environ.get("XDG_CONFIG_HOME") or os.path.join(os.path.expanduser("~"), ".config")
|
|
return os.path.join(base, "terractl"), None
|
|
|
|
|
|
def load_remotes():
|
|
folder, _ = config_location()
|
|
path = os.path.join(folder, "remotes.json")
|
|
try:
|
|
with open(path) as f:
|
|
return json.load(f).get("remotes", {})
|
|
except FileNotFoundError:
|
|
return {}
|
|
except (OSError, ValueError, AttributeError) as e:
|
|
die(f"can't read {path}: {e}")
|
|
|
|
|
|
def save_remotes(remotes):
|
|
folder, owner = config_location()
|
|
created = [d for d in (os.path.dirname(folder), folder) if not os.path.isdir(d)]
|
|
os.makedirs(folder, mode=0o700, exist_ok=True)
|
|
path = os.path.join(folder, "remotes.json")
|
|
tmp = path + ".new"
|
|
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
with os.fdopen(fd, "w") as f:
|
|
json.dump({"remotes": remotes}, f, indent=2, sort_keys=True)
|
|
f.write("\n")
|
|
if owner:
|
|
for p in created + [tmp]:
|
|
os.chown(p, owner.pw_uid, owner.pw_gid)
|
|
os.replace(tmp, path)
|
|
|
|
|
|
class PinMismatch(Exception):
|
|
"""The daemon's certificate isn't the one pinned for it."""
|
|
|
|
|
|
class PinnedHTTPSConnection(http.client.HTTPSConnection):
|
|
"""HTTPS to a daemon whose certificate must have a known SHA-256 fingerprint."""
|
|
|
|
def __init__(self, host, port, fingerprint, timeout):
|
|
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
context.check_hostname = False
|
|
context.verify_mode = ssl.CERT_NONE # pinned instead: see connect()
|
|
context.minimum_version = ssl.TLSVersion.TLSv1_2
|
|
super().__init__(host, port, timeout=timeout, context=context, blocksize=CHUNK)
|
|
self.pin = fingerprint
|
|
self.pin_context = context
|
|
|
|
def connect(self):
|
|
sock = socket.create_connection((self.host, self.port), self.timeout)
|
|
try:
|
|
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
|
tls = self.pin_context.wrap_socket(sock)
|
|
except BaseException:
|
|
sock.close()
|
|
raise
|
|
got = hashlib.sha256(tls.getpeercert(binary_form=True) or b"").hexdigest()
|
|
if not hmac.compare_digest(got, self.pin):
|
|
tls.close()
|
|
raise PinMismatch(got)
|
|
self.sock = tls
|
|
|
|
|
|
class LinkLost(Exception):
|
|
pass
|
|
|
|
|
|
class Remote:
|
|
"""A daemon on another host, as this user added it with 'terractl remote add'."""
|
|
|
|
def __init__(self, name, conf, via=None):
|
|
self.name = self.label = name
|
|
self.host = conf["address"]
|
|
self.port = int(conf["port"])
|
|
self.fingerprint = conf["fingerprint"]
|
|
self.key = conf.get("key", "")
|
|
self.addr = join_hostport(self.host, self.port)
|
|
self.via = via or f"--host {name}"
|
|
self.console_conn = None
|
|
self.info = None
|
|
self.joining = False
|
|
|
|
def same_host(self, other):
|
|
return (self.host.lower(), self.port) == (other.host.lower(), other.port)
|
|
|
|
def connect(self, timeout):
|
|
conn = PinnedHTTPSConnection(self.host, self.port, self.fingerprint, timeout)
|
|
try:
|
|
conn.connect()
|
|
except PinMismatch as e:
|
|
if self.joining:
|
|
raise Unreachable(f"the daemon at {self.addr} isn't the host the token is from (its "
|
|
f"certificate's SHA-256 is {e}, the token expects {self.fingerprint})")
|
|
raise Unreachable(
|
|
f"the daemon at {self.addr} isn't the one '{self.name}' was added with: its certificate "
|
|
f"changed (SHA-256 {e}, expected {self.fingerprint}). If it was replaced on purpose, add "
|
|
f"the remote again (terractl remote remove {self.name}, then terractl remote add ...). "
|
|
f"If not, something may be intercepting the connection.")
|
|
except OSError as e:
|
|
raise Unreachable(f"can't reach {self.label} at {self.addr}: {e.strerror or e}. Is its "
|
|
f"daemon running? (check there with: terractl daemon status)")
|
|
return conn
|
|
|
|
def request(self, method, path, body=None, *, timeout=30, length=None, conn=None, auth=True):
|
|
"""Sends a request and returns the response, or fails with the daemon's
|
|
error message. Without conn, the connection is closed after the response."""
|
|
headers = {"User-Agent": f"terractl/{VERSION}"}
|
|
if conn is None:
|
|
conn = self.connect(timeout)
|
|
headers["Connection"] = "close"
|
|
reused = False
|
|
else:
|
|
reused = True
|
|
if auth:
|
|
headers["Authorization"] = f"Bearer {self.key}"
|
|
if isinstance(body, dict):
|
|
body = json.dumps(body).encode()
|
|
headers["Content-Type"] = "application/json"
|
|
if length is not None:
|
|
headers["Content-Length"] = str(length)
|
|
try:
|
|
conn.request(method, path, body=body, headers=headers)
|
|
resp = conn.getresponse()
|
|
except (http.client.HTTPException, OSError, PinMismatch) as e:
|
|
conn.close()
|
|
if reused:
|
|
raise LinkLost()
|
|
die(f"lost the connection to {self.label} ({e})")
|
|
if resp.status >= 400:
|
|
raw = resp.read()
|
|
conn.close()
|
|
try:
|
|
message = json.loads(raw)["error"]
|
|
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). Run 'sudo terractl daemon "
|
|
f"token' there, then here: terractl remote add <token>")
|
|
die(f"{self.label}: {message}")
|
|
return resp
|
|
|
|
def call(self, method, path, body=None, **kwargs):
|
|
resp = self.request(method, path, body, **kwargs)
|
|
try:
|
|
data = resp.read()
|
|
except (http.client.HTTPException, OSError) as e:
|
|
die(f"lost the connection to {self.label} ({e})")
|
|
return json.loads(data or b"{}")
|
|
|
|
def host_info(self):
|
|
if self.info is None:
|
|
info = self.call("GET", "/1.0")
|
|
if info.get("api") != API:
|
|
die(f"{self.label} runs terractl {info.get('version', '?')}, which can't work with this "
|
|
f"one ({VERSION}); install the same version on both")
|
|
self.info = info
|
|
return self.info
|
|
|
|
def exec(self, args, show_stdout=True, capture=False):
|
|
"""Runs 'terractl <args>' on the remote, showing its output as it comes.
|
|
Returns (exit status, last message, captured stdout)."""
|
|
resp = self.request("POST", "/1.0/exec", {"args": args, "via": self.via}, timeout=STREAM_TIMEOUT)
|
|
captured = []
|
|
try:
|
|
while True:
|
|
try:
|
|
line = resp.readline()
|
|
frame = json.loads(line) if line else None
|
|
except (http.client.HTTPException, OSError, ValueError) as e:
|
|
die(f"lost the connection to {self.label} ({e})")
|
|
if frame is None:
|
|
die(f"lost the connection to {self.label} during '{args[0]}'")
|
|
if "o" in frame:
|
|
if capture:
|
|
captured.append(frame["o"])
|
|
elif show_stdout:
|
|
sys.stdout.write(frame["o"])
|
|
sys.stdout.flush()
|
|
elif "e" in frame:
|
|
sys.stderr.write(frame["e"])
|
|
sys.stderr.flush()
|
|
elif "x" in frame:
|
|
return frame["x"], frame, "".join(captured)
|
|
elif "error" in frame:
|
|
die(f"{self.label}: {frame['error']}")
|
|
finally:
|
|
resp.close()
|
|
|
|
def upload(self, path, stream, size, label):
|
|
"""PUTs size bytes from stream, showing progress. Returns the reply."""
|
|
progress = Progress(label, size)
|
|
resp = self.request("PUT", path, Counted(stream, progress), length=size, timeout=IO_TIMEOUT)
|
|
reply = json.loads(resp.read() or b"{}")
|
|
progress.done()
|
|
return reply
|
|
|
|
def send_file(self, local_path, label):
|
|
self.host_info() # fails early, before a long upload, if we're not let in
|
|
with open(local_path, "rb") as f:
|
|
return self.upload("/1.0/files", f, os.fstat(f.fileno()).st_size, label)["id"]
|
|
|
|
def console_line(self, name, line):
|
|
"""Sends a line to a server's console, over one connection kept for attach."""
|
|
for attempt in (1, 2):
|
|
if self.console_conn is None:
|
|
self.console_conn = self.connect(30)
|
|
try:
|
|
return self.call("POST", f"/1.0/console/{name}", {"line": line}, conn=self.console_conn)
|
|
except LinkLost:
|
|
self.console_conn = None
|
|
if attempt == 2:
|
|
die(f"lost the connection to {self.label}")
|
|
|
|
|
|
class Progress:
|
|
"""'sending x: 12.3 MB of 45.6 MB (27%)', updated in place on a terminal."""
|
|
|
|
def __init__(self, label, total):
|
|
self.label, self.total, self.count = label, total, 0
|
|
self.tty = sys.stderr.isatty()
|
|
self.shown = 0.0
|
|
|
|
def add(self, n):
|
|
self.count += n
|
|
now = time.monotonic()
|
|
if self.tty and now - self.shown >= 0.25:
|
|
self.shown = now
|
|
percent = self.count * 100 // self.total if self.total else 100
|
|
sys.stderr.write(f"\r {self.label}: {human_size(self.count)} of "
|
|
f"{human_size(self.total)} ({percent}%)\033[K")
|
|
sys.stderr.flush()
|
|
|
|
def done(self):
|
|
line = f" {self.label}: {human_size(self.count)}"
|
|
sys.stderr.write(f"\r{line}\033[K\n" if self.tty else f"{line}\n")
|
|
sys.stderr.flush()
|
|
|
|
|
|
class Counted:
|
|
"""A readable stream that reports progress as it's read."""
|
|
|
|
def __init__(self, stream, progress):
|
|
self.stream, self.progress = stream, progress
|
|
|
|
def read(self, n=-1):
|
|
data = self.stream.read(CHUNK if n is None or n < 0 else n)
|
|
self.progress.add(len(data))
|
|
return data
|
|
|
|
|
|
def copy_stream(src, dst, progress, size):
|
|
while True:
|
|
data = src.read(CHUNK)
|
|
if not data:
|
|
break
|
|
dst.write(data)
|
|
progress.add(len(data))
|
|
if progress.count != size:
|
|
die(f"the transfer was cut off ({human_size(progress.count)} of {human_size(size)})")
|
|
progress.done()
|
|
|
|
|
|
def find_remote(spec):
|
|
"""A remote by name, or by the address it was added with."""
|
|
remotes = load_remotes()
|
|
if spec in remotes:
|
|
return Remote(spec, remotes[spec], via=f"--host {spec}")
|
|
try:
|
|
host, port = split_hostport(spec)
|
|
except Fail:
|
|
host = port = None
|
|
for name, conf in sorted(remotes.items()):
|
|
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 token' there, then here: terractl remote add <token>")
|
|
|
|
|
|
def remote_add(args):
|
|
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 -)")
|
|
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; 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", join, auth=False, timeout=5)
|
|
break
|
|
except Unreachable as e:
|
|
problems.append(str(e))
|
|
else:
|
|
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 <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)
|
|
remote.key = reply["key"]
|
|
remote.joining = False
|
|
remote.label = name
|
|
try:
|
|
info = remote.host_info()
|
|
about = f"{info['hostname']} at {remote.addr}, terractl {info['version']}"
|
|
except Fail:
|
|
about = remote.addr
|
|
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
|
|
|
|
|
|
def remote_list(args):
|
|
if args:
|
|
die("usage: terractl remote list")
|
|
remotes = load_remotes()
|
|
if not remotes:
|
|
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()):
|
|
print(f"{name:<20} {join_hostport(conf['address'], conf['port']):<30} {conf['fingerprint'][:16]}")
|
|
return 0
|
|
|
|
|
|
def remote_remove(args):
|
|
if len(args) != 1:
|
|
die("usage: terractl remote remove <name>")
|
|
remotes = load_remotes()
|
|
conf = remotes.pop(args[0], None)
|
|
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 trusts this computer; to revoke that too, run there: "
|
|
f"sudo terractl daemon revoke {conf.get('client_name', '<name>')}")
|
|
return 0
|
|
|
|
|
|
def remote_main(argv):
|
|
sub, rest = (argv[0], argv[1:]) if argv else ("list", [])
|
|
commands = {"add": remote_add, "list": remote_list, "remove": remote_remove}
|
|
if sub not in commands:
|
|
die("usage: terractl remote add <name> <token> | remote list | remote remove <name>")
|
|
return commands[sub](rest)
|
|
|
|
|
|
# ------------------------------------------------ commands on a remote ----
|
|
|
|
def local_path(path):
|
|
return os.path.abspath(os.path.expanduser(path))
|
|
|
|
|
|
def on_remote_attach(remote, argv):
|
|
if len(argv) != 2:
|
|
die("usage: terractl attach <name>")
|
|
name = argv[1]
|
|
if not NAME_RE.fullmatch(name):
|
|
die(f"invalid instance name '{name}'")
|
|
remote.call("POST", f"/1.0/console/{name}", {"check": True})
|
|
print(f"Attached to terraria@{name} on {remote.label}. Ctrl-D or Ctrl-C detaches; the server keeps running.")
|
|
|
|
def follow():
|
|
try:
|
|
remote.exec(["watch", name, "-n", "30", "-q"])
|
|
except Fail as e:
|
|
log(str(e))
|
|
log(f"no more output from {remote.label}; Ctrl-D detaches")
|
|
|
|
threading.Thread(target=follow, daemon=True).start()
|
|
try:
|
|
import readline # line editing and history for input()
|
|
readline.set_auto_history(True)
|
|
except ImportError:
|
|
pass
|
|
try:
|
|
while True:
|
|
line = input()
|
|
if not line:
|
|
continue
|
|
if line == "exit":
|
|
print(f"(not sent: 'exit' would shut the server down. Use Ctrl-D to detach, "
|
|
f"or 'terractl {remote.via} stop {name}' to stop it.)")
|
|
continue
|
|
try:
|
|
remote.console_line(name, line)
|
|
except Fail as e:
|
|
print(f"(not sent: {e})", file=sys.stderr)
|
|
except (EOFError, KeyboardInterrupt):
|
|
print()
|
|
return 0
|
|
|
|
|
|
def on_remote_watch(remote, argv):
|
|
try:
|
|
return remote.exec(argv)[0]
|
|
except KeyboardInterrupt:
|
|
print()
|
|
return 0
|
|
|
|
|
|
def on_remote_export(remote, argv):
|
|
if len(argv) < 2:
|
|
die("usage: terractl export <name> [file.tar.gz] [--with-server]")
|
|
name, out, with_server = argv[1], None, False
|
|
if not NAME_RE.fullmatch(name):
|
|
die(f"invalid instance name '{name}'")
|
|
for arg in argv[2:]:
|
|
if arg == "--with-server":
|
|
with_server = True
|
|
elif arg.startswith("-"):
|
|
die(f"unknown option '{arg}'")
|
|
elif out:
|
|
die("more than one output file given")
|
|
else:
|
|
out = arg
|
|
out = local_path(out or f"{name}-{time.strftime('%Y%m%d-%H%M%S')}.tar.gz")
|
|
if not out.endswith((".tar.gz", ".tgz")):
|
|
out += ".tar.gz"
|
|
if os.path.exists(out):
|
|
die(f"{out} already exists")
|
|
if not os.path.isdir(os.path.dirname(out)):
|
|
die(f"folder {os.path.dirname(out)} does not exist")
|
|
archive = RemoteHost(remote).export(name, with_server)
|
|
archive.save(out, f"fetching {archive.name}")
|
|
print(f"exported '{name}' from {remote.label} to {out} ({human_size(archive.size)}, readable only by you)")
|
|
if not with_server:
|
|
print("server/ not included; it's unpacked again from the server zip on first start after import")
|
|
return 0
|
|
|
|
|
|
def on_remote_import(remote, argv):
|
|
if len(argv) != 3:
|
|
die("usage: terractl import <new instance name> <file.tar.gz>")
|
|
path = local_path(argv[2])
|
|
if not os.path.isfile(path):
|
|
die(f"cannot read {path}")
|
|
fid = remote.send_file(path, f"sending {os.path.basename(path)} to {remote.label}")
|
|
return remote.exec(["import", argv[1], {"file": fid}])[0]
|
|
|
|
|
|
def on_remote_mods(remote, argv):
|
|
if len(argv) < 4 or argv[2] != "add":
|
|
return remote.exec(argv)[0]
|
|
tmp = big_tempdir()
|
|
try:
|
|
bundle = os.path.join(tmp, "mods.tar")
|
|
names = set()
|
|
with tarfile.open(bundle, "w", dereference=True) as tar:
|
|
for arg in argv[3:]:
|
|
path = local_path(arg)
|
|
if not os.path.exists(path):
|
|
die(f"{path} not found")
|
|
base = os.path.basename(os.path.normpath(path))
|
|
if base in names:
|
|
die(f"more than one of the given paths is called '{base}'")
|
|
names.add(base)
|
|
tar.add(path, arcname=base, filter=lambda m: m if m.isfile() or m.isdir() else None)
|
|
fid = remote.send_file(bundle, f"sending mods to {remote.label}")
|
|
finally:
|
|
shutil.rmtree(tmp, ignore_errors=True)
|
|
return remote.exec(["mods", argv[1], "add", {"dir": fid}])[0]
|
|
|
|
|
|
def on_remote_delete(remote, argv):
|
|
if len(argv) >= 2 and "-y" not in argv and "--yes" not in argv:
|
|
name = argv[1]
|
|
if not sys.stdin.isatty():
|
|
die("not a terminal; use -y to delete without confirmation")
|
|
print(f"This will stop terraria@{name} on {remote.label} and remove it there.")
|
|
if "--no-backup" in argv:
|
|
print("NO BACKUP will be made. Worlds will be lost.")
|
|
else:
|
|
print(f"Worlds and configs will be archived to {remote.host_info()['base_dir']}/.deleted/ "
|
|
f"on {remote.label} first.")
|
|
if input("Type the instance name to confirm: ") != name:
|
|
die("confirmation did not match; nothing deleted")
|
|
argv = argv + ["-y"]
|
|
return remote.exec(argv)[0]
|
|
|
|
|
|
def run_on_remote(spec, argv):
|
|
if not argv:
|
|
die("usage: terractl --host <remote> <command> [args]")
|
|
remote = find_remote(spec)
|
|
command = argv[0]
|
|
if command in ("remote", "daemon", "copy", "move", "-H") or command.startswith("--host"):
|
|
die(f"'{command}' runs on this computer; use it without --host "
|
|
"(copy and move take <remote>:<name> instead)")
|
|
if command in ("prepare", "run", "service-stop", "journal-access"):
|
|
die(f"'{command}' is only used by the service itself")
|
|
handlers = {"attach": on_remote_attach, "watch": on_remote_watch, "export": on_remote_export,
|
|
"import": on_remote_import, "mods": on_remote_mods, "delete": on_remote_delete}
|
|
if command in handlers:
|
|
return handlers[command](remote, argv)
|
|
return remote.exec(argv)[0]
|
|
|
|
|
|
# ------------------------------------------------------------ copy/move ----
|
|
|
|
class LocalArchive:
|
|
def __init__(self, path, tmpdir):
|
|
self.path, self.tmpdir = path, tmpdir
|
|
self.name = os.path.basename(path)
|
|
|
|
def cleanup(self):
|
|
shutil.rmtree(self.tmpdir, ignore_errors=True)
|
|
|
|
|
|
class RemoteArchive:
|
|
"""An export waiting on a daemon. Fetching it (or importing it on the same
|
|
host) uses it up; otherwise cleanup() removes it there."""
|
|
|
|
def __init__(self, host, fid, name, size):
|
|
self.host, self.fid, self.name, self.size = host, fid, name, size
|
|
self.used = False
|
|
|
|
def claim(self):
|
|
self.used = True
|
|
return self.fid
|
|
|
|
def open(self):
|
|
self.used = True
|
|
return self.host.remote.request("GET", f"/1.0/files/{self.fid}", timeout=IO_TIMEOUT)
|
|
|
|
def save(self, path, label):
|
|
stream = self.open()
|
|
try:
|
|
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
try:
|
|
with os.fdopen(fd, "wb") as out:
|
|
copy_stream(stream, out, Progress(label, self.size), self.size)
|
|
except BaseException:
|
|
os.unlink(path)
|
|
raise
|
|
finally:
|
|
stream.close()
|
|
|
|
def cleanup(self):
|
|
if not self.used:
|
|
try:
|
|
self.host.remote.call("DELETE", f"/1.0/files/{self.fid}")
|
|
except Fail:
|
|
pass
|
|
|
|
|
|
class LocalHost:
|
|
label = "this host"
|
|
is_local = True
|
|
|
|
def same_host(self, other):
|
|
return isinstance(other, LocalHost)
|
|
|
|
def host_info(self):
|
|
info = parse_info(terractl(["info"], capture=True).stdout)
|
|
return {"hostname": one(info, "host"), "base_dir": one(info, "base_dir"),
|
|
"dist_dir": one(info, "dist_dir"), "dist_zips": info.get("dist_zip", [])}
|
|
|
|
def instance(self, name):
|
|
base = self.host_info()["base_dir"]
|
|
if not os.access(base, os.R_OK | os.X_OK):
|
|
die(f"can't read the instances in {base}: join the terraria group, or use sudo")
|
|
proc = terractl(["info", name], capture=True)
|
|
if proc.returncode != 0:
|
|
die(f"can't read instance '{name}' on this host")
|
|
return {k: v[-1] for k, v in parse_info(proc.stdout).items()}
|
|
|
|
def run(self, args, show_stdout=True):
|
|
return terractl(args, show_stdout=show_stdout).returncode
|
|
|
|
def export(self, name, with_server):
|
|
tmp = big_tempdir()
|
|
path = os.path.join(tmp, f"{name}.tar.gz")
|
|
if self.run(["export", name, path] + (["--with-server"] if with_server else []), show_stdout=False):
|
|
shutil.rmtree(tmp, ignore_errors=True)
|
|
die(f"exporting '{name}' failed")
|
|
return LocalArchive(path, tmp)
|
|
|
|
def import_(self, name, archive, show_stdout):
|
|
if isinstance(archive, LocalArchive):
|
|
return self.run(["import", name, archive.path], show_stdout)
|
|
tmp = big_tempdir()
|
|
try:
|
|
path = os.path.join(tmp, archive.name)
|
|
archive.save(path, f"fetching {archive.name} from {archive.host.label}")
|
|
return self.run(["import", name, path], show_stdout)
|
|
finally:
|
|
shutil.rmtree(tmp, ignore_errors=True)
|
|
|
|
def open_zip(self, zip_name):
|
|
f = open(os.path.join(self.host_info()["dist_dir"], zip_name), "rb")
|
|
return f, os.fstat(f.fileno()).st_size
|
|
|
|
def receive_zip(self, zip_name, stream, size, label):
|
|
dist = self.host_info()["dist_dir"]
|
|
part = os.path.join(dist, f".{zip_name}.{secrets.token_hex(4)}.part")
|
|
try:
|
|
with open(part, "xb") as out:
|
|
copy_stream(stream, out, Progress(label, size), size)
|
|
os.chmod(part, 0o644)
|
|
os.link(part, os.path.join(dist, zip_name)) # never replaces an existing zip
|
|
finally:
|
|
try:
|
|
os.unlink(part)
|
|
except FileNotFoundError:
|
|
pass
|
|
|
|
|
|
class RemoteHost:
|
|
is_local = False
|
|
|
|
def __init__(self, remote):
|
|
self.remote = remote
|
|
self.label = remote.label
|
|
|
|
def same_host(self, other):
|
|
return isinstance(other, RemoteHost) and self.remote.same_host(other.remote)
|
|
|
|
def host_info(self):
|
|
return self.remote.host_info()
|
|
|
|
def instance(self, name):
|
|
code, _, out = self.remote.exec(["info", name], capture=True)
|
|
if code != 0:
|
|
die(f"can't read instance '{name}' on {self.label}")
|
|
return {k: v[-1] for k, v in parse_info(out).items()}
|
|
|
|
def run(self, args, show_stdout=True):
|
|
return self.remote.exec(args, show_stdout=show_stdout)[0]
|
|
|
|
def export(self, name, with_server):
|
|
args = ["export", name, {"output": f"{name}.tar.gz"}] + (["--with-server"] if with_server else [])
|
|
code, final, _ = self.remote.exec(args, show_stdout=False)
|
|
if code != 0 or "file" not in final:
|
|
die(f"exporting '{name}' on {self.label} failed")
|
|
f = final["file"]
|
|
return RemoteArchive(self, f["id"], f["name"], f["size"])
|
|
|
|
def import_(self, name, archive, show_stdout):
|
|
if isinstance(archive, RemoteArchive) and archive.host.same_host(self):
|
|
fid = archive.claim() # already there
|
|
elif isinstance(archive, LocalArchive):
|
|
fid = self.remote.send_file(archive.path, f"sending {archive.name} to {self.label}")
|
|
else:
|
|
self.host_info()
|
|
stream = archive.open()
|
|
try:
|
|
fid = self.remote.upload("/1.0/files", stream, archive.size,
|
|
f"copying {archive.name} from {archive.host.label} to {self.label}")["id"]
|
|
finally:
|
|
stream.close()
|
|
return self.remote.exec(["import", name, {"file": fid}], show_stdout=show_stdout)[0]
|
|
|
|
def open_zip(self, zip_name):
|
|
resp = self.remote.request("GET", f"/1.0/dist/{urllib.parse.quote(zip_name)}", timeout=IO_TIMEOUT)
|
|
return resp, int(resp.getheader("Content-Length") or 0)
|
|
|
|
def receive_zip(self, zip_name, stream, size, label):
|
|
self.remote.upload(f"/1.0/dist/{urllib.parse.quote(zip_name)}", stream, size, label)
|
|
|
|
|
|
def transfer_label(src, dst, name):
|
|
if dst.is_local:
|
|
return f"fetching {name} from {src.label}"
|
|
if src.is_local:
|
|
return f"sending {name} to {dst.label}"
|
|
return f"copying {name} from {src.label} to {dst.label}"
|
|
|
|
|
|
def parse_location(text):
|
|
"""'[<remote>:]<name>' -> (host, name). The name may be empty ('nas:')."""
|
|
if ":" not in text:
|
|
return LocalHost(), text
|
|
spec, _, name = text.rpartition(":")
|
|
return RemoteHost(find_remote(spec)), name
|
|
|
|
|
|
def copy_or_move(argv, move):
|
|
verb = "move" if move else "copy"
|
|
usage = f"usage: terractl {verb} [<remote>:]<name> [<remote>:][<new name>] [--with-server]"
|
|
with_server, places = False, []
|
|
for arg in argv:
|
|
if arg == "--with-server":
|
|
with_server = True
|
|
elif arg.startswith("-"):
|
|
die(f"unknown option '{arg}'")
|
|
else:
|
|
places.append(arg)
|
|
if len(places) != 2:
|
|
die(usage)
|
|
src, name = parse_location(places[0])
|
|
dst, new_name = parse_location(places[1])
|
|
new_name = new_name or name
|
|
for n in (name, new_name):
|
|
if not NAME_RE.fullmatch(n):
|
|
die(f"invalid instance name '{n}'" if n else usage)
|
|
if src.same_host(dst) and name == new_name:
|
|
die("the source and the destination are the same")
|
|
if move and src.same_host(dst):
|
|
# Renaming in place keeps the port, server/ and tModLoader's .NET
|
|
if src.is_local and os.geteuid() != 0:
|
|
die(f"renaming needs root: sudo terractl move {shlex.join(argv)}")
|
|
return src.run(["rename", name, new_name])
|
|
|
|
# Check everything first, so nothing changes if it can't work
|
|
if move and src.is_local and os.geteuid() != 0:
|
|
die(f"moving '{name}' deletes it on this host, which needs root: sudo terractl move {shlex.join(argv)}")
|
|
if dst.is_local and os.geteuid() != 0 and not os.access(dst.host_info()["base_dir"], os.W_OK):
|
|
die(f"adding an instance on this host needs root: sudo terractl {verb} {shlex.join(argv)}")
|
|
source = src.instance(name)
|
|
if source.get("exists") != "yes":
|
|
die(f"no instance '{name}' on {src.label}")
|
|
if dst.instance(new_name).get("exists") == "yes":
|
|
prefix = places[1].rpartition(":")[0] + ":" if ":" in places[1] else ""
|
|
die(f"'{new_name}' already exists on {dst.label}; give it another name there, "
|
|
f"e.g. terractl {verb} {places[0]} {prefix}<new name>")
|
|
# The server zip the instance runs (import pins it). It has to be in the
|
|
# destination's dist folder, unless the unpacked server goes along.
|
|
needed = os.path.basename(source.get("pinned_zip", "")) or source.get("installed_zip", "")
|
|
send_zip = None
|
|
if needed and not with_server and not src.same_host(dst):
|
|
dst_info = dst.host_info()
|
|
if needed not in dst_info["dist_zips"]:
|
|
if needed not in src.host_info()["dist_zips"]:
|
|
die(f"'{name}' runs {needed}, which {dst.label} doesn't have in {dst_info['dist_dir']} and "
|
|
f"{src.label} has no copy of to send. Put it there first, or add --with-server to "
|
|
f"include the unpacked server")
|
|
send_zip = needed
|
|
|
|
was_running = source.get("state") in ("active", "activating", "reloading")
|
|
was_enabled = source.get("boot") == "enabled"
|
|
to = f" as '{new_name}'" if new_name != name else ""
|
|
print(f"{'Moving' if move else 'Copying'} '{name}' from {src.label} to {dst.label}{to}")
|
|
stopped = imported = False
|
|
archive = None
|
|
try:
|
|
if move and was_running:
|
|
print(f"stopping '{name}' on {src.label} (saves the world first)...")
|
|
if src.run(["stop", name], show_stdout=False) != 0:
|
|
die(f"couldn't stop '{name}' on {src.label}")
|
|
stopped = True
|
|
archive = src.export(name, with_server)
|
|
if send_zip:
|
|
stream, size = src.open_zip(send_zip)
|
|
try:
|
|
dst.receive_zip(send_zip, stream, size, transfer_label(src, dst, send_zip))
|
|
finally:
|
|
stream.close()
|
|
if dst.import_(new_name, archive, show_stdout=not move) != 0:
|
|
die(f"importing on {dst.label} failed; '{name}' on {src.label} is unchanged")
|
|
imported = True
|
|
except BaseException:
|
|
if stopped and not imported:
|
|
log(f"starting '{name}' on {src.label} again")
|
|
try:
|
|
src.run(["start", name], show_stdout=False)
|
|
except Fail as e:
|
|
log(f"that failed too: {e}")
|
|
raise
|
|
finally:
|
|
if archive:
|
|
archive.cleanup()
|
|
if not move:
|
|
return 0
|
|
|
|
if src.run(["delete", name, "-y"]) != 0:
|
|
die(f"'{new_name}' is on {dst.label} now, but deleting '{name}' on {src.label} failed (see above), "
|
|
f"so it's in both places. The copy on {dst.label} wasn't started.")
|
|
now = []
|
|
if was_enabled and dst.run(["enable", new_name], show_stdout=False) == 0:
|
|
now.append("starts at boot")
|
|
if was_running and dst.run(["start", new_name], show_stdout=False) == 0:
|
|
now.append("is running")
|
|
state = f"; it {' and '.join(now)} there" if now else ""
|
|
print(f"moved '{name}' from {src.label} to {dst.label}{to}{state}")
|
|
return 0
|
|
|
|
|
|
# ---------------------------------------------------------------- main ----
|
|
|
|
def main(argv):
|
|
if not argv:
|
|
die("usage: terractl --host <remote> <command> | remote ... | daemon ... | copy ... | move ...")
|
|
first = argv[0]
|
|
if first in ("--host", "-H"):
|
|
if len(argv) < 2:
|
|
die("usage: terractl --host <remote> <command> [args]")
|
|
return run_on_remote(argv[1], argv[2:])
|
|
if first.startswith("--host="):
|
|
return run_on_remote(first[len("--host="):], argv[1:])
|
|
if first == "remote":
|
|
return remote_main(argv[1:])
|
|
if first == "daemon":
|
|
return daemon_main(argv[1:])
|
|
if first in ("copy", "move"):
|
|
return copy_or_move(argv[1:], first == "move")
|
|
die(f"unknown command '{first}'")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.stdout.reconfigure(line_buffering=True) # keeps it in order with terractl's own output
|
|
# terractl works from / and says where it was called from (see abspath there)
|
|
try:
|
|
os.chdir(os.environ.get("TERRACTL_CALLER_PWD") or ".")
|
|
except OSError:
|
|
pass
|
|
try:
|
|
sys.exit(main(sys.argv[1:]))
|
|
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)
|
|
sys.exit(1)
|
|
except KeyboardInterrupt:
|
|
print(file=sys.stderr)
|
|
sys.exit(130)
|