Merge pull request #186 from naturallaw777/copilot/add-remote-deployment-mode
feat: Remote deployment mode for headless install and post-install access
This commit is contained in:
@@ -62,8 +62,28 @@ in
|
||||
nixos-install-tools
|
||||
git
|
||||
curl
|
||||
openssh
|
||||
];
|
||||
|
||||
# Remote install support — SSH on the live ISO
|
||||
services.openssh = {
|
||||
enable = true;
|
||||
listenAddresses = [{ addr = "0.0.0.0"; port = 22; }];
|
||||
settings = {
|
||||
PasswordAuthentication = true;
|
||||
PermitRootLogin = "yes";
|
||||
};
|
||||
};
|
||||
users.users.root.initialPassword = "sovran-remote";
|
||||
|
||||
# mDNS so the machine is discoverable as sovran-installer.local
|
||||
services.avahi = {
|
||||
enable = true;
|
||||
hostName = "sovran-installer";
|
||||
nssmdns4 = true;
|
||||
publish = { enable = true; addresses = true; };
|
||||
};
|
||||
|
||||
environment.etc."sovran/logo.png".source = ./assets/splash-logo.png;
|
||||
environment.etc."sovran/flake".source = sovranSource;
|
||||
environment.etc."sovran/installer.py".source = ./installer.py;
|
||||
|
||||
254
iso/sovran-install-headless.sh
Executable file
254
iso/sovran-install-headless.sh
Executable file
@@ -0,0 +1,254 @@
|
||||
#!/usr/bin/env bash
|
||||
# sovran-install-headless.sh — Non-interactive remote installer for Sovran_SystemsOS
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: sovran-install-headless.sh [OPTIONS]
|
||||
|
||||
Options:
|
||||
--disk /dev/sda Target OS disk (required)
|
||||
--data-disk /dev/sdb Data disk for Bitcoin (optional)
|
||||
--role server|desktop|node Installation role (default: server)
|
||||
--deploy-key "ssh-ed25519 AAAA..." SSH pubkey for remote access after install
|
||||
--relay-host HOST Reverse tunnel relay hostname
|
||||
--relay-user USER Relay username (default: deploy)
|
||||
--relay-port PORT Relay SSH port (default: 22)
|
||||
--tunnel-port PORT Reverse tunnel port on relay (default: 2222)
|
||||
USAGE
|
||||
}
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── Defaults ──────────────────────────────────────────────────────────────────
|
||||
DISK=""
|
||||
DATA_DISK=""
|
||||
ROLE="server"
|
||||
DEPLOY_KEY=""
|
||||
RELAY_HOST=""
|
||||
RELAY_USER="deploy"
|
||||
RELAY_PORT="22"
|
||||
TUNNEL_PORT="2222"
|
||||
|
||||
FLAKE="/etc/sovran/flake"
|
||||
LOG="/tmp/sovran-headless-install.log"
|
||||
|
||||
BYTES_256GB=$((256 * 1024 * 1024 * 1024))
|
||||
BYTES_2TB=$((2 * 1000 * 1000 * 1000 * 1000))
|
||||
|
||||
# ── Logging ───────────────────────────────────────────────────────────────────
|
||||
exec > >(tee -a "$LOG") 2>&1
|
||||
|
||||
log() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"
|
||||
}
|
||||
|
||||
die() {
|
||||
log "ERROR: $*"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ── Argument parsing ─────────────────────────────────────────────────────────
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--disk) DISK="$2"; shift 2 ;;
|
||||
--data-disk) DATA_DISK="$2"; shift 2 ;;
|
||||
--role) ROLE="$2"; shift 2 ;;
|
||||
--deploy-key) DEPLOY_KEY="$2"; shift 2 ;;
|
||||
--relay-host) RELAY_HOST="$2"; shift 2 ;;
|
||||
--relay-user) RELAY_USER="$2"; shift 2 ;;
|
||||
--relay-port) RELAY_PORT="$2"; shift 2 ;;
|
||||
--tunnel-port) TUNNEL_PORT="$2"; shift 2 ;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*) die "Unknown argument: $1" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── Validate required arguments ───────────────────────────────────────────────
|
||||
[[ -n "$DISK" ]] || die "--disk is required"
|
||||
|
||||
case "$ROLE" in
|
||||
server|desktop|node) ;;
|
||||
*) die "--role must be one of: server, desktop, node" ;;
|
||||
esac
|
||||
|
||||
# ── Validate disk existence and size ─────────────────────────────────────────
|
||||
log "=== Validating disks ==="
|
||||
|
||||
[[ -b "$DISK" ]] || die "OS disk not found: $DISK"
|
||||
|
||||
disk_size_bytes() {
|
||||
local dev="$1"
|
||||
lsblk -b -dno SIZE "$dev" 2>/dev/null || echo 0
|
||||
}
|
||||
|
||||
OS_SIZE=$(disk_size_bytes "$DISK")
|
||||
log "OS disk $DISK: $OS_SIZE bytes"
|
||||
[[ "$OS_SIZE" -ge "$BYTES_256GB" ]] \
|
||||
|| die "OS disk $DISK is too small ($(( OS_SIZE / 1024 / 1024 / 1024 )) GB). Minimum is 256 GB."
|
||||
|
||||
if [[ -n "$DATA_DISK" ]]; then
|
||||
[[ -b "$DATA_DISK" ]] || die "Data disk not found: $DATA_DISK"
|
||||
[[ "$DATA_DISK" != "$DISK" ]] || die "OS disk and data disk cannot be the same device"
|
||||
DATA_SIZE=$(disk_size_bytes "$DATA_DISK")
|
||||
log "Data disk $DATA_DISK: $DATA_SIZE bytes"
|
||||
[[ "$DATA_SIZE" -ge "$BYTES_2TB" ]] \
|
||||
|| die "Data disk $DATA_DISK is too small ($(( DATA_SIZE / 1024 / 1024 / 1024 )) GB). Minimum is 2 TB."
|
||||
fi
|
||||
|
||||
# ── Helper: partition suffix ──────────────────────────────────────────────────
|
||||
part_suffix() {
|
||||
local dev="$1" n="$2"
|
||||
if [[ "$dev" == *nvme* ]]; then
|
||||
echo "${dev}p${n}"
|
||||
else
|
||||
echo "${dev}${n}"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Step 1: Wipe disks ────────────────────────────────────────────────────────
|
||||
log "=== Wiping disk(s) ==="
|
||||
|
||||
sgdisk --zap-all "$DISK"
|
||||
wipefs --all --force "$DISK"
|
||||
|
||||
if [[ -n "$DATA_DISK" ]]; then
|
||||
sgdisk --zap-all "$DATA_DISK"
|
||||
wipefs --all --force "$DATA_DISK"
|
||||
fi
|
||||
|
||||
partprobe "$DISK"
|
||||
[[ -n "$DATA_DISK" ]] && partprobe "$DATA_DISK"
|
||||
sleep 2
|
||||
|
||||
# ── Step 2: Partition OS disk ─────────────────────────────────────────────────
|
||||
log "=== Partitioning OS disk ==="
|
||||
|
||||
sgdisk \
|
||||
-n "1:1M:+512M" -t "1:EF00" -c "1:ESP" \
|
||||
-n "2:0:0" -t "2:8300" -c "2:root" \
|
||||
"$DISK"
|
||||
|
||||
partprobe "$DISK"
|
||||
sleep 2
|
||||
|
||||
# ── Step 3: Partition data disk (if present) ──────────────────────────────────
|
||||
if [[ -n "$DATA_DISK" ]]; then
|
||||
log "=== Partitioning data disk ==="
|
||||
sgdisk \
|
||||
-n "1:1M:0" -t "1:8300" -c "1:primary" \
|
||||
"$DATA_DISK"
|
||||
partprobe "$DATA_DISK"
|
||||
sleep 2
|
||||
fi
|
||||
|
||||
# ── Step 4: Format partitions ─────────────────────────────────────────────────
|
||||
log "=== Formatting partitions ==="
|
||||
|
||||
BOOT_P1=$(part_suffix "$DISK" 1)
|
||||
BOOT_P2=$(part_suffix "$DISK" 2)
|
||||
|
||||
mkfs.vfat -F 32 "$BOOT_P1"
|
||||
mkfs.ext4 -F -L sovran_systemsos "$BOOT_P2"
|
||||
|
||||
if [[ -n "$DATA_DISK" ]]; then
|
||||
DATA_P1=$(part_suffix "$DATA_DISK" 1)
|
||||
mkfs.ext4 -F -L BTCEcoandBackup "$DATA_P1"
|
||||
fi
|
||||
|
||||
# ── Step 5: Mount filesystems ─────────────────────────────────────────────────
|
||||
log "=== Mounting filesystems ==="
|
||||
|
||||
mount "$BOOT_P2" /mnt
|
||||
mkdir -p /mnt/boot/efi
|
||||
mount -o umask=0077,defaults "$BOOT_P1" /mnt/boot/efi
|
||||
|
||||
if [[ -n "$DATA_DISK" ]]; then
|
||||
DATA_P1=$(part_suffix "$DATA_DISK" 1)
|
||||
mkdir -p /mnt/run/media/Second_Drive
|
||||
mount "$DATA_P1" /mnt/run/media/Second_Drive
|
||||
|
||||
# ── Step 6: Create Bitcoin data directories ─────────────────────────────
|
||||
log "=== Creating Bitcoin data directories ==="
|
||||
mkdir -p /mnt/run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node
|
||||
mkdir -p /mnt/run/media/Second_Drive/BTCEcoandBackup/Electrs_Data
|
||||
mkdir -p /mnt/run/media/Second_Drive/BTCEcoandBackup/NixOS_Snapshot_Backup
|
||||
fi
|
||||
|
||||
# ── Step 7: Generate hardware config ─────────────────────────────────────────
|
||||
log "=== Generating hardware config ==="
|
||||
nixos-generate-config --root /mnt
|
||||
|
||||
# ── Step 8: Copy flake source ─────────────────────────────────────────────────
|
||||
log "=== Copying flake to /mnt ==="
|
||||
cp /mnt/etc/nixos/hardware-configuration.nix /tmp/hardware-configuration.nix
|
||||
rm -rf /mnt/etc/nixos/
|
||||
mkdir -p /mnt/etc/nixos
|
||||
cp -a "${FLAKE}/." /mnt/etc/nixos/
|
||||
cp /tmp/hardware-configuration.nix /mnt/etc/nixos/hardware-configuration.nix
|
||||
|
||||
# ── Step 9: Write role-state.nix ─────────────────────────────────────────────
|
||||
log "=== Writing role config ==="
|
||||
|
||||
case "$ROLE" in
|
||||
server)
|
||||
IS_SERVER=true; IS_DESKTOP=false; IS_NODE=false ;;
|
||||
desktop)
|
||||
IS_SERVER=false; IS_DESKTOP=true; IS_NODE=false ;;
|
||||
node)
|
||||
IS_SERVER=false; IS_DESKTOP=false; IS_NODE=true ;;
|
||||
esac
|
||||
|
||||
cat > /mnt/etc/nixos/role-state.nix <<EOF
|
||||
# THIS FILE IS AUTO-GENERATED BY THE INSTALLER. DO NOT EDIT.
|
||||
{ config, lib, ... }:
|
||||
{
|
||||
sovran_systemsOS.roles.server_plus_desktop = lib.mkDefault ${IS_SERVER};
|
||||
sovran_systemsOS.roles.desktop = lib.mkDefault ${IS_DESKTOP};
|
||||
sovran_systemsOS.roles.node = lib.mkDefault ${IS_NODE};
|
||||
}
|
||||
EOF
|
||||
|
||||
# ── Step 10: Write custom.nix with deploy config ──────────────────────────────
|
||||
log "=== Writing custom.nix ==="
|
||||
|
||||
if [[ -n "$DEPLOY_KEY" ]]; then
|
||||
cat > /mnt/etc/nixos/custom.nix <<EOF
|
||||
{ config, lib, ... }:
|
||||
{
|
||||
sovran_systemsOS.deploy = {
|
||||
enable = true;
|
||||
authorizedKey = "${DEPLOY_KEY}";
|
||||
relayHost = "${RELAY_HOST}";
|
||||
relayUser = "${RELAY_USER}";
|
||||
relayPort = ${RELAY_PORT};
|
||||
reverseTunnelPort = ${TUNNEL_PORT};
|
||||
};
|
||||
}
|
||||
EOF
|
||||
else
|
||||
cp /mnt/etc/nixos/custom.template.nix /mnt/etc/nixos/custom.nix
|
||||
fi
|
||||
|
||||
# ── Step 11: Copy configs to host for flake evaluation ───────────────────────
|
||||
log "=== Copying config files to host /etc/nixos for flake evaluation ==="
|
||||
mkdir -p /etc/nixos
|
||||
cp /mnt/etc/nixos/role-state.nix /etc/nixos/role-state.nix
|
||||
cp /mnt/etc/nixos/custom.nix /etc/nixos/custom.nix
|
||||
cp /mnt/etc/nixos/hardware-configuration.nix /etc/nixos/hardware-configuration.nix
|
||||
|
||||
# ── Step 12: Run nixos-install ────────────────────────────────────────────────
|
||||
log "=== Running nixos-install ==="
|
||||
nixos-install \
|
||||
--root /mnt \
|
||||
--flake /mnt/etc/nixos#nixos \
|
||||
--no-root-password \
|
||||
--impure
|
||||
|
||||
log "=== Installation complete! ==="
|
||||
log "You can now reboot into Sovran_SystemsOS."
|
||||
log "After reboot, the machine will be accessible via SSH on port 22 (if --deploy-key was provided)."
|
||||
[[ -n "$RELAY_HOST" ]] && \
|
||||
log "Reverse tunnel will connect to ${RELAY_USER}@${RELAY_HOST}:${RELAY_PORT} — forward port ${TUNNEL_PORT} maps to the machine's SSH."
|
||||
117
modules/core/remote-deploy.nix
Normal file
117
modules/core/remote-deploy.nix
Normal file
@@ -0,0 +1,117 @@
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
let
|
||||
cfg = config.sovran_systemsOS.deploy;
|
||||
in
|
||||
|
||||
{
|
||||
options.sovran_systemsOS.deploy = {
|
||||
enable = lib.mkEnableOption "Remote deploy mode";
|
||||
|
||||
relayHost = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "";
|
||||
description = "SSH relay server hostname or IP for the reverse tunnel";
|
||||
};
|
||||
|
||||
relayPort = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 22;
|
||||
description = "SSH port on the relay server";
|
||||
};
|
||||
|
||||
relayUser = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "deploy";
|
||||
description = "Username on the relay server";
|
||||
};
|
||||
|
||||
reverseTunnelPort = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 2222;
|
||||
description = "Port on the relay that maps back to this machine's SSH (port 22)";
|
||||
};
|
||||
|
||||
authorizedKey = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "";
|
||||
description = "Deployer's SSH public key for root access";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
|
||||
# ── Force SSH open on all interfaces ────────────────────────────────────
|
||||
services.openssh = {
|
||||
enable = true;
|
||||
listenAddresses = lib.mkForce [
|
||||
{ addr = "0.0.0.0"; port = 22; }
|
||||
{ addr = "127.0.0.1"; port = 22; }
|
||||
];
|
||||
settings = {
|
||||
PermitRootLogin = lib.mkForce "prohibit-password";
|
||||
PasswordAuthentication = lib.mkForce false;
|
||||
};
|
||||
};
|
||||
|
||||
networking.firewall.allowedTCPPorts = [ 22 ];
|
||||
|
||||
# ── Inject deployer's SSH public key into root's authorized keys ─────────
|
||||
users.users.root.openssh.authorizedKeys.keys =
|
||||
lib.mkIf (cfg.authorizedKey != "") [ cfg.authorizedKey ];
|
||||
|
||||
# ── Force RDP on ─────────────────────────────────────────────────────────
|
||||
sovran_systemsOS.features.rdp = lib.mkForce true;
|
||||
|
||||
# ── Enable Fail2Ban for SSH protection ───────────────────────────────────
|
||||
services.fail2ban = {
|
||||
enable = true;
|
||||
ignoreIP = [ "127.0.0.0/8" ];
|
||||
};
|
||||
|
||||
# ── Reverse tunnel service (only when relayHost is configured) ───────────
|
||||
systemd.services.deploy-reverse-tunnel = lib.mkIf (cfg.relayHost != "") {
|
||||
description = "Deploy reverse SSH tunnel to ${cfg.relayHost}";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "network-online.target" "sshd.service" ];
|
||||
wants = [ "network-online.target" ];
|
||||
serviceConfig = {
|
||||
Restart = "always";
|
||||
RestartSec = "10s";
|
||||
ExecStart = "${pkgs.openssh}/bin/ssh"
|
||||
+ " -o StrictHostKeyChecking=accept-new"
|
||||
+ " -o ServerAliveInterval=30"
|
||||
+ " -o ServerAliveCountMax=3"
|
||||
+ " -o ExitOnForwardFailure=yes"
|
||||
+ " -i /var/lib/secrets/deploy-relay-key"
|
||||
+ " -N"
|
||||
+ " -R ${toString cfg.reverseTunnelPort}:localhost:22"
|
||||
+ " -p ${toString cfg.relayPort}"
|
||||
+ " ${cfg.relayUser}@${cfg.relayHost}";
|
||||
};
|
||||
path = [ pkgs.openssh ];
|
||||
};
|
||||
|
||||
# ── Safety auto-expiry service ────────────────────────────────────────────
|
||||
systemd.services.deploy-auto-expire = {
|
||||
description = "Auto-expire remote deploy mode after 48 hours";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "multi-user.target" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = false;
|
||||
};
|
||||
script = ''
|
||||
# 48 hours = 172800 seconds
|
||||
sleep $((48 * 60 * 60))
|
||||
systemctl stop deploy-reverse-tunnel || true
|
||||
mkdir -p /etc/sovran
|
||||
echo "expired" > /etc/sovran/deploy-mode
|
||||
'';
|
||||
path = [ pkgs.coreutils ];
|
||||
};
|
||||
|
||||
# ── Deploy-mode indicator file ────────────────────────────────────────────
|
||||
environment.etc."sovran/deploy-mode".text = "active";
|
||||
};
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
./core/sshd-localhost.nix
|
||||
./core/sovran-hub.nix
|
||||
./core/legacy-cleanup.nix
|
||||
./core/remote-deploy.nix
|
||||
|
||||
# ── Always on (no flag) ───────────────────────────────────
|
||||
./php.nix
|
||||
|
||||
Reference in New Issue
Block a user