@aifabrix/server-setup 1.3.0 → 1.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,8 +3,12 @@
3
3
  # Run via af-server install user@host. REPO_ROOT must be set to the dir containing builder/builder-server/nginx-builder-server.conf.template.
4
4
  # On empty server: installs Docker, nginx, admin user, optional Portainer/Mutagen; then updates nginx config and ensures builder-server container.
5
5
  # Optional env: DEV_DOMAIN, SSL_DIR, DATA_DIR, SETUP_ADMIN_USER, SYNC_USER, BUILDER_SERVER_PORT, NGINX_CONF_DIR, SETUP_HOSTNAME, INSTALL_PORTAINER=1, SKIP_DOCKER_TLS=1.
6
+ # INSTALL_PHASE: infra = Docker, nginx pkg, Mutagen, data dir, apply-dev-users (no vhost, no container, no Docker TLS). server = nginx vhost, builder-server container, Docker TLS. full = both.
6
7
 
7
8
  set -e
9
+ export DEBIAN_FRONTEND=noninteractive
10
+ # Cache sudo so one password covers the whole script (avoids repeated "[sudo] password for ...")
11
+ sudo -v
8
12
 
9
13
  REPO_ROOT="${REPO_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
10
14
  DATA_DIR="${DATA_DIR:-/opt/aifabrix/builder-server/data}"
@@ -14,6 +18,7 @@ SETUP_ADMIN_USER="${SETUP_ADMIN_USER:-serveradmin}"
14
18
  SYNC_USER="${SYNC_USER:-aifabrix-sync}"
15
19
  BUILDER_SERVER_PORT="${BUILDER_SERVER_PORT:-3000}"
16
20
  NGINX_CONF_DIR="${NGINX_CONF_DIR:-/etc/nginx/conf.d}"
21
+ INSTALL_PHASE="${INSTALL_PHASE:-full}"
17
22
 
18
23
  # Sanitize user-controlled env to prevent path/command injection
19
24
  sanitize_domain() {
@@ -25,6 +30,9 @@ sanitize_path() {
25
30
  *) return 0 ;;
26
31
  esac
27
32
  }
33
+ case "$DEV_DOMAIN" in
34
+ *'--'*) echo "Invalid DEV_DOMAIN: '$DEV_DOMAIN' looks like a typo (e.g. missing space before --ssl-dir). Use: af-server install user@host --dev-domain DOMAIN --ssl-dir PATH"; exit 1 ;;
35
+ esac
28
36
  if ! sanitize_domain "$DEV_DOMAIN"; then
29
37
  echo "Invalid DEV_DOMAIN (use only letters, digits, dots, hyphens)."
30
38
  exit 1
@@ -48,6 +56,9 @@ require_sudo() {
48
56
  }
49
57
  require_sudo
50
58
 
59
+ # --- Infra phase: hostname, system, Docker, nginx package, Mutagen, data dir, apply-dev-users ---
60
+ if [ "$INSTALL_PHASE" = "infra" ] || [ "$INSTALL_PHASE" = "full" ]; then
61
+
51
62
  # --- Hostname (optional) ---
52
63
  if [ -n "$SETUP_HOSTNAME" ]; then
53
64
  current=$(hostname 2>/dev/null || true)
@@ -58,7 +69,26 @@ fi
58
69
 
59
70
  # --- System updates ---
60
71
  apt-get update -qq
61
- DEBIAN_FRONTEND=noninteractive apt-get upgrade -y -qq
72
+ apt-get upgrade -y -qq
73
+
74
+ # --- Packages for SSH users (same base as builder-server/wsl/install-wsl-ubuntu-dev.sh; no Node/Ruby) ---
75
+ apt-get install -y \
76
+ openssh-server sudo curl git ca-certificates gnupg software-properties-common apt-transport-https \
77
+ build-essential unzip zip jq wget vim nano less locales \
78
+ python3 python3-venv python3-dev python3-pip \
79
+ openjdk-17-jdk \
80
+ autoconf bison libssl-dev libyaml-dev libreadline-dev zlib1g-dev libffi-dev libgdbm-dev libncurses-dev \
81
+ redis-tools dbus-x11 imagemagick \
82
+ openssh-client rsync sshfs
83
+ systemctl enable ssh 2>/dev/null || systemctl enable sshd 2>/dev/null || true
84
+ systemctl start ssh 2>/dev/null || systemctl start sshd 2>/dev/null || true
85
+
86
+ # --- Azure CLI (az) ---
87
+ # Manual one-liner (sudo must apply to bash): curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash
88
+ echo "=== Azure CLI (optional - remove block if not needed) ==="
89
+ if ! command -v az >/dev/null 2>&1; then
90
+ curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash
91
+ fi
62
92
 
63
93
  # --- Docker ---
64
94
  if ! command -v docker >/dev/null 2>&1; then
@@ -96,30 +126,12 @@ if [ "$INSTALL_PORTAINER" = "1" ] && ! docker ps -a --format '{{.Names}}' 2>/dev
96
126
  portainer/portainer-ce:latest 2>/dev/null || true
97
127
  fi
98
128
 
99
- # --- Nginx ---
129
+ # --- Nginx (package only in infra; vhost + reload in server phase) ---
100
130
  if ! command -v nginx >/dev/null 2>&1; then
101
131
  apt-get install -y nginx
102
132
  systemctl enable nginx
103
133
  systemctl start nginx
104
134
  fi
105
- NGINX_CONF="$NGINX_CONF_DIR/$DEV_DOMAIN.conf"
106
- NGINX_TEMPLATE="$REPO_ROOT/builder/builder-server/nginx-builder-server.conf.template"
107
- # Always update builder vhost from template so re-running install applies latest config.
108
- if [ -f "$NGINX_TEMPLATE" ]; then
109
- sed -e "s|DEV_DOMAIN_PLACEHOLDER|$DEV_DOMAIN|g" \
110
- -e "s|SSL_DIR_PLACEHOLDER|$SSL_DIR|g" \
111
- -e "s|BUILDER_SERVER_PORT_PLACEHOLDER|$BUILDER_SERVER_PORT|g" \
112
- -e "s|DATA_DIR_PLACEHOLDER|$DATA_DIR|g" \
113
- "$NGINX_TEMPLATE" > "$NGINX_CONF"
114
- elif [ ! -f "$NGINX_CONF" ]; then
115
- echo "Warning: SSL prereqs required. Place $NGINX_CONF (see SETUP.md) and ensure $SSL_DIR/wildcard.crt and $SSL_DIR/wildcard.key exist."
116
- fi
117
- if command -v nginx >/dev/null 2>&1 && nginx -t 2>/dev/null; then
118
- systemctl reload nginx
119
- elif [ -f "$NGINX_TEMPLATE" ] && command -v nginx >/dev/null 2>&1; then
120
- echo "Warning: nginx -t failed (config was still written to $NGINX_CONF). Nginx was NOT reloaded."
121
- nginx -t 2>&1 || true
122
- fi
123
135
 
124
136
  # --- Mutagen ---
125
137
  if ! command -v mutagen >/dev/null 2>&1; then
@@ -152,21 +164,110 @@ MUTAGEN_EOF
152
164
  fi
153
165
  fi
154
166
 
155
- # --- Docker TLS (daemon.json) ---
156
- if [ "$SKIP_DOCKER_TLS" != "1" ] && [ -d /etc/docker ]; then
157
- if [ ! -f /etc/docker/daemon.json ]; then
158
- cat > /etc/docker/daemon.json << 'DOCKER_EOF'
159
- {
160
- "tls": true,
161
- "tlsverify": true,
162
- "tlscacert": "/etc/docker/ca.pem",
163
- "tlscert": "/etc/docker/server-cert.pem",
164
- "tlskey": "/etc/docker/server-key.pem",
165
- "hosts": ["tcp://0.0.0.0:2376", "unix:///var/run/docker.sock"]
166
- }
167
- DOCKER_EOF
168
- echo "Docker TLS daemon.json created. Ensure /etc/docker/ca.pem, server-cert.pem, server-key.pem exist (see SETUP.md Docker API TLS)."
167
+ # --- Data dir and workspace/ssh-keys (for apply-dev-users) ---
168
+ mkdir -p "$DATA_DIR" "${DATA_DIR}/workspace" "${DATA_DIR}/ssh-keys"
169
+ chown -R 1001:65533 "$DATA_DIR"
170
+ chmod 755 "$DATA_DIR"
171
+ DATA_DIR_ABS=$(cd "$DATA_DIR" && pwd)
172
+
173
+ # --- Host job: apply per-developer OS users from builder-server state ---
174
+ APPLY_DEV_USERS_SCRIPT="/usr/local/bin/aifabrix-apply-dev-users.sh"
175
+ SETUP_ASSETS_DIR="$(cd "$(dirname "$0")" && pwd)"
176
+ if [ ! -f "$APPLY_DEV_USERS_SCRIPT" ]; then
177
+ if [ -f "$SETUP_ASSETS_DIR/aifabrix-apply-dev-users.sh" ]; then
178
+ cp "$SETUP_ASSETS_DIR/aifabrix-apply-dev-users.sh" "$APPLY_DEV_USERS_SCRIPT"
179
+ else
180
+ # Inline fallback when run from builder/builder-server context
181
+ cat > "$APPLY_DEV_USERS_SCRIPT" << 'APPLY_DEV_EOF'
182
+ #!/bin/sh
183
+ DATA_DIR="${DATA_DIR:-/opt/aifabrix/builder-server/data}"
184
+ SSH_KEYS_DIR="${DATA_DIR}/ssh-keys"
185
+ PENDING_REMOVALS="${DATA_DIR}/pending-removals"
186
+ [ ! -d "$DATA_DIR" ] && exit 0
187
+ if [ -f "$PENDING_REMOVALS" ]; then
188
+ while IFS= read -r dev_id || [ -n "$dev_id" ]; do
189
+ dev_id=$(echo "$dev_id" | tr -d '\r\n ')
190
+ [ -z "$dev_id" ] && continue
191
+ user_name="dev${dev_id}"
192
+ getent passwd "$user_name" >/dev/null 2>&1 && ( userdel -r "$user_name" 2>/dev/null || userdel "$user_name" 2>/dev/null || true )
193
+ done < "$PENDING_REMOVALS"
194
+ : > "$PENDING_REMOVALS"
195
+ fi
196
+ for key_file in "${SSH_KEYS_DIR}"/*/authorized_keys; do
197
+ [ -f "$key_file" ] || continue
198
+ dev_id=$(dirname "$key_file" | xargs basename)
199
+ [ -z "$dev_id" ] && continue
200
+ user_name="dev${dev_id}"
201
+ home_dir="/home/${user_name}"
202
+ workspace_target="${DATA_DIR}/workspace/${user_name}"
203
+ getent passwd "$user_name" >/dev/null 2>&1 || useradd -m -s /bin/bash "$user_name"
204
+ mkdir -p "${home_dir}/.ssh"
205
+ cp "$key_file" "${home_dir}/.ssh/authorized_keys"
206
+ chown -R "${user_name}:${user_name}" "${home_dir}/.ssh"
207
+ chmod 700 "${home_dir}/.ssh"
208
+ chmod 600 "${home_dir}/.ssh/authorized_keys"
209
+ config_file="${home_dir}/.aifabrix/config.yaml"
210
+ if [ ! -f "$config_file" ]; then
211
+ mkdir -p "${home_dir}/.aifabrix"
212
+ hostname_val=$(hostname -f 2>/dev/null || hostname 2>/dev/null || echo "localhost")
213
+ printf 'user-mutagen-folder: %s\nsecrets-encryption: ""\naifabrix-secrets: ""\naifabrix-env-config: ""\nremote-server: "http://localhost:3000"\ndocker-endpoint: "tcp://%s:2376"\nsync-ssh-user: "%s"\nsync-ssh-host: "%s"\n' "$workspace_target" "$hostname_val" "$user_name" "$hostname_val" > "$config_file"
214
+ chown -R "${user_name}:${user_name}" "${home_dir}/.aifabrix"
215
+ chmod 700 "${home_dir}/.aifabrix"
216
+ chmod 600 "$config_file"
217
+ fi
218
+ mkdir -p "$workspace_target"
219
+ chown -R "${user_name}:${user_name}" "$workspace_target"
220
+ [ -L "${home_dir}/workspace" ] && [ "$(readlink -f "${home_dir}/workspace" 2>/dev/null)" != "$(readlink -f "$workspace_target" 2>/dev/null)" ] && rm -f "${home_dir}/workspace"
221
+ [ ! -e "${home_dir}/workspace" ] && ln -s "$workspace_target" "${home_dir}/workspace" && chown -h "${user_name}:${user_name}" "${home_dir}/workspace"
222
+ profile="${home_dir}/.profile"
223
+ [ ! -f "$profile" ] && touch "$profile" && chown "${user_name}:${user_name}" "$profile"
224
+ grep -q 'cd.*workspace' "$profile" 2>/dev/null || echo '[ -d "$HOME/workspace" ] && cd "$HOME/workspace"' >> "$profile"
225
+ bashrc="${home_dir}/.bashrc"
226
+ [ ! -f "$bashrc" ] && touch "$bashrc" && chown "${user_name}:${user_name}" "$bashrc"
227
+ grep -q 'cd.*workspace' "$bashrc" 2>/dev/null || echo '[ -d "$HOME/workspace" ] && cd "$HOME/workspace"' >> "$bashrc"
228
+ done
229
+ APPLY_DEV_EOF
169
230
  fi
231
+ chmod 755 "$APPLY_DEV_USERS_SCRIPT"
232
+ fi
233
+ # Defaults for apply script (aifabrix-secrets, aifabrix-env-config in generated config); match builder-server env.template
234
+ APPLY_DEFAULTS="$DATA_DIR_ABS/apply-dev-users-defaults"
235
+ if [ ! -f "$APPLY_DEFAULTS" ]; then
236
+ {
237
+ echo "# Sourced by cron before aifabrix-apply-dev-users.sh; override to match builder-server .env"
238
+ echo 'export AIFABRIX_SECRETS="${AIFABRIX_SECRETS:-/aifabrix-miso/builder/secrets.local.yaml}"'
239
+ echo 'export AIFABRIX_ENV_CONFIG="${AIFABRIX_ENV_CONFIG:-aifabrix-miso/builder/env-config.yaml}"'
240
+ } > "$APPLY_DEFAULTS"
241
+ chmod 644 "$APPLY_DEFAULTS"
242
+ fi
243
+ if [ -d /etc/cron.d ] && [ ! -f /etc/cron.d/aifabrix-apply-dev-users ]; then
244
+ echo "*/2 * * * * root /bin/sh -c '. $APPLY_DEFAULTS 2>/dev/null; export DATA_DIR=$DATA_DIR_ABS; exec $APPLY_DEV_USERS_SCRIPT'" > /etc/cron.d/aifabrix-apply-dev-users
245
+ chmod 644 /etc/cron.d/aifabrix-apply-dev-users
246
+ fi
247
+
248
+ fi
249
+ # --- End infra phase ---
250
+
251
+ # --- Server phase: nginx vhost, builder-server container, Docker TLS ---
252
+ if [ "$INSTALL_PHASE" = "server" ] || [ "$INSTALL_PHASE" = "full" ]; then
253
+
254
+ # --- Nginx builder vhost from template ---
255
+ NGINX_CONF="$NGINX_CONF_DIR/$DEV_DOMAIN.conf"
256
+ NGINX_TEMPLATE="$REPO_ROOT/builder/builder-server/nginx-builder-server.conf.template"
257
+ if [ -f "$NGINX_TEMPLATE" ]; then
258
+ sed -e "s|DEV_DOMAIN_PLACEHOLDER|$DEV_DOMAIN|g" \
259
+ -e "s|SSL_DIR_PLACEHOLDER|$SSL_DIR|g" \
260
+ -e "s|BUILDER_SERVER_PORT_PLACEHOLDER|$BUILDER_SERVER_PORT|g" \
261
+ -e "s|DATA_DIR_PLACEHOLDER|$DATA_DIR|g" \
262
+ "$NGINX_TEMPLATE" > "$NGINX_CONF"
263
+ elif [ ! -f "$NGINX_CONF" ]; then
264
+ echo "Warning: SSL prereqs required. Place $NGINX_CONF (see SETUP.md) and ensure $SSL_DIR/wildcard.crt and $SSL_DIR/wildcard.key exist."
265
+ fi
266
+ if command -v nginx >/dev/null 2>&1 && nginx -t 2>/dev/null; then
267
+ systemctl reload nginx
268
+ elif [ -f "$NGINX_TEMPLATE" ] && command -v nginx >/dev/null 2>&1; then
269
+ echo "Warning: nginx -t failed (config was still written to $NGINX_CONF). Nginx was NOT reloaded."
270
+ nginx -t 2>&1 || true
170
271
  fi
171
272
 
172
273
  # --- Builder-server data dir and container ---
@@ -217,6 +318,7 @@ if command -v docker >/dev/null 2>&1; then
217
318
  -e PORT=3000 \
218
319
  -e DATA_DIR="$CONTAINER_DATA_PATH" \
219
320
  -e ENCRYPTION_KEY_PATH="${CONTAINER_DATA_PATH}/secrets-encryption.key" \
321
+ -e DOCKER_ENDPOINT="tcp://${DEV_DOMAIN}:2376" \
220
322
  "$IMG_TO_USE"
221
323
  else
222
324
  echo "Builder-server image not found. Get the image from the AI Fabrix Builder (aifabrix build builder-server; then push/deploy to this host). No source or docker build on server. See builder/builder-server/README.md."
@@ -227,50 +329,88 @@ if command -v docker >/dev/null 2>&1; then
227
329
  fi
228
330
  fi
229
331
 
230
- # --- Sync user for Mutagen SSH ---
231
- SYNC_USER_HOME="${DATA_DIR}/.sync-home"
232
- MANAGED_KEYS_FILE="${DATA_DIR}/sync-authorized-keys"
233
- if ! getent passwd "$SYNC_USER" >/dev/null 2>&1; then
234
- useradd --system --home-dir "$SYNC_USER_HOME" --create-home --shell /bin/bash "$SYNC_USER"
235
- fi
236
- mkdir -p "$SYNC_USER_HOME/.ssh"
237
- chown -R "$SYNC_USER:$SYNC_USER" "$SYNC_USER_HOME"
238
- chmod 700 "$SYNC_USER_HOME/.ssh"
239
- if [ ! -f "$SYNC_USER_HOME/.ssh/authorized_keys" ]; then
240
- touch "$SYNC_USER_HOME/.ssh/authorized_keys"
241
- chown "$SYNC_USER:$SYNC_USER" "$SYNC_USER_HOME/.ssh/authorized_keys"
242
- chmod 600 "$SYNC_USER_HOME/.ssh/authorized_keys"
332
+ # --- Docker TLS (daemon.json + certs): use website cert for server, builder-server CA for client verification ---
333
+ if [ "$SKIP_DOCKER_TLS" != "1" ] && [ -d /etc/docker ]; then
334
+ DATA_DIR_ABS=$(cd "$DATA_DIR" 2>/dev/null && pwd) || true
335
+ if [ -n "$DATA_DIR_ABS" ]; then
336
+ # Wait for builder-server to create ca.crt (up to 30s)
337
+ for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do
338
+ [ -f "$DATA_DIR_ABS/ca.crt" ] && break
339
+ sleep 2
340
+ done
341
+ # Server cert/key: use existing website cert (same as nginx) so no extra cert to manage
342
+ if [ -f "$SSL_DIR/wildcard.crt" ] && [ -f "$SSL_DIR/wildcard.key" ]; then
343
+ cp "$SSL_DIR/wildcard.crt" /etc/docker/server-cert.pem
344
+ cp "$SSL_DIR/wildcard.key" /etc/docker/server-key.pem
345
+ chmod 600 /etc/docker/server-key.pem
346
+ fi
347
+ # Client verification: CA that signed developer certs (builder-server creates ca.crt on first run)
348
+ if [ -f "$DATA_DIR_ABS/ca.crt" ]; then
349
+ cp "$DATA_DIR_ABS/ca.crt" /etc/docker/ca.pem
350
+ fi
351
+ if [ -f /etc/docker/ca.pem ] && [ -f /etc/docker/server-cert.pem ] && [ -f /etc/docker/server-key.pem ]; then
352
+ # Allow daemon.json to set "hosts" (avoid conflict with systemd -H fd://)
353
+ DOCKER_DROPIN="/etc/systemd/system/docker.service.d"
354
+ if [ -d /etc/systemd/system ]; then
355
+ mkdir -p "$DOCKER_DROPIN"
356
+ if [ ! -f "$DOCKER_DROPIN/override.conf" ]; then
357
+ cat > "$DOCKER_DROPIN/override.conf" << 'DROPIN_EOF'
358
+ [Service]
359
+ ExecStart=
360
+ ExecStart=/usr/bin/dockerd
361
+ DROPIN_EOF
362
+ systemctl daemon-reload
363
+ fi
364
+ fi
365
+ [ -f /etc/docker/daemon.json ] && cp -a /etc/docker/daemon.json /etc/docker/daemon.json.bak
366
+ DATA_ROOT=''
367
+ if [ -f /etc/docker/daemon.json.bak ]; then
368
+ DATA_ROOT=$(grep -o '"data-root"[[:space:]]*:[[:space:]]*"[^"]*"' /etc/docker/daemon.json.bak 2>/dev/null | sed 's/.*:[[:space:]]*"\([^"]*\)".*/\1/' | head -1)
369
+ fi
370
+ if [ -n "$DATA_ROOT" ] && [ -d "$DATA_ROOT" ]; then
371
+ cat > /etc/docker/daemon.json << DOCKER_EOF
372
+ {
373
+ "data-root": "$DATA_ROOT",
374
+ "tls": true,
375
+ "tlsverify": true,
376
+ "tlscacert": "/etc/docker/ca.pem",
377
+ "tlscert": "/etc/docker/server-cert.pem",
378
+ "tlskey": "/etc/docker/server-key.pem",
379
+ "hosts": ["tcp://0.0.0.0:2376", "unix:///var/run/docker.sock"]
380
+ }
381
+ DOCKER_EOF
382
+ else
383
+ cat > /etc/docker/daemon.json << 'DOCKER_EOF'
384
+ {
385
+ "tls": true,
386
+ "tlsverify": true,
387
+ "tlscacert": "/etc/docker/ca.pem",
388
+ "tlscert": "/etc/docker/server-cert.pem",
389
+ "tlskey": "/etc/docker/server-key.pem",
390
+ "hosts": ["tcp://0.0.0.0:2376", "unix:///var/run/docker.sock"]
391
+ }
392
+ DOCKER_EOF
393
+ fi
394
+ systemctl restart docker 2>/dev/null || true
395
+ if ! docker info >/dev/null 2>&1; then
396
+ echo "Docker failed to start with TLS config. Restoring previous daemon.json so Docker can start."
397
+ if [ -f /etc/docker/daemon.json.bak ]; then
398
+ mv /etc/docker/daemon.json.bak /etc/docker/daemon.json
399
+ else
400
+ rm -f /etc/docker/daemon.json
401
+ fi
402
+ systemctl start docker 2>/dev/null || true
403
+ echo "Run scripts/fix-docker-daemon.sh if Docker still does not start. See SETUP.md Docker API TLS."
404
+ else
405
+ docker start aifabrix-builder-server 2>/dev/null || docker start builder-server 2>/dev/null || true
406
+ fi
407
+ else
408
+ echo "Docker TLS skipped: need $SSL_DIR/wildcard.crt, wildcard.key and $DATA_DIR/ca.crt (ca.crt appears after builder-server first run). Re-run af-server install after container has started. See SETUP.md Docker API TLS."
409
+ fi
410
+ fi
243
411
  fi
244
412
 
245
- # --- Workspace dir ---
246
- mkdir -p "${DATA_DIR}/workspace"
247
- chown -R "$SYNC_USER:$SYNC_USER" "${DATA_DIR}/workspace" 2>/dev/null || true
248
-
249
- # --- Host job: apply managed SSH keys to sync user ---
250
- APPLY_KEYS_SCRIPT="/usr/local/bin/aifabrix-apply-sync-keys.sh"
251
- if [ ! -f "$APPLY_KEYS_SCRIPT" ]; then
252
- cat > "$APPLY_KEYS_SCRIPT" << APPLY_EOF
253
- #!/bin/sh
254
- SYNC_USER="$SYNC_USER"
255
- DATA_DIR="$DATA_DIR"
256
- SYNC_HOME="\${DATA_DIR}/.sync-home"
257
- MANAGED="\${DATA_DIR}/sync-authorized-keys"
258
- if [ -f "\$MANAGED" ]; then
259
- cp "\$MANAGED" "\$SYNC_HOME/.ssh/authorized_keys"
260
- else
261
- touch "\$SYNC_HOME/.ssh/authorized_keys"
262
- fi
263
- chown "\$SYNC_USER:\$SYNC_USER" "\$SYNC_HOME/.ssh/authorized_keys"
264
- chmod 600 "\$SYNC_HOME/.ssh/authorized_keys"
265
- if [ -d "\${DATA_DIR}/workspace" ]; then
266
- chown -R "\$SYNC_USER:\$SYNC_USER" "\${DATA_DIR}/workspace"
267
- fi
268
- APPLY_EOF
269
- chmod 755 "$APPLY_KEYS_SCRIPT"
270
- fi
271
- if [ -d /etc/cron.d ] && [ ! -f /etc/cron.d/aifabrix-sync-keys ]; then
272
- echo "*/2 * * * * root $APPLY_KEYS_SCRIPT" > /etc/cron.d/aifabrix-sync-keys
273
- chmod 644 /etc/cron.d/aifabrix-sync-keys
274
413
  fi
414
+ # --- End server phase ---
275
415
 
276
416
  echo "Setup complete. Ensure manual prerequisites (SSL at $SSL_DIR: wildcard.crt, wildcard.key; DNS $DEV_DOMAIN) are done; see SETUP.md."
@@ -0,0 +1,42 @@
1
+ #!/bin/sh
2
+ # One-time bootstrap: ensure SSH, install Node 18+ and npm, then af-server CLI on the server.
3
+ # Run via: af-server install-init user@host (from PC over SSH). No Docker, nginx, or builder-server.
4
+ # After this, user logs in to the server and runs: sudo af-server install, then sudo af-server install-server --dev-domain DOMAIN.
5
+
6
+ set -e
7
+ export DEBIAN_FRONTEND=noninteractive
8
+
9
+ # --- SSH server (so install-init and later ssh-cert install can connect) ---
10
+ wait_for_apt() {
11
+ i=0
12
+ while [ $i -lt 20 ]; do
13
+ apt-get update -qq 2>/dev/null && return 0
14
+ echo "Waiting for apt lock..."; sleep 6
15
+ i=$((i + 1))
16
+ done
17
+ return 1
18
+ }
19
+ wait_for_apt
20
+ apt-get install -y openssh-server
21
+ systemctl enable ssh 2>/dev/null || systemctl enable sshd 2>/dev/null || true
22
+ systemctl start ssh 2>/dev/null || systemctl start sshd 2>/dev/null || true
23
+
24
+ # --- Node.js 18+ and npm (NodeSource) ---
25
+ need_node=0
26
+ if ! command -v node >/dev/null 2>&1; then
27
+ need_node=1
28
+ else
29
+ case "$(node -v 2>/dev/null)" in v1[89].*|v[2-9]*) ;; *) need_node=1 ;; esac
30
+ fi
31
+ if [ "$need_node" = "1" ]; then
32
+ curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
33
+ apt-get install -y nodejs
34
+ fi
35
+ node -v
36
+ npm -v
37
+
38
+ # --- af-server CLI (same as on PC) ---
39
+ npm install -g @aifabrix/builder @aifabrix/server-setup
40
+ command -v af-server >/dev/null 2>&1 && af-server --version || true
41
+
42
+ echo "Bootstrap complete. Log in to the server and run: sudo af-server install, then sudo af-server install-server --dev-domain YOUR_DOMAIN"
@@ -100,7 +100,7 @@ describe('runBackup', () => {
100
100
  mockExec.mockResolvedValue({ stdout: '1\n', stderr: '', code: 0 });
101
101
  mockReadFile.mockResolvedValue(Buffer.from(''));
102
102
  const result = await runBackup({ target: 'user@host', dataDir: '/data' });
103
- expect(result).toMatch(/\/aifabrix-backup-\d{8}-\d{4}\.zip$/);
103
+ expect(result).toMatch(/aifabrix-backup-\d{8}-\d{4}\.zip$/);
104
104
  expect(fs.existsSync(result)).toBe(true);
105
105
  try {
106
106
  fs.unlinkSync(result);
package/dist/cli.js CHANGED
@@ -3,21 +3,42 @@
3
3
  * af-server — Install, backup, and restore AI Fabrix builder-server (config + DB) over SSH.
4
4
  * Usage: af-server <command> [options] [user@host]
5
5
  */
6
+ import { readFileSync } from 'fs';
7
+ import { dirname, join } from 'path';
8
+ import { fileURLToPath } from 'url';
6
9
  import { Command } from 'commander';
7
- import { runInstall, runInstallLocal } from './install.js';
10
+ import { runInstall, runInstallLocal, runInstallServerLocal } from './install.js';
8
11
  import { runBackup, runBackupLocal } from './backup.js';
9
12
  import { runBackupScheduleInstall, runBackupScheduleInstallLocal } from './backup-schedule.js';
10
13
  import { runRestore, runRestoreLocal } from './restore.js';
11
14
  import { runSshCertRequest, runSshCertInstall, runSshCertInstallLocal } from './ssh-cert.js';
15
+ import { runInstallSsh, runInstallSshLocal } from './install-ssh.js';
16
+ import { runInstallInit } from './install-init.js';
12
17
  import { requireUbuntu } from './ubuntu.js';
18
+ const __dirname = dirname(fileURLToPath(import.meta.url));
19
+ const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
13
20
  const program = new Command();
14
21
  program
15
22
  .name('af-server')
16
23
  .description('Install, backup, and restore AI Fabrix builder-server over SSH (config + DB only)')
17
- .version('0.1.0');
24
+ .version(pkg.version);
25
+ program
26
+ .command('install-init <user@host>')
27
+ .description('One-time bootstrap over SSH: install on server SSH (if needed), Node 18+, npm, and af-server CLI. Run from PC only.')
28
+ .option('-i, --identity <path>', 'SSH private key path')
29
+ .action(async (target, opts) => {
30
+ try {
31
+ await runInstallInit({ target: target.trim(), privateKeyPath: opts.identity });
32
+ console.log('Install-init complete. Log in to the server and run: sudo af-server install, then sudo af-server install-server --dev-domain YOUR_DOMAIN');
33
+ }
34
+ catch (err) {
35
+ console.error(err instanceof Error ? err.message : err);
36
+ process.exit(1);
37
+ }
38
+ });
18
39
  program
19
40
  .command('install [user@host]')
20
- .description('Run server setup on host or locally (omit target on server). Docker, nginx, SSL, cron.')
41
+ .description('Run server setup on host or locally (omit target on server). Docker, nginx pkg, Mutagen, cron; no builder-server. Run on server: sudo af-server install.')
21
42
  .option('-d, --data-dir <path>', 'DATA_DIR', '/opt/aifabrix/builder-server/data')
22
43
  .option('--dev-domain <domain>', 'DEV_DOMAIN for nginx', 'builder01.aifabrix.dev')
23
44
  .option('--ssl-dir <path>', 'SSL_DIR', '/opt/aifabrix/ssl')
@@ -51,6 +72,29 @@ program
51
72
  process.exit(1);
52
73
  }
53
74
  });
75
+ program
76
+ .command('install-server')
77
+ .description('On server only: nginx vhost, builder-server container, Docker TLS. Run after: sudo af-server install. Requires --dev-domain.')
78
+ .option('-d, --data-dir <path>', 'DATA_DIR', '/opt/aifabrix/builder-server/data')
79
+ .requiredOption('--dev-domain <domain>', 'DEV_DOMAIN for nginx (e.g. builder01.aifabrix.dev)')
80
+ .option('--ssl-dir <path>', 'SSL_DIR', '/opt/aifabrix/ssl')
81
+ .option('--builder-port <port>', 'BUILDER_SERVER_PORT', '3000')
82
+ .action(async (opts) => {
83
+ try {
84
+ requireUbuntu();
85
+ runInstallServerLocal({
86
+ dataDir: opts.dataDir,
87
+ devDomain: opts.devDomain,
88
+ sslDir: opts.sslDir,
89
+ builderServerPort: opts.builderPort ? parseInt(opts.builderPort, 10) : undefined,
90
+ });
91
+ console.log('Install-server complete.');
92
+ }
93
+ catch (err) {
94
+ console.error(err instanceof Error ? err.message : err);
95
+ process.exit(1);
96
+ }
97
+ });
54
98
  program
55
99
  .command('backup [user@host]')
56
100
  .description('On-demand backup, or --schedule to install cron (omit target for local).')
@@ -133,6 +177,25 @@ program
133
177
  process.exit(1);
134
178
  }
135
179
  });
180
+ program
181
+ .command('install-ssh [user@host]')
182
+ .description('Activate SSH server (install openssh-server, enable and start ssh) without login. Omit target for local.')
183
+ .option('-i, --identity <path>', 'SSH private key path')
184
+ .action(async (target, opts) => {
185
+ try {
186
+ if (!target || !target.trim()) {
187
+ requireUbuntu();
188
+ runInstallSshLocal();
189
+ }
190
+ else {
191
+ await runInstallSsh({ target: target.trim(), privateKeyPath: opts.identity });
192
+ }
193
+ }
194
+ catch (err) {
195
+ console.error(err instanceof Error ? err.message : err);
196
+ process.exit(1);
197
+ }
198
+ });
136
199
  const sshCert = program.command('ssh-cert').description('Passwordless auth: install = append your SSH public key; request = stub for future SSH CA.');
137
200
  sshCert
138
201
  .command('request')
@@ -0,0 +1,6 @@
1
+ /**
2
+ * install-init: One-time bootstrap over SSH. Installs on the server: SSH (if needed), Node 18+, npm, and af-server CLI.
3
+ * No Docker, nginx, or builder-server. After this, the user logs in to the server and runs install + install-server locally.
4
+ */
5
+ import { type SSHConnectionOptions } from './ssh.js';
6
+ export declare function runInstallInit(options: SSHConnectionOptions): Promise<void>;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * install-init: One-time bootstrap over SSH. Installs on the server: SSH (if needed), Node 18+, npm, and af-server CLI.
3
+ * No Docker, nginx, or builder-server. After this, the user logs in to the server and runs install + install-server locally.
4
+ */
5
+ import * as path from 'path';
6
+ import * as fs from 'fs';
7
+ import { fileURLToPath } from 'url';
8
+ import { createSSHClient, exec, writeFile, close } from './ssh.js';
9
+ const scriptDir = path.dirname(fileURLToPath(import.meta.url));
10
+ const ASSETS_DIR = path.resolve(scriptDir, '..', 'assets');
11
+ function toUnixLf(s) {
12
+ return s.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
13
+ }
14
+ function getInitScript() {
15
+ const p = path.join(ASSETS_DIR, 'setup-install-init.sh');
16
+ return toUnixLf(fs.readFileSync(p, 'utf8'));
17
+ }
18
+ export async function runInstallInit(options) {
19
+ const conn = await createSSHClient(options);
20
+ try {
21
+ const tmpDir = `/tmp/aifabrix-init-${Date.now()}`;
22
+ await exec(conn, `mkdir -p ${tmpDir}`);
23
+ await exec(conn, `chmod 755 ${tmpDir}`);
24
+ const script = getInitScript();
25
+ await writeFile(conn, `${tmpDir}/setup-install-init.sh`, script);
26
+ await exec(conn, `chmod +x ${tmpDir}/setup-install-init.sh`);
27
+ const cmd = `sudo ${tmpDir}/setup-install-init.sh`;
28
+ const result = await exec(conn, cmd);
29
+ if (result.stderr)
30
+ process.stderr.write(result.stderr);
31
+ if (result.stdout)
32
+ process.stdout.write(result.stdout);
33
+ if (result.code !== 0) {
34
+ throw new Error(`install-init script exited with code ${result.code}`);
35
+ }
36
+ await exec(conn, `rm -rf ${tmpDir}`);
37
+ }
38
+ finally {
39
+ close(conn);
40
+ }
41
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Tests for install-init: bootstrap over SSH (mocked).
3
+ * install-init.ts uses import.meta.url; Jest (CJS) fails to load it. Skipped until ESM/import.meta is supported in tests.
4
+ */
5
+ describe.skip('install-init', () => {
6
+ it('placeholder until Jest supports import.meta in transformed modules', () => {
7
+ expect(true).toBe(true);
8
+ });
9
+ });
10
+ export {};
@@ -0,0 +1,10 @@
1
+ /**
2
+ * install-ssh: Activate SSH server (openssh-server) without login.
3
+ * Installs openssh-server if needed, enables and starts the ssh service so the server accepts SSH connections.
4
+ */
5
+ import { type SSHConnectionOptions } from './ssh.js';
6
+ export declare function runInstallSsh(options: SSHConnectionOptions): Promise<void>;
7
+ /**
8
+ * Enable and start SSH server locally (no SSH). Call requireUbuntu() before this.
9
+ */
10
+ export declare function runInstallSshLocal(): void;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * install-ssh: Activate SSH server (openssh-server) without login.
3
+ * Installs openssh-server if needed, enables and starts the ssh service so the server accepts SSH connections.
4
+ */
5
+ import { execSync } from 'child_process';
6
+ import { createSSHClient, exec, close } from './ssh.js';
7
+ const INSTALL_SSH_SCRIPT = [
8
+ 'wait_for_apt() { i=0; while [ $i -lt 20 ]; do',
9
+ ' apt-get update -qq 2>/dev/null && return 0;',
10
+ ' echo "Waiting for apt lock (another process is using apt)..."; sleep 6; i=$((i+1));',
11
+ ' done; return 1;',
12
+ '};',
13
+ 'wait_for_apt && DEBIAN_FRONTEND=noninteractive apt-get install -y openssh-server && systemctl enable ssh && systemctl start ssh',
14
+ ].join(' ');
15
+ export async function runInstallSsh(options) {
16
+ const conn = await createSSHClient(options);
17
+ try {
18
+ const cmd = `sudo bash -c '${INSTALL_SSH_SCRIPT.replace(/'/g, "'\"'\"'")}'`;
19
+ const result = await exec(conn, cmd);
20
+ if (result.stderr)
21
+ process.stderr.write(result.stderr);
22
+ if (result.stdout)
23
+ process.stdout.write(result.stdout);
24
+ if (result.code !== 0) {
25
+ throw new Error(`install-ssh exited with code ${result.code}`);
26
+ }
27
+ console.log('SSH server is enabled and running. You can connect with key-based or password auth.');
28
+ }
29
+ finally {
30
+ close(conn);
31
+ }
32
+ }
33
+ /**
34
+ * Enable and start SSH server locally (no SSH). Call requireUbuntu() before this.
35
+ */
36
+ export function runInstallSshLocal() {
37
+ execSync(`sudo bash -c '${INSTALL_SSH_SCRIPT.replace(/'/g, "'\"'\"'")}'`, { stdio: 'inherit' });
38
+ console.log('SSH server is enabled and running. You can connect with key-based or password auth.');
39
+ }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Tests for install-ssh: remote (mocked SSH) and local (mocked execSync).
3
+ */
4
+ export {};