#!/usr/bin/env bash # One-time setup of the vault fly's server: a dedicated machine (e.g. a mini PC at home) on a fresh Ubuntu Server 24.04 # with full-disk encryption, hardened along the lines of the CIS Ubuntu 24.04 benchmark for a single-purpose host that # holds a hot wallet. Run with sudo, in two passes: # # 1. sudo TS_AUTHKEY=tskey-auth-... RECOVERY_PUBKEY="ssh-ed25519 AAAA... recovery" EXPECT_MANIFEST= \ # TPM_UNLOCK=1 ADMIN_USER=fly-admin bash bootstrap-host.sh "" # then, from your Mac, log in over Tailscale: ssh fly-admin@fly-vault (MagicDNS name or the 100.x address) # 2. sudo CLOSE_PUBLIC_SSH=1 bash bootstrap-host.sh "" # removes the SSH rule for the local network / internet: after it the machine accepts SSH only over Tailscale and # has no other open port. # # TPM_UNLOCK=1 binds the root disk's LUKS encryption to the TPM (clevis, PCR 7 = the Secure Boot state), so an # unattended security reboot comes back up without a passphrase; it asks once for the disk passphrase. Without it the # machine waits at every boot for the passphrase on its console, and automatic reboots are turned off. # # ADMIN_USER (default fly-admin) becomes the only account that can log in over SSH (key only, sudo without a # password; root login is switched off). If it has no ~/.ssh/authorized_keys yet, root's are copied to it first, and # the script stops before touching SSH if neither exists. Keep this root session open until you have logged in as # ADMIN_USER from a second terminal. deploy/check-host.sh verifies every setting afterwards. # # RELEASE_PUBKEY / RECOVERY_PUBKEY: the two keys whose signatures the updater accepts (principals bryce and # bryce-recovery), valid for the release namespace only. EXPECT_MANIFEST pins the first release the updater may # install (tools/publish_release.py prints it as the manifest sha256); until it is set the updater installs nothing. # TS_AUTHKEY: a one-off, pre-authorised key tagged tag:fly from the Tailscale admin console; used once, not stored. # # Idempotent: safe to run again (keys and edited config files are never overwritten). set -euo pipefail RELEASE_PUBKEY="${1:?usage: ADMIN_USER=fly-admin bootstrap-host.sh \"ecdsa-sha2-nistp256 AAAA... release-key\"}" RECOVERY_PUBKEY="${RECOVERY_PUBKEY:-}" EXPECT_MANIFEST="${EXPECT_MANIFEST:-}" TS_AUTHKEY="${TS_AUTHKEY:-}" CLOSE_PUBLIC_SSH="${CLOSE_PUBLIC_SSH:-0}" TPM_UNLOCK="${TPM_UNLOCK:-0}" ADMIN_USER="${ADMIN_USER:-fly-admin}" # container users (the images' USER lines); with userns-remap each is REMAP_UID + id on the host BRAIN_UID=10001; SIGNER_UID=10002; FLY_GID=10000 HERE="$(cd "$(dirname "$0")" && pwd)" export DEBIAN_FRONTEND=noninteractive step() { printf '\n== %s\n' "$*"; } # ---------------------------------------------------------------- 1. packages step "packages" apt-get update -q apt-get install -y -q --no-install-recommends ca-certificates curl ufw unattended-upgrades python3 openssh-client openssl \ fail2ban python3-systemd auditd audispd-plugins aide aide-common lynis apparmor-utils systemd-timesyncd needrestart \ libpam-tmpdir debsums apt-show-versions apt-listchanges acct sysstat mokutil install -m 0755 -d /etc/apt/keyrings curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" > /etc/apt/sources.list.d/docker.list apt-get update -q apt-get install -y -q docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin # Tailscale: SSH reaches this box only over your tailnet (WireGuard); its repo key is pinned like Docker's curl -fsSL "https://pkgs.tailscale.com/stable/ubuntu/$(. /etc/os-release && echo "$VERSION_CODENAME").noarmor.gpg" -o /etc/apt/keyrings/tailscale.gpg echo "deb [signed-by=/etc/apt/keyrings/tailscale.gpg] https://pkgs.tailscale.com/stable/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") main" > /etc/apt/sources.list.d/tailscale.list apt-get update -q apt-get install -y -q tailscale # nothing on this box needs snaps; fewer daemons, fewer holes if dpkg -l snapd >/dev/null 2>&1; then apt-get purge -y -q snapd; fi # no mail server: nothing here sends mail (alerts go to Telegram), and an MTA is one more listening daemon if dpkg -l postfix 2>/dev/null | grep -q '^ii'; then apt-get purge -y -q postfix; fi # ---------------------------------------------------------------- 1b. the machine itself step "machine: wireless off, DMA protection, disk encryption" # wired Ethernet only: Wi-Fi and Bluetooth drivers can never load (a radio is a way in that bypasses the router) printf 'install cfg80211 /bin/false\ninstall mac80211 /bin/false\ninstall bluetooth /bin/false\ninstall btusb /bin/false\n' \ > /etc/modprobe.d/61-fly-no-wireless.conf command -v rfkill >/dev/null && rfkill block all 2>/dev/null || true systemctl disable --now bluetooth.service wpa_supplicant.service 2>/dev/null || true # USB4 / Thunderbolt / OCuLink devices can read memory directly; the IOMMU confines them if [ -z "$(ls -A /sys/kernel/iommu_groups 2>/dev/null)" ]; then echo "WARNING: the IOMMU is off: enable it (AMD-Vi / IOMMU) in the firmware settings" fi # the root filesystem must sit on LUKS: the wallet keys are files on this disk ROOT_LUKS="$(lsblk -rno NAME,TYPE -s "$(findmnt -no SOURCE /)" 2>/dev/null | awk '$2=="crypt"{found=1} found && $2=="part"{print "/dev/"$1; exit}')" if [ -z "$ROOT_LUKS" ]; then echo "WARNING: the root filesystem is not on an encrypted (LUKS) disk: reinstall with 'Encrypt the LVM group with LUKS'" elif [ "$TPM_UNLOCK" = 1 ]; then apt-get install -y -q --no-install-recommends clevis clevis-luks clevis-tpm2 clevis-initramfs if ! clevis luks list -d "$ROOT_LUKS" 2>/dev/null | grep -q tpm2; then echo "binding $ROOT_LUKS to the TPM (PCR 7); enter the disk passphrase when asked" clevis luks bind -d "$ROOT_LUKS" tpm2 '{"pcr_ids":"7"}' update-initramfs -u -k all fi fi # ---------------------------------------------------------------- 2. patching step "automatic security updates (reboot 04:00 UTC when a kernel needs it)" cat > /etc/apt/apt.conf.d/20auto-upgrades <<'EOF' APT::Periodic::Update-Package-Lists "1"; APT::Periodic::Unattended-Upgrade "1"; APT::Periodic::AutocleanInterval "7"; EOF REBOOT=true # a disk that needs a typed passphrase at boot: no unattended reboots if [ -n "${ROOT_LUKS:-}" ] && ! { command -v clevis >/dev/null && clevis luks list -d "$ROOT_LUKS" 2>/dev/null | grep -q tpm2; }; then REBOOT=false fi cat > /etc/apt/apt.conf.d/52fly-unattended < 5 min off # ---------------------------------------------------------------- 3. admin account step "admin account: $ADMIN_USER" id "$ADMIN_USER" >/dev/null 2>&1 || useradd -m -s /bin/bash "$ADMIN_USER" passwd -l "$ADMIN_USER" >/dev/null # no password at all: SSH key only home="$(getent passwd "$ADMIN_USER" | cut -d: -f6)" install -d -m 0700 -o "$ADMIN_USER" -g "$ADMIN_USER" "$home/.ssh" if [ ! -s "$home/.ssh/authorized_keys" ]; then if [ -s /root/.ssh/authorized_keys ]; then install -m 0600 -o "$ADMIN_USER" -g "$ADMIN_USER" /root/.ssh/authorized_keys "$home/.ssh/authorized_keys" else echo "STOP: neither $ADMIN_USER nor root has ~/.ssh/authorized_keys; add your key first" >&2; exit 1 fi fi echo "$ADMIN_USER ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/90-fly-admin chmod 0440 /etc/sudoers.d/90-fly-admin visudo -cf /etc/sudoers.d/90-fly-admin >/dev/null # NOT in the docker group: that group is root without sudo's log line; docker runs through sudo gpasswd -d "$ADMIN_USER" docker >/dev/null 2>&1 || true # ---------------------------------------------------------------- 4. SSH step "SSH: $ADMIN_USER only, key only, modern crypto" cat > /etc/ssh/sshd_config.d/10-fly-hardening.conf </dev/null || systemctl restart ssh step "fail2ban: SSH brute force" cat > /etc/fail2ban/jail.d/fly-sshd.local <<'EOF' [sshd] enabled = true backend = systemd maxretry = 4 findtime = 10m bantime = 1h bantime.increment = true EOF systemctl enable --now fail2ban systemctl restart fail2ban # ---------------------------------------------------------------- 5. Tailscale + firewall step "Tailscale" systemctl enable --now tailscaled if [ -n "$TS_AUTHKEY" ] && ! tailscale status >/dev/null 2>&1; then tailscale up --auth-key="$TS_AUTHKEY" --hostname=fly-vault --advertise-tags=tag:fly --ssh=false --accept-dns=false fi unset TS_AUTHKEY TS_UP=0; tailscale status >/dev/null 2>&1 && TS_UP=1 step "firewall: SSH over Tailscale only (plus LAN/public SSH until CLOSE_PUBLIC_SSH=1); out: HTTPS, HTTP (apt), DNS, NTP, WireGuard" ufw --force reset >/dev/null ufw default deny incoming ufw default deny outgoing ufw default deny routed ufw allow in on tailscale0 to any port 22 proto tcp comment 'ssh over the tailnet' if [ "$CLOSE_PUBLIC_SSH" = 1 ]; then [ "$TS_UP" = 1 ] || { echo "STOP: Tailscale is not up; closing public SSH now would lock you out" >&2; exit 1; } echo "public SSH closed: log in with ssh $ADMIN_USER@fly-vault (Tailscale)" else ufw limit in 22/tcp comment 'ssh (rate limited) -- removed by CLOSE_PUBLIC_SSH=1' fi ufw allow out 443/tcp comment 'https: RPCs, relay, HF, Telegram, apt, Tailscale control' ufw allow out 80/tcp comment 'http: apt mirrors' ufw allow out 53 comment 'dns' ufw allow out 123/udp comment 'ntp' ufw allow out 41641/udp comment 'tailscale wireguard' ufw allow out 3478/udp comment 'tailscale stun' ufw logging low ufw --force enable # Docker writes its own iptables rules and bypasses ufw; the DOCKER-USER chain is where the host gets a say. Containers # may only talk to each other, reach DNS/HTTPS out, and nothing on the internet may open a connection to them. cat > /usr/local/sbin/fly-docker-firewall <<'EOF' #!/bin/sh set -e iptables -N DOCKER-USER 2>/dev/null || true iptables -F DOCKER-USER iptables -A DOCKER-USER -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN iptables -A DOCKER-USER -i docker0 -o docker0 -j RETURN iptables -A DOCKER-USER -i br-+ -o br-+ -j RETURN for p in 443 80 53; do # 80: apt mirrors during image builds (apt checks its own signatures) iptables -A DOCKER-USER -i br-+ -p tcp --dport $p -j RETURN iptables -A DOCKER-USER -i docker0 -p tcp --dport $p -j RETURN done iptables -A DOCKER-USER -i br-+ -p udp --dport 53 -j RETURN iptables -A DOCKER-USER -i docker0 -p udp --dport 53 -j RETURN iptables -A DOCKER-USER -i br-+ -j DROP iptables -A DOCKER-USER -i docker0 -j DROP iptables -A DOCKER-USER -m conntrack --ctstate NEW -j DROP EOF chmod 0750 /usr/local/sbin/fly-docker-firewall cat > /etc/systemd/system/fly-docker-firewall.service <<'EOF' [Unit] Description=fly-trader: restrict container traffic (DOCKER-USER chain) After=docker.service Requires=docker.service PartOf=docker.service [Service] Type=oneshot RemainAfterExit=yes ExecStart=/usr/local/sbin/fly-docker-firewall [Install] WantedBy=docker.service EOF # ---------------------------------------------------------------- 6. kernel step "kernel and network settings" cat > /etc/sysctl.d/60-fly-hardening.conf <<'EOF' net.ipv4.conf.all.accept_redirects = 0 net.ipv4.conf.default.accept_redirects = 0 net.ipv4.conf.all.secure_redirects = 0 net.ipv4.conf.default.secure_redirects = 0 net.ipv4.conf.all.send_redirects = 0 net.ipv4.conf.default.send_redirects = 0 net.ipv4.conf.all.accept_source_route = 0 net.ipv4.conf.default.accept_source_route = 0 net.ipv4.conf.all.rp_filter = 1 net.ipv4.conf.default.rp_filter = 1 net.ipv4.conf.all.log_martians = 1 net.ipv4.conf.default.log_martians = 1 net.ipv4.icmp_echo_ignore_broadcasts = 1 net.ipv4.icmp_ignore_bogus_error_responses = 1 net.ipv4.tcp_syncookies = 1 net.ipv6.conf.all.accept_redirects = 0 net.ipv6.conf.default.accept_redirects = 0 net.ipv6.conf.all.accept_source_route = 0 net.ipv6.conf.default.accept_source_route = 0 kernel.kptr_restrict = 2 kernel.dmesg_restrict = 1 kernel.unprivileged_bpf_disabled = 1 net.core.bpf_jit_harden = 2 kernel.yama.ptrace_scope = 2 kernel.randomize_va_space = 2 kernel.sysrq = 0 fs.protected_hardlinks = 1 fs.protected_symlinks = 1 fs.protected_fifos = 2 fs.protected_regular = 2 fs.suid_dumpable = 0 EOF sysctl -q --system # core dumps could write a key to disk cat > /etc/security/limits.d/60-fly-nocore.conf <<'EOF' * hard core 0 EOF mkdir -p /etc/systemd/coredump.conf.d printf '[Coredump]\nStorage=none\nProcessSizeMax=0\n' > /etc/systemd/coredump.conf.d/60-fly.conf aa-status --enabled # AppArmor must be on (Docker's default profile uses it) # protocols, filesystems and devices this server never uses: not loadable at all for m in dccp sctp rds tipc usb-storage firewire-core cramfs freevxfs hfs hfsplus jffs2 udf; do echo "install $m /bin/false" done > /etc/modprobe.d/60-fly-blacklist.conf # new files readable by their group at most, never by everyone sed -i 's/^UMASK.*/UMASK\t\t027/' /etc/login.defs printf 'Authorized access only. Activity on this system is logged.\n' > /etc/issue cp /etc/issue /etc/issue.net sed -i 's/^ENABLED=.*/ENABLED="true"/' /etc/default/sysstat 2>/dev/null || true systemctl enable --now acct sysstat >/dev/null 2>&1 || true # ---------------------------------------------------------------- 7. Docker step "Docker: user namespaces, no new privileges, bounded logs" install -d -m 0755 /etc/docker [ -f /etc/docker/daemon.json ] || cat > /etc/docker/daemon.json <<'EOF' { "userns-remap": "default", "no-new-privileges": true, "icc": false, "live-restore": true, "userland-proxy": false, "log-driver": "json-file", "log-opts": {"max-size": "50m", "max-file": "5"} } EOF systemctl restart docker systemctl daemon-reload systemctl enable --now fly-docker-firewall.service # with user namespaces, root inside a container is this unprivileged uid on the host REMAP_UID="$(awk -F: '$1=="dockremap"{print $2; exit}' /etc/subuid)" # ---------------------------------------------------------------- 8. layout, keys, updater step "fly layout and wallets" install -d -m 0750 /srv/fly install -d -m 0755 /srv/fly/releases # public release files (from HF); the remapped container root reads them H_BRAIN=$((REMAP_UID + BRAIN_UID)); H_SIGNER=$((REMAP_UID + SIGNER_UID)); H_FLY=$((REMAP_UID + FLY_GID)) install -d -m 0750 -o "$H_BRAIN" -g "$H_FLY" /srv/fly/data /srv/fly/logs install -d -m 0700 -o "$H_SIGNER" -g "$H_SIGNER" /srv/fly/signer # the signer's own ledger (policy state) install -d -m 0750 -o "$H_SIGNER" -g "$H_FLY" /srv/fly/run # its socket: the brain may connect, nothing else install -d -m 0700 /srv/fly/secrets install -d -m 0755 /etc/fly /usr/local/lib/fly # release signers: valid for the release namespace only, so these keys sign nothing else this box would accept. A re-run # without RECOVERY_PUBKEY keeps the recovery signer already installed (so closing SSH or updating the scripts never drops it). if [ -z "$RECOVERY_PUBKEY" ] && [ -f /etc/fly/allowed_signers ]; then RECOVERY_PUBKEY="$(awk '$1=="bryce-recovery"{ $1=""; $2=""; sub(/^ */, ""); print; exit }' /etc/fly/allowed_signers)" fi { echo "bryce namespaces=\"fly-trader-release\" ${RELEASE_PUBKEY}" if [ -n "$RECOVERY_PUBKEY" ]; then echo "bryce-recovery namespaces=\"fly-trader-release\" ${RECOVERY_PUBKEY}"; fi } > /etc/fly/allowed_signers chmod 0644 /etc/fly/allowed_signers [ -n "$RECOVERY_PUBKEY" ] || echo "WARNING: no RECOVERY_PUBKEY: losing the release key would mean re-bootstrapping the updater" install -m 0755 "$HERE/fly" /usr/local/bin/fly # sudo fly : fly-trader inside the running container # the database password: made here, root-only, handed to both database and brain by compose if [ ! -s /srv/fly/db.env ]; then pw="$(openssl rand -hex 24)" ( umask 077; printf 'POSTGRES_PASSWORD=%s\nDATABASE_URL=postgresql://fly:%s@db:5432/fly_trader\n' "$pw" "$pw" > /srv/fly/db.env ) unset pw fi chmod 0600 /srv/fly/db.env # two hot keys, each an ed25519 seed made HERE and never copied off this box in plaintext (the signer writes # age-encrypted backups for you): the trading key (bot_key: the trading wallet; refills itself from the treasury under # L1) and the payout key (payout_key: holds nothing; pays claims from the treasury under L2). Only the signer container # mounts them. Their public keys are printed below, once; later: sudo fly signer status. make_key() { local name="$1" if [ ! -e "/srv/fly/secrets/$name" ]; then umask 077 openssl genpkey -algorithm ed25519 -out "/srv/fly/secrets/$name.pem" python3 - "$name" <<'PY' import subprocess, sys name = sys.argv[1] A = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" def b58(b): n = int.from_bytes(b, "big"); s = "" while n: n, r = divmod(n, 58); s = A[r] + s return "1" * (len(b) - len(b.lstrip(b"\0"))) + s pem = f"/srv/fly/secrets/{name}.pem" priv = subprocess.run(["openssl", "pkey", "-in", pem, "-outform", "DER"], check=True, capture_output=True).stdout[-32:] pub = subprocess.run(["openssl", "pkey", "-in", pem, "-pubout", "-outform", "DER"], check=True, capture_output=True).stdout[-32:] open(f"/srv/fly/secrets/{name}", "x").write(b58(priv) + "\n") print(f"{name}: {b58(pub)}") PY shred -u "/srv/fly/secrets/$name.pem" fi # readable only by the signer container's user (remapped), not by the brain container or any host account chown "$H_SIGNER:$H_SIGNER" "/srv/fly/secrets/$name" chmod 0400 "/srv/fly/secrets/$name" } make_key bot_key # the trading hot key: holds at most the float; refills itself from the Squads treasury (L1) make_key payout_key # the payout key: holds nothing; pays claims from the treasury within its weekly cap (L2) install -m 0755 "$HERE/fly_update.py" /usr/local/lib/fly/fly_update.py install -m 0755 "$HERE/check-host.sh" /usr/local/lib/fly/check-host.sh [ -f /etc/fly/update.json ] || install -m 0600 "$HERE/update.json.example" /etc/fly/update.json if [ -n "$EXPECT_MANIFEST" ]; then # pin the first release: the updater refuses anything else until one is installed python3 - "$EXPECT_MANIFEST" <<'PY' import json, re, sys pin = sys.argv[1].strip().lower() if not re.fullmatch(r"[0-9a-f]{64}", pin): raise SystemExit("EXPECT_MANIFEST must be a sha256 hex digest") p = "/etc/fly/update.json"; c = json.load(open(p)); c["expect_manifest_sha256"] = pin open(p, "w").write(json.dumps(c, indent=1) + "\n") PY fi [ -f /srv/fly/vault.env ] || install -m 0600 "$HERE/vault.env.example" /srv/fly/vault.env [ -f /srv/fly/custody.env ] || install -m 0600 "$HERE/custody.env.example" /srv/fly/custody.env [ -f /srv/fly/signer.env ] || install -m 0600 "$HERE/signer.env.example" /srv/fly/signer.env install -m 0644 "$HERE/docker-compose.vault.yml" /srv/fly/docker-compose.yml install -m 0644 "$HERE/fly-update.service" /etc/systemd/system/fly-update.service install -m 0644 "$HERE/fly-update.timer" /etc/systemd/system/fly-update.timer install -m 0755 "$HERE/fly_audit_watch.py" /usr/local/lib/fly/fly_audit_watch.py install -m 0644 "$HERE/fly-audit-watch.service" /etc/systemd/system/fly-audit-watch.service install -m 0644 "$HERE/fly-audit-watch.timer" /etc/systemd/system/fly-audit-watch.timer install -m 0644 "$HERE/fly-aide.service" /etc/systemd/system/fly-aide.service install -m 0644 "$HERE/fly-aide.timer" /etc/systemd/system/fly-aide.timer systemctl daemon-reload systemctl enable --now fly-audit-watch.timer # a key read by anything but the signer goes to Telegram within a minute systemctl enable --now fly-aide.timer # the daily file-integrity check reports to Telegram (there is no mail here) # ---------------------------------------------------------------- 9. audit and integrity step "audit log: key reads and config changes" cat > /etc/audit/rules.d/60-fly.rules <<'EOF' -w /srv/fly/secrets -p rwa -k fly_keys -w /etc/fly -p wa -k fly_conf -w /srv/fly/vault.env -p rwa -k fly_conf -w /srv/fly/db.env -p rwa -k fly_conf -w /srv/fly/custody.env -p wa -k fly_conf -w /srv/fly/signer.env -p rwa -k fly_conf -w /srv/fly/signer -p wa -k fly_signer -w /usr/local/lib/fly -p wa -k fly_updater -w /etc/ssh/sshd_config -p wa -k sshd -w /etc/ssh/sshd_config.d -p wa -k sshd -w /etc/sudoers -p wa -k sudoers -w /etc/sudoers.d -p wa -k sudoers -w /etc/docker -p wa -k docker_conf -w /etc/systemd/system -p wa -k systemd_units -w /etc/passwd -p wa -k identity -w /etc/shadow -p wa -k identity -w /etc/group -p wa -k identity EOF augenrules --load >/dev/null systemctl enable --now auditd step "file integrity baseline (AIDE, checked daily; takes a few minutes)" echo 'Checksums = sha512' > /etc/aide/aide.conf.d/10_fly_checksums cat > /etc/aide/aide.conf.d/90_fly_ignore <<'EOF' !/srv/fly/data !/srv/fly/logs !/srv/fly/releases !/var/lib/docker !/var/lib/containerd EOF [ -f /var/lib/aide/aide.db ] || { aideinit -y -f >/dev/null 2>&1 && cp /var/lib/aide/aide.db.new /var/lib/aide/aide.db; } # ---------------------------------------------------------------- 10. logs step "persistent journal" mkdir -p /etc/systemd/journald.conf.d printf '[Journal]\nStorage=persistent\nSystemMaxUse=2G\n' > /etc/systemd/journald.conf.d/60-fly.conf systemctl restart systemd-journald step "done" if [ "$CLOSE_PUBLIC_SSH" = 1 ]; then next="Verify: sudo /usr/local/lib/fly/check-host.sh (every control should pass now)" else next="From your Mac, BEFORE closing this session: ssh $ADMIN_USER@fly-vault (over Tailscale; root login is now off) Then run this script again with CLOSE_PUBLIC_SSH=1 to remove public SSH." fi cat <