@aifabrix/server-setup 1.2.0 → 1.4.0

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.
package/README.md CHANGED
@@ -17,7 +17,8 @@ This is the **one document** you need to get your builder-server **from zero to
17
17
  **The builder-server Docker image is not on a public registry.** You get it with the **AI Fabrix platform**.
18
18
 
19
19
  1. Install the **AI Fabrix Builder** CLI: `npm install -g @aifabrix/builder`
20
- 2. Use the CLI to run or deploy the platform (including builder-server). See [AI Fabrix Builder](https://github.com/esystemsdev/aifabrix-builder) and [docs/README.md](https://github.com/esystemsdev/aifabrix-builder/blob/main/docs/README.md).
20
+ 2. Install the **AI Fabrix server-setup** CLI (af-server): `npm install -g @aifabrix/server-setup`
21
+ 3. Use the Builder CLI to run or deploy the platform (including builder-server). See [AI Fabrix Builder](https://github.com/esystemsdev/aifabrix-builder) and [docs/README.md](https://github.com/esystemsdev/aifabrix-builder/blob/main/docs/README.md).
21
22
 
22
23
  Once you have the platform (and the builder-server image), use **af-server** to install that server on your own host.
23
24
 
@@ -39,16 +40,71 @@ Do the steps in [What you must do before running af-server](#what-you-must-do-be
39
40
 
40
41
  ## Install: from zero to running
41
42
 
42
- **From your PC** (SSH to server):
43
+ Complete the [manual prerequisites](#what-you-must-do-before-running-af-server) first (DNS, SSL directory, certificate and key on the server).
43
44
 
44
- ```bash
45
- af-server install user@host
46
- ```
45
+ ### Option A: From your PC (remote install over SSH)
46
+
47
+ 1. **Install CLIs** (one-time):
48
+
49
+ ```bash
50
+ npm install -g @aifabrix/builder
51
+ npm install -g @aifabrix/server-setup
52
+ ```
53
+
54
+ 2. **Set your server target and local path** (replace with your host, user, and the folder on your PC where your cert and key files are):
55
+
56
+ ```bash
57
+ export SSH=serveradmin@builder02.aifabrix.dev
58
+ export HDD=/c/git/esystemsdev/aifabrix-setup/certificates
59
+ export DOMAIN=builder02.aifabrix.dev
60
+ ```
61
+
62
+ 3. **If the server has no SSH yet** (e.g. fresh VM with console only), enable SSH first (you need another way to run one command, e.g. cloud console):
63
+
64
+ ```bash
65
+ af-server install-ssh $SSH
66
+ ```
67
+
68
+ 4. **Add your SSH key** for passwordless auth (so install can run over SSH):
69
+
70
+ ```bash
71
+ af-server ssh-cert install $SSH
72
+ ```
73
+
74
+ 5. **Put SSL cert and key on the server** in `/opt/aifabrix/ssl` as `wildcard.crt` and `wildcard.key`. See [SSL directory and certificates](#ssl-directory-and-certificates).
75
+
76
+ Example: copy from your PC to the server (create the directory on the server first, then copy and fix key permissions):
77
+
78
+ ```bash
79
+ ssh $SSH "sudo mkdir -p /opt/aifabrix/ssl"
80
+ scp $HDD/wildcard.crt $SSH:/tmp/wildcard.crt
81
+ scp $HDD/wildcard.key $SSH:/tmp/wildcard.key
82
+ ssh $SSH "sudo mv /tmp/wildcard.crt /tmp/wildcard.key /opt/aifabrix/ssl/ && sudo chmod 600 /opt/aifabrix/ssl/wildcard.key"
83
+ ```
84
+
85
+ 6. **Run install** (uses sudo on the server):
86
+
87
+ ```bash
88
+ af-server install $SSH
89
+ ```
90
+
91
+ To use a different domain or SSL path:
92
+
93
+ ```bash
94
+ af-server install $SSH --dev-domain $DOMAIN --ssl-dir /opt/aifabrix/ssl
95
+ ```
96
+
97
+ - Space between `$DOMAIN` and `--ssl-dir` is required.
98
+ - **Git Bash (Windows):** quote server paths so the shell doesn't expand `/opt` (e.g. `--ssl-dir '/opt/aifabrix/ssl'`, `--data-dir '/opt/aifabrix/builder-server/data'`).
99
+
100
+ ### Option B: On the server itself (Ubuntu, no target)
47
101
 
48
- **On the server itself** (Ubuntu, no target):
102
+ If you are already on the server (e.g. over console or SSH), run install locally. Prerequisites (DNS, SSL dir with `wildcard.crt` and `wildcard.key`) must still be done first.
49
103
 
50
104
  ```bash
51
- af-server install
105
+ npm install -g @aifabrix/builder
106
+ npm install -g @aifabrix/server-setup
107
+ sudo af-server install
52
108
  ```
53
109
 
54
110
  After install, your builder-server is up. Use the **AI Fabrix Builder CLI** for all usage (users, secrets, certs, etc.)—see [Builder documentation](https://github.com/esystemsdev/aifabrix-builder/blob/main/docs/developer-isolation.md).
@@ -59,11 +115,12 @@ After install, your builder-server is up. Use the **AI Fabrix Builder CLI** for
59
115
 
60
116
  | Command | Description |
61
117
  | -------- | ----------- |
62
- | `af-server install [ user@host ] [ -d DATA_DIR ] [ --dev-domain DOMAIN ] [ --ssl-dir PATH ] [ -i SSH_KEY ]` | Install: Docker, nginx, SSL proxy, sync user, cron. |
118
+ | `af-server install [ user@host ] [ -d DATA_DIR ] [ --dev-domain DOMAIN ] [ --ssl-dir PATH ] [ -i SSH_KEY ]` | Install or update: Docker, nginx (builder vhost always updated from template), SSL proxy, sync user, cron. Re-run to apply latest config. |
63
119
  | `af-server backup [ user@host ] [ -d DATA_DIR ] [ -o output.zip ] [ -i SSH_KEY ]` | On-demand backup (config + DB + keys). |
64
120
  | `af-server backup [ user@host ] --schedule [ --backup-dir PATH ] [ --keep-days N ] [ -i SSH_KEY ]` | Cron backup (daily 02:00, keep last N, default 7). |
65
121
  | `af-server restore backup.zip [ user@host ] [ -d DATA_DIR ] [ --force ] [ -i SSH_KEY ]` | Restore backup to DATA_DIR. |
66
122
  | `af-server ssh-cert install [ user@host ] [ -i SSH_KEY ]` | Add your SSH public key to server (passwordless auth). |
123
+ | `af-server install-ssh [ user@host ] [ -i SSH_KEY ]` | Activate SSH server (install openssh-server, enable and start ssh) without login. Omit target for local. |
67
124
 
68
125
  Backups contain secrets; store encrypted. Cron backup needs SQLite (`builder.db`) and `zip` on the server; default backup dir: `/opt/aifabrix/backups`.
69
126
 
@@ -0,0 +1,123 @@
1
+ #!/bin/sh
2
+ # Apply per-developer OS users from builder-server state (DATA_DIR/ssh-keys, pending-removals).
3
+ # Run via cron every 2 minutes. Creates/updates dev<id> users, .ssh/authorized_keys, workspace symlink,
4
+ # and optional ~/.aifabrix/config.yaml when missing. Processes pending-removals then applies keys.
5
+ # Requires root. DATA_DIR must match builder-server (e.g. /opt/aifabrix/builder-server/data).
6
+
7
+ set -e
8
+
9
+ DATA_DIR="${DATA_DIR:-/opt/aifabrix/builder-server/data}"
10
+ SSH_KEYS_DIR="${DATA_DIR}/ssh-keys"
11
+ PENDING_REMOVALS="${DATA_DIR}/pending-removals"
12
+ # Defaults matching builder-server env.template (override via env or apply-dev-users-defaults)
13
+ AIFABRIX_SECRETS="${AIFABRIX_SECRETS:-/aifabrix-miso/builder/secrets.local.yaml}"
14
+ AIFABRIX_ENV_CONFIG="${AIFABRIX_ENV_CONFIG:-aifabrix-miso/builder/env-config.yaml}"
15
+
16
+ if [ ! -d "$DATA_DIR" ]; then
17
+ echo "DATA_DIR not found: $DATA_DIR"
18
+ exit 0
19
+ fi
20
+
21
+ # Read secrets-encryption key from server data dir (same as builder-server ENCRYPTION_KEY_PATH); do not log.
22
+ secrets_encryption_value=""
23
+ if [ -f "${DATA_DIR}/secrets-encryption.key" ]; then
24
+ secrets_encryption_value=$(cat "${DATA_DIR}/secrets-encryption.key" | tr -d '\n\r' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
25
+ fi
26
+
27
+ # --- 1. Process removals ---
28
+ if [ -f "$PENDING_REMOVALS" ]; then
29
+ while IFS= read -r dev_id || [ -n "$dev_id" ]; do
30
+ dev_id=$(echo "$dev_id" | tr -d '\r\n ')
31
+ [ -z "$dev_id" ] && continue
32
+ user_name="dev${dev_id}"
33
+ if getent passwd "$user_name" >/dev/null 2>&1; then
34
+ userdel -r "$user_name" 2>/dev/null || userdel "$user_name" 2>/dev/null || true
35
+ fi
36
+ done < "$PENDING_REMOVALS"
37
+ : > "$PENDING_REMOVALS"
38
+ fi
39
+
40
+ # --- 2. Process each developer that has keys ---
41
+ for key_file in "${SSH_KEYS_DIR}"/*/authorized_keys; do
42
+ [ -f "$key_file" ] || continue
43
+ dev_id=$(dirname "$key_file" | xargs basename)
44
+ [ -z "$dev_id" ] && continue
45
+ user_name="dev${dev_id}"
46
+ home_dir="/home/${user_name}"
47
+ workspace_target="${DATA_DIR}/workspace/${user_name}"
48
+
49
+ if ! getent passwd "$user_name" >/dev/null 2>&1; then
50
+ useradd -m -s /bin/bash "$user_name"
51
+ else
52
+ # Ensure shell is bash (e.g. after manual changes)
53
+ current_shell=$(getent passwd "$user_name" | cut -d: -f7)
54
+ if [ "$current_shell" != "/bin/bash" ]; then
55
+ usermod -s /bin/bash "$user_name" 2>/dev/null || true
56
+ fi
57
+ fi
58
+
59
+ mkdir -p "${home_dir}/.ssh"
60
+ cp "$key_file" "${home_dir}/.ssh/authorized_keys"
61
+ chown -R "${user_name}:${user_name}" "${home_dir}/.ssh"
62
+ chmod 700 "${home_dir}/.ssh"
63
+ chmod 600 "${home_dir}/.ssh/authorized_keys"
64
+
65
+ # .aifabrix/config.yaml only if missing (do not overwrite)
66
+ config_dir="${home_dir}/.aifabrix"
67
+ config_file="${config_dir}/config.yaml"
68
+ if [ ! -f "$config_file" ]; then
69
+ mkdir -p "$config_dir"
70
+ hostname_val=$(hostname -f 2>/dev/null || hostname 2>/dev/null || echo "localhost")
71
+ # Values from builder-server: secrets-encryption from DATA_DIR, paths from env (apply-dev-users-defaults or env.template)
72
+ yaml_escape() { echo "$1" | sed 's/\\/\\\\/g;s/"/\\"/g'; }
73
+ {
74
+ echo "user-mutagen-folder: ${workspace_target}"
75
+ printf "secrets-encryption: \"%s\"\n" "$(yaml_escape "$secrets_encryption_value")"
76
+ printf "aifabrix-secrets: \"%s\"\n" "$(yaml_escape "$AIFABRIX_SECRETS")"
77
+ printf "aifabrix-env-config: \"%s\"\n" "$(yaml_escape "$AIFABRIX_ENV_CONFIG")"
78
+ echo "remote-server: \"http://localhost:3000\""
79
+ echo "docker-endpoint: \"tcp://${hostname_val}:2376\""
80
+ echo "sync-ssh-user: \"${user_name}\""
81
+ echo "sync-ssh-host: \"${hostname_val}\""
82
+ } > "$config_file"
83
+ chown -R "${user_name}:${user_name}" "$config_dir"
84
+ chmod 700 "$config_dir"
85
+ chmod 600 "$config_file"
86
+ fi
87
+
88
+ # Workspace: ensure dir exists, owned by user; symlink from home
89
+ mkdir -p "$workspace_target"
90
+ chown -R "${user_name}:${user_name}" "$workspace_target"
91
+ if [ -L "${home_dir}/workspace" ]; then
92
+ current_target=$(readlink -f "${home_dir}/workspace" 2>/dev/null || true)
93
+ want_target=$(readlink -f "$workspace_target" 2>/dev/null || echo "$workspace_target")
94
+ if [ -n "$current_target" ] && [ -n "$want_target" ] && [ "$current_target" != "$want_target" ]; then
95
+ rm -f "${home_dir}/workspace"
96
+ ln -s "$workspace_target" "${home_dir}/workspace"
97
+ chown -h "${user_name}:${user_name}" "${home_dir}/workspace"
98
+ fi
99
+ elif [ ! -e "${home_dir}/workspace" ]; then
100
+ ln -s "$workspace_target" "${home_dir}/workspace"
101
+ chown -h "${user_name}:${user_name}" "${home_dir}/workspace"
102
+ fi
103
+
104
+ # Default directory on SSH login: cd to workspace
105
+ profile="${home_dir}/.profile"
106
+ if [ ! -f "$profile" ]; then
107
+ touch "$profile"
108
+ chown "${user_name}:${user_name}" "$profile"
109
+ fi
110
+ if ! grep -q 'cd.*workspace' "$profile" 2>/dev/null; then
111
+ echo '[ -d "$HOME/workspace" ] && cd "$HOME/workspace"' >> "$profile"
112
+ chown "${user_name}:${user_name}" "$profile"
113
+ fi
114
+ bashrc="${home_dir}/.bashrc"
115
+ if [ ! -f "$bashrc" ]; then
116
+ touch "$bashrc"
117
+ chown "${user_name}:${user_name}" "$bashrc"
118
+ fi
119
+ if ! grep -q 'cd.*workspace' "$bashrc" 2>/dev/null; then
120
+ echo '[ -d "$HOME/workspace" ] && cd "$HOME/workspace"' >> "$bashrc"
121
+ chown "${user_name}:${user_name}" "$bashrc"
122
+ fi
123
+ done
@@ -1,12 +1,8 @@
1
1
  # Nginx snippet for builder-server onboarding API (https://DEV_DOMAIN_PLACEHOLDER).
2
2
  # Generated from template by af-server install (substitutes DEV_DOMAIN, SSL_DIR, BUILDER_SERVER_PORT, DATA_DIR).
3
3
  # SSL cert and key from SSL_DIR_PLACEHOLDER (wildcard.crt, wildcard.key).
4
- # Client cert: ssl_client_certificate uses DATA_DIR_PLACEHOLDER/ca.crt (Builder CA; created by builder-server on first run).
5
- # If the backend returns 400 Bad Request for requests with client cert, nginx may be sending $ssl_client_cert with
6
- # literal newlines (header folding). Use njs to send cert on one line: load_module modules/ngx_http_js_module.so;
7
- # then js_set $client_cert_escaped cert.oneline; js_include cert-escaped.js; and proxy_set_header X-Client-Cert $client_cert_escaped;
8
- # (cert-escaped.js: function cert(r){return r.variables.ssl_client_cert?r.variables.ssl_client_cert.replace(/\n/g,'\\n'):'';}).
9
- # Reload nginx after placing: sudo nginx -t && sudo systemctl reload nginx.
4
+ # Client cert: ssl_client_certificate uses DATA_DIR_PLACEHOLDER/ca.crt (Builder CA).
5
+ # Client sends X-Client-Cert as base64-encoded PEM; nginx forwards it to the backend.
10
6
 
11
7
  server {
12
8
  listen 443 ssl;
@@ -25,6 +21,6 @@ server {
25
21
  proxy_set_header X-Real-IP $remote_addr;
26
22
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
27
23
  proxy_set_header X-Forwarded-Proto $scheme;
28
- proxy_set_header X-Client-Cert $ssl_client_cert;
24
+ proxy_set_header X-Client-Cert $http_x_client_cert;
29
25
  }
30
26
  }
@@ -1,9 +1,11 @@
1
1
  #!/bin/sh
2
2
  # Idempotent dev server setup script (no Node/Builder on server). Safe to run multiple times.
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
+ # On empty server: installs Docker, nginx, admin user, optional Portainer/Mutagen; then updates nginx config and ensures builder-server container.
4
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.
5
6
 
6
7
  set -e
8
+ export DEBIAN_FRONTEND=noninteractive
7
9
 
8
10
  REPO_ROOT="${REPO_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
9
11
  DATA_DIR="${DATA_DIR:-/opt/aifabrix/builder-server/data}"
@@ -24,6 +26,9 @@ sanitize_path() {
24
26
  *) return 0 ;;
25
27
  esac
26
28
  }
29
+ case "$DEV_DOMAIN" in
30
+ *'--'*) 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 ;;
31
+ esac
27
32
  if ! sanitize_domain "$DEV_DOMAIN"; then
28
33
  echo "Invalid DEV_DOMAIN (use only letters, digits, dots, hyphens)."
29
34
  exit 1
@@ -57,7 +62,19 @@ fi
57
62
 
58
63
  # --- System updates ---
59
64
  apt-get update -qq
60
- DEBIAN_FRONTEND=noninteractive apt-get upgrade -y -qq
65
+ apt-get upgrade -y -qq
66
+
67
+ # --- Packages for SSH users (same base as builder-server/wsl/install-wsl-ubuntu-dev.sh; no Node/Ruby) ---
68
+ apt-get install -y \
69
+ openssh-server sudo curl git ca-certificates gnupg software-properties-common apt-transport-https \
70
+ build-essential unzip zip jq wget vim nano less locales \
71
+ python3 python3-venv python3-dev python3-pip \
72
+ openjdk-17-jdk \
73
+ autoconf bison libssl-dev libyaml-dev libreadline-dev zlib1g-dev libffi-dev libgdbm-dev libncurses-dev \
74
+ redis-tools dbus-x11 imagemagick \
75
+ openssh-client rsync sshfs
76
+ systemctl enable ssh 2>/dev/null || systemctl enable sshd 2>/dev/null || true
77
+ systemctl start ssh 2>/dev/null || systemctl start sshd 2>/dev/null || true
61
78
 
62
79
  # --- Docker ---
63
80
  if ! command -v docker >/dev/null 2>&1; then
@@ -101,25 +118,23 @@ if ! command -v nginx >/dev/null 2>&1; then
101
118
  systemctl enable nginx
102
119
  systemctl start nginx
103
120
  fi
104
-
105
121
  NGINX_CONF="$NGINX_CONF_DIR/$DEV_DOMAIN.conf"
106
122
  NGINX_TEMPLATE="$REPO_ROOT/builder/builder-server/nginx-builder-server.conf.template"
107
- if [ ! -f "$NGINX_CONF" ] && [ -f "$NGINX_TEMPLATE" ]; then
123
+ # Always update builder vhost from template so re-running install applies latest config.
124
+ if [ -f "$NGINX_TEMPLATE" ]; then
108
125
  sed -e "s|DEV_DOMAIN_PLACEHOLDER|$DEV_DOMAIN|g" \
109
126
  -e "s|SSL_DIR_PLACEHOLDER|$SSL_DIR|g" \
110
127
  -e "s|BUILDER_SERVER_PORT_PLACEHOLDER|$BUILDER_SERVER_PORT|g" \
111
128
  -e "s|DATA_DIR_PLACEHOLDER|$DATA_DIR|g" \
112
129
  "$NGINX_TEMPLATE" > "$NGINX_CONF"
113
- if nginx -t 2>/dev/null; then
114
- systemctl reload nginx
115
- fi
116
130
  elif [ ! -f "$NGINX_CONF" ]; then
117
131
  echo "Warning: SSL prereqs required. Place $NGINX_CONF (see SETUP.md) and ensure $SSL_DIR/wildcard.crt and $SSL_DIR/wildcard.key exist."
118
132
  fi
119
- if command -v nginx >/dev/null 2>&1; then
120
- if nginx -t 2>/dev/null; then
121
- systemctl reload nginx
122
- fi
133
+ if command -v nginx >/dev/null 2>&1 && nginx -t 2>/dev/null; then
134
+ systemctl reload nginx
135
+ elif [ -f "$NGINX_TEMPLATE" ] && command -v nginx >/dev/null 2>&1; then
136
+ echo "Warning: nginx -t failed (config was still written to $NGINX_CONF). Nginx was NOT reloaded."
137
+ nginx -t 2>&1 || true
123
138
  fi
124
139
 
125
140
  # --- Mutagen ---
@@ -153,11 +168,107 @@ MUTAGEN_EOF
153
168
  fi
154
169
  fi
155
170
 
156
- # --- Docker TLS (daemon.json) ---
171
+ # --- Builder-server data dir and container ---
172
+ # Paths match AI Fabrix Builder: aifabrix build + resolve use DATA_DIR=/mnt/data in container; host DATA_DIR (e.g. /opt/aifabrix/builder-server/data) is the HDD/mount. See builder/builder-server/README.md and env.template.
173
+ # Nginx uses DATA_DIR/ca.crt for ssl_client_certificate; container must use the same host path so CA matches.
174
+ CONTAINER_DATA_PATH="/mnt/data"
175
+ BUILDER_IMAGE="aifabrix/builder-server:latest"
176
+ mkdir -p "$DATA_DIR"
177
+ chown -R 1001:65533 "$DATA_DIR"
178
+ chmod 755 "$DATA_DIR"
179
+ DATA_DIR_ABS=$(cd "$DATA_DIR" && pwd)
180
+ if command -v docker >/dev/null 2>&1; then
181
+ CONTAINER_NAME=""
182
+ for n in builder-server aifabrix-builder-server; do
183
+ if docker ps -a --format '{{.Names}}' 2>/dev/null | grep -q "^${n}$"; then
184
+ CONTAINER_NAME="$n"
185
+ break
186
+ fi
187
+ done
188
+ if [ -n "$CONTAINER_NAME" ]; then
189
+ # Ensure container uses DATA_DIR as bind mount so nginx and app share same ca.crt (check /mnt/data first, then /data)
190
+ DATA_MOUNT_SOURCE=$(docker inspect --format '{{range .Mounts}}{{if eq .Destination "/mnt/data"}}{{.Source}}{{end}}{{end}}' "$CONTAINER_NAME" 2>/dev/null)
191
+ if [ -z "$DATA_MOUNT_SOURCE" ]; then
192
+ DATA_MOUNT_SOURCE=$(docker inspect --format '{{range .Mounts}}{{if eq .Destination "/data"}}{{.Source}}{{end}}{{end}}' "$CONTAINER_NAME" 2>/dev/null)
193
+ fi
194
+ MOUNT_SOURCE_ABS=""
195
+ if [ -n "$DATA_MOUNT_SOURCE" ] && [ -d "$DATA_MOUNT_SOURCE" ]; then
196
+ MOUNT_SOURCE_ABS=$(cd "$DATA_MOUNT_SOURCE" && pwd)
197
+ fi
198
+ if [ "$MOUNT_SOURCE_ABS" != "$DATA_DIR_ABS" ]; then
199
+ echo "Recreating builder-server container to use DATA_DIR bind mount ($DATA_DIR_ABS -> $CONTAINER_DATA_PATH) so nginx and container share the same CA."
200
+ BUILDER_IMAGE=$(docker inspect --format '{{.Config.Image}}' "$CONTAINER_NAME" 2>/dev/null) || true
201
+ [ -z "$BUILDER_IMAGE" ] && BUILDER_IMAGE="aifabrix/builder-server:latest"
202
+ docker stop "$CONTAINER_NAME" 2>/dev/null || true
203
+ docker rm "$CONTAINER_NAME" 2>/dev/null || true
204
+ CONTAINER_NAME=""
205
+ fi
206
+ fi
207
+ if [ -z "$CONTAINER_NAME" ]; then
208
+ IMG_TO_USE="$BUILDER_IMAGE"
209
+ if ! docker images -q "$IMG_TO_USE" 2>/dev/null | grep -q .; then
210
+ IMG_TO_USE="builder-server:latest"
211
+ fi
212
+ if docker images -q "$IMG_TO_USE" 2>/dev/null | grep -q .; then
213
+ docker run -d --name aifabrix-builder-server --restart unless-stopped \
214
+ -p "${BUILDER_SERVER_PORT}:3000" \
215
+ -v "$DATA_DIR_ABS:$CONTAINER_DATA_PATH" \
216
+ -e PORT=3000 \
217
+ -e DATA_DIR="$CONTAINER_DATA_PATH" \
218
+ -e ENCRYPTION_KEY_PATH="${CONTAINER_DATA_PATH}/secrets-encryption.key" \
219
+ -e DOCKER_ENDPOINT="tcp://${DEV_DOMAIN}:2376" \
220
+ "$IMG_TO_USE"
221
+ else
222
+ 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."
223
+ echo "After the image is on this host, re-run af-server install. Install will start the container with: -v $DATA_DIR_ABS:$CONTAINER_DATA_PATH and DATA_DIR=$CONTAINER_DATA_PATH."
224
+ fi
225
+ else
226
+ docker start "$CONTAINER_NAME" 2>/dev/null || true
227
+ fi
228
+ fi
229
+
230
+ # --- Docker TLS (daemon.json + certs): use website cert for server, builder-server CA for client verification ---
157
231
  if [ "$SKIP_DOCKER_TLS" != "1" ] && [ -d /etc/docker ]; then
158
- if [ ! -f /etc/docker/daemon.json ]; then
159
- cat > /etc/docker/daemon.json << 'DOCKER_EOF'
232
+ DATA_DIR_ABS=$(cd "$DATA_DIR" 2>/dev/null && pwd) || true
233
+ if [ -n "$DATA_DIR_ABS" ]; then
234
+ # Wait for builder-server to create ca.crt (up to 30s)
235
+ for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do
236
+ [ -f "$DATA_DIR_ABS/ca.crt" ] && break
237
+ sleep 2
238
+ done
239
+ # Server cert/key: use existing website cert (same as nginx) so no extra cert to manage
240
+ if [ -f "$SSL_DIR/wildcard.crt" ] && [ -f "$SSL_DIR/wildcard.key" ]; then
241
+ cp "$SSL_DIR/wildcard.crt" /etc/docker/server-cert.pem
242
+ cp "$SSL_DIR/wildcard.key" /etc/docker/server-key.pem
243
+ chmod 600 /etc/docker/server-key.pem
244
+ fi
245
+ # Client verification: CA that signed developer certs (builder-server creates ca.crt on first run)
246
+ if [ -f "$DATA_DIR_ABS/ca.crt" ]; then
247
+ cp "$DATA_DIR_ABS/ca.crt" /etc/docker/ca.pem
248
+ fi
249
+ if [ -f /etc/docker/ca.pem ] && [ -f /etc/docker/server-cert.pem ] && [ -f /etc/docker/server-key.pem ]; then
250
+ # Allow daemon.json to set "hosts" (avoid conflict with systemd -H fd://)
251
+ DOCKER_DROPIN="/etc/systemd/system/docker.service.d"
252
+ if [ -d /etc/systemd/system ]; then
253
+ mkdir -p "$DOCKER_DROPIN"
254
+ if [ ! -f "$DOCKER_DROPIN/override.conf" ]; then
255
+ cat > "$DOCKER_DROPIN/override.conf" << 'DROPIN_EOF'
256
+ [Service]
257
+ ExecStart=
258
+ ExecStart=/usr/bin/dockerd
259
+ DROPIN_EOF
260
+ systemctl daemon-reload
261
+ fi
262
+ fi
263
+ [ -f /etc/docker/daemon.json ] && cp -a /etc/docker/daemon.json /etc/docker/daemon.json.bak
264
+ DATA_ROOT=''
265
+ if [ -f /etc/docker/daemon.json.bak ]; then
266
+ DATA_ROOT=$(grep -o '"data-root"[[:space:]]*:[[:space:]]*"[^"]*"' /etc/docker/daemon.json.bak 2>/dev/null | sed 's/.*:[[:space:]]*"\([^"]*\)".*/\1/' | head -1)
267
+ fi
268
+ if [ -n "$DATA_ROOT" ] && [ -d "$DATA_ROOT" ]; then
269
+ cat > /etc/docker/daemon.json << DOCKER_EOF
160
270
  {
271
+ "data-root": "$DATA_ROOT",
161
272
  "tls": true,
162
273
  "tlsverify": true,
163
274
  "tlscacert": "/etc/docker/ca.pem",
@@ -166,69 +277,114 @@ if [ "$SKIP_DOCKER_TLS" != "1" ] && [ -d /etc/docker ]; then
166
277
  "hosts": ["tcp://0.0.0.0:2376", "unix:///var/run/docker.sock"]
167
278
  }
168
279
  DOCKER_EOF
169
- echo "Docker TLS daemon.json created. Ensure /etc/docker/ca.pem, server-cert.pem, server-key.pem exist (see SETUP.md Docker API TLS)."
280
+ else
281
+ cat > /etc/docker/daemon.json << 'DOCKER_EOF'
282
+ {
283
+ "tls": true,
284
+ "tlsverify": true,
285
+ "tlscacert": "/etc/docker/ca.pem",
286
+ "tlscert": "/etc/docker/server-cert.pem",
287
+ "tlskey": "/etc/docker/server-key.pem",
288
+ "hosts": ["tcp://0.0.0.0:2376", "unix:///var/run/docker.sock"]
289
+ }
290
+ DOCKER_EOF
291
+ fi
292
+ systemctl restart docker 2>/dev/null || true
293
+ if ! docker info >/dev/null 2>&1; then
294
+ echo "Docker failed to start with TLS config. Restoring previous daemon.json so Docker can start."
295
+ if [ -f /etc/docker/daemon.json.bak ]; then
296
+ mv /etc/docker/daemon.json.bak /etc/docker/daemon.json
297
+ else
298
+ rm -f /etc/docker/daemon.json
299
+ fi
300
+ systemctl start docker 2>/dev/null || true
301
+ echo "Run scripts/fix-docker-daemon.sh if Docker still does not start. See SETUP.md Docker API TLS."
302
+ else
303
+ docker start aifabrix-builder-server 2>/dev/null || docker start builder-server 2>/dev/null || true
304
+ fi
305
+ else
306
+ 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."
307
+ fi
170
308
  fi
171
309
  fi
172
310
 
173
- # --- Builder-server data dir and container ---
174
- # Container runs as uid 1001 (nodejs); data dir must be writable for bootstrap (key, CA, DB).
175
- mkdir -p "$DATA_DIR"
176
- chown -R 1001:65533 "$DATA_DIR"
177
- chmod 755 "$DATA_DIR"
178
- if command -v docker >/dev/null 2>&1; then
179
- if ! docker ps -a --format '{{.Names}}' 2>/dev/null | grep -q '^builder-server$'; then
180
- echo "Builder-server container not found. Build and run manually (see builder/builder-server/README.md):"
181
- echo " docker build -t builder-server:latest -f builder/builder-server/Dockerfile ."
182
- echo " docker run -d --name builder-server --restart unless-stopped -p ${BUILDER_SERVER_PORT}:3000 -v $DATA_DIR:/data -e PORT=3000 builder-server:latest"
311
+ # --- Workspace and ssh-keys dirs (per-developer users; apply script manages OS users) ---
312
+ mkdir -p "${DATA_DIR}/workspace"
313
+ mkdir -p "${DATA_DIR}/ssh-keys"
314
+
315
+ # --- Host job: apply per-developer OS users from builder-server state ---
316
+ APPLY_DEV_USERS_SCRIPT="/usr/local/bin/aifabrix-apply-dev-users.sh"
317
+ SETUP_ASSETS_DIR="$(cd "$(dirname "$0")" && pwd)"
318
+ if [ ! -f "$APPLY_DEV_USERS_SCRIPT" ]; then
319
+ if [ -f "$SETUP_ASSETS_DIR/aifabrix-apply-dev-users.sh" ]; then
320
+ cp "$SETUP_ASSETS_DIR/aifabrix-apply-dev-users.sh" "$APPLY_DEV_USERS_SCRIPT"
183
321
  else
184
- docker start builder-server 2>/dev/null || true
185
- fi
322
+ # Inline fallback when run from builder/builder-server context
323
+ cat > "$APPLY_DEV_USERS_SCRIPT" << 'APPLY_DEV_EOF'
324
+ #!/bin/sh
325
+ DATA_DIR="${DATA_DIR:-/opt/aifabrix/builder-server/data}"
326
+ SSH_KEYS_DIR="${DATA_DIR}/ssh-keys"
327
+ PENDING_REMOVALS="${DATA_DIR}/pending-removals"
328
+ [ ! -d "$DATA_DIR" ] && exit 0
329
+ if [ -f "$PENDING_REMOVALS" ]; then
330
+ while IFS= read -r dev_id || [ -n "$dev_id" ]; do
331
+ dev_id=$(echo "$dev_id" | tr -d '\r\n ')
332
+ [ -z "$dev_id" ] && continue
333
+ user_name="dev${dev_id}"
334
+ getent passwd "$user_name" >/dev/null 2>&1 && ( userdel -r "$user_name" 2>/dev/null || userdel "$user_name" 2>/dev/null || true )
335
+ done < "$PENDING_REMOVALS"
336
+ : > "$PENDING_REMOVALS"
186
337
  fi
187
-
188
- # --- Sync user for Mutagen SSH ---
189
- SYNC_USER_HOME="${DATA_DIR}/.sync-home"
190
- MANAGED_KEYS_FILE="${DATA_DIR}/sync-authorized-keys"
191
- if ! getent passwd "$SYNC_USER" >/dev/null 2>&1; then
192
- useradd --system --home-dir "$SYNC_USER_HOME" --create-home --shell /bin/bash "$SYNC_USER"
338
+ for key_file in "${SSH_KEYS_DIR}"/*/authorized_keys; do
339
+ [ -f "$key_file" ] || continue
340
+ dev_id=$(dirname "$key_file" | xargs basename)
341
+ [ -z "$dev_id" ] && continue
342
+ user_name="dev${dev_id}"
343
+ home_dir="/home/${user_name}"
344
+ workspace_target="${DATA_DIR}/workspace/${user_name}"
345
+ getent passwd "$user_name" >/dev/null 2>&1 || useradd -m -s /bin/bash "$user_name"
346
+ mkdir -p "${home_dir}/.ssh"
347
+ cp "$key_file" "${home_dir}/.ssh/authorized_keys"
348
+ chown -R "${user_name}:${user_name}" "${home_dir}/.ssh"
349
+ chmod 700 "${home_dir}/.ssh"
350
+ chmod 600 "${home_dir}/.ssh/authorized_keys"
351
+ config_file="${home_dir}/.aifabrix/config.yaml"
352
+ if [ ! -f "$config_file" ]; then
353
+ mkdir -p "${home_dir}/.aifabrix"
354
+ hostname_val=$(hostname -f 2>/dev/null || hostname 2>/dev/null || echo "localhost")
355
+ 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"
356
+ chown -R "${user_name}:${user_name}" "${home_dir}/.aifabrix"
357
+ chmod 700 "${home_dir}/.aifabrix"
358
+ chmod 600 "$config_file"
359
+ fi
360
+ mkdir -p "$workspace_target"
361
+ chown -R "${user_name}:${user_name}" "$workspace_target"
362
+ [ -L "${home_dir}/workspace" ] && [ "$(readlink -f "${home_dir}/workspace" 2>/dev/null)" != "$(readlink -f "$workspace_target" 2>/dev/null)" ] && rm -f "${home_dir}/workspace"
363
+ [ ! -e "${home_dir}/workspace" ] && ln -s "$workspace_target" "${home_dir}/workspace" && chown -h "${user_name}:${user_name}" "${home_dir}/workspace"
364
+ profile="${home_dir}/.profile"
365
+ [ ! -f "$profile" ] && touch "$profile" && chown "${user_name}:${user_name}" "$profile"
366
+ grep -q 'cd.*workspace' "$profile" 2>/dev/null || echo '[ -d "$HOME/workspace" ] && cd "$HOME/workspace"' >> "$profile"
367
+ bashrc="${home_dir}/.bashrc"
368
+ [ ! -f "$bashrc" ] && touch "$bashrc" && chown "${user_name}:${user_name}" "$bashrc"
369
+ grep -q 'cd.*workspace' "$bashrc" 2>/dev/null || echo '[ -d "$HOME/workspace" ] && cd "$HOME/workspace"' >> "$bashrc"
370
+ done
371
+ APPLY_DEV_EOF
372
+ fi
373
+ chmod 755 "$APPLY_DEV_USERS_SCRIPT"
193
374
  fi
194
- mkdir -p "$SYNC_USER_HOME/.ssh"
195
- chown -R "$SYNC_USER:$SYNC_USER" "$SYNC_USER_HOME"
196
- chmod 700 "$SYNC_USER_HOME/.ssh"
197
- if [ ! -f "$SYNC_USER_HOME/.ssh/authorized_keys" ]; then
198
- touch "$SYNC_USER_HOME/.ssh/authorized_keys"
199
- chown "$SYNC_USER:$SYNC_USER" "$SYNC_USER_HOME/.ssh/authorized_keys"
200
- chmod 600 "$SYNC_USER_HOME/.ssh/authorized_keys"
375
+ # Defaults for apply script (aifabrix-secrets, aifabrix-env-config in generated config); match builder-server env.template
376
+ APPLY_DEFAULTS="$DATA_DIR_ABS/apply-dev-users-defaults"
377
+ if [ ! -f "$APPLY_DEFAULTS" ]; then
378
+ {
379
+ echo "# Sourced by cron before aifabrix-apply-dev-users.sh; override to match builder-server .env"
380
+ echo 'export AIFABRIX_SECRETS="${AIFABRIX_SECRETS:-/aifabrix-miso/builder/secrets.local.yaml}"'
381
+ echo 'export AIFABRIX_ENV_CONFIG="${AIFABRIX_ENV_CONFIG:-aifabrix-miso/builder/env-config.yaml}"'
382
+ } > "$APPLY_DEFAULTS"
383
+ chmod 644 "$APPLY_DEFAULTS"
201
384
  fi
202
-
203
- # --- Workspace dir ---
204
- mkdir -p "${DATA_DIR}/workspace"
205
- chown -R "$SYNC_USER:$SYNC_USER" "${DATA_DIR}/workspace" 2>/dev/null || true
206
-
207
- # --- Host job: apply managed SSH keys to sync user ---
208
- APPLY_KEYS_SCRIPT="/usr/local/bin/aifabrix-apply-sync-keys.sh"
209
- if [ ! -f "$APPLY_KEYS_SCRIPT" ]; then
210
- cat > "$APPLY_KEYS_SCRIPT" << APPLY_EOF
211
- #!/bin/sh
212
- SYNC_USER="$SYNC_USER"
213
- DATA_DIR="$DATA_DIR"
214
- SYNC_HOME="\${DATA_DIR}/.sync-home"
215
- MANAGED="\${DATA_DIR}/sync-authorized-keys"
216
- if [ -f "\$MANAGED" ]; then
217
- cp "\$MANAGED" "\$SYNC_HOME/.ssh/authorized_keys"
218
- else
219
- touch "\$SYNC_HOME/.ssh/authorized_keys"
220
- fi
221
- chown "\$SYNC_USER:\$SYNC_USER" "\$SYNC_HOME/.ssh/authorized_keys"
222
- chmod 600 "\$SYNC_HOME/.ssh/authorized_keys"
223
- if [ -d "\${DATA_DIR}/workspace" ]; then
224
- chown -R "\$SYNC_USER:\$SYNC_USER" "\${DATA_DIR}/workspace"
225
- fi
226
- APPLY_EOF
227
- chmod 755 "$APPLY_KEYS_SCRIPT"
228
- fi
229
- if [ -d /etc/cron.d ] && [ ! -f /etc/cron.d/aifabrix-sync-keys ]; then
230
- echo "*/2 * * * * root $APPLY_KEYS_SCRIPT" > /etc/cron.d/aifabrix-sync-keys
231
- chmod 644 /etc/cron.d/aifabrix-sync-keys
385
+ if [ -d /etc/cron.d ] && [ ! -f /etc/cron.d/aifabrix-apply-dev-users ]; then
386
+ 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
387
+ chmod 644 /etc/cron.d/aifabrix-apply-dev-users
232
388
  fi
233
389
 
234
390
  echo "Setup complete. Ensure manual prerequisites (SSL at $SSL_DIR: wildcard.crt, wildcard.key; DNS $DEV_DOMAIN) are done; see SETUP.md."
@@ -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
@@ -9,6 +9,7 @@ import { runBackup, runBackupLocal } from './backup.js';
9
9
  import { runBackupScheduleInstall, runBackupScheduleInstallLocal } from './backup-schedule.js';
10
10
  import { runRestore, runRestoreLocal } from './restore.js';
11
11
  import { runSshCertRequest, runSshCertInstall, runSshCertInstallLocal } from './ssh-cert.js';
12
+ import { runInstallSsh, runInstallSshLocal } from './install-ssh.js';
12
13
  import { requireUbuntu } from './ubuntu.js';
13
14
  const program = new Command();
14
15
  program
@@ -133,6 +134,25 @@ program
133
134
  process.exit(1);
134
135
  }
135
136
  });
137
+ program
138
+ .command('install-ssh [user@host]')
139
+ .description('Activate SSH server (install openssh-server, enable and start ssh) without login. Omit target for local.')
140
+ .option('-i, --identity <path>', 'SSH private key path')
141
+ .action(async (target, opts) => {
142
+ try {
143
+ if (!target || !target.trim()) {
144
+ requireUbuntu();
145
+ runInstallSshLocal();
146
+ }
147
+ else {
148
+ await runInstallSsh({ target: target.trim(), privateKeyPath: opts.identity });
149
+ }
150
+ }
151
+ catch (err) {
152
+ console.error(err instanceof Error ? err.message : err);
153
+ process.exit(1);
154
+ }
155
+ });
136
156
  const sshCert = program.command('ssh-cert').description('Passwordless auth: install = append your SSH public key; request = stub for future SSH CA.');
137
157
  sshCert
138
158
  .command('request')
@@ -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 {};
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Tests for install-ssh: remote (mocked SSH) and local (mocked execSync).
3
+ */
4
+ const mockConn = {};
5
+ const mockExec = jest.fn();
6
+ const mockClose = jest.fn();
7
+ jest.mock('child_process', () => ({ execSync: jest.fn(() => Buffer.from('')) }));
8
+ jest.mock('./ssh.js', () => ({
9
+ createSSHClient: jest.fn(() => Promise.resolve(mockConn)),
10
+ exec: jest.fn((...args) => mockExec(...args)),
11
+ close: jest.fn((...args) => mockClose(...args)),
12
+ }));
13
+ import { execSync } from 'child_process';
14
+ import { runInstallSsh, runInstallSshLocal } from './install-ssh.js';
15
+ describe('install-ssh', () => {
16
+ beforeEach(() => {
17
+ jest.clearAllMocks();
18
+ mockExec.mockResolvedValue({ stdout: '', stderr: '', code: 0 });
19
+ jest.spyOn(console, 'log').mockImplementation(() => { });
20
+ });
21
+ afterEach(() => {
22
+ console.log.mockRestore();
23
+ });
24
+ describe('runInstallSsh', () => {
25
+ it('connects, runs sudo apt install openssh-server and systemctl enable/start ssh, then closes', async () => {
26
+ await runInstallSsh({ target: 'user@host' });
27
+ expect(mockExec).toHaveBeenCalledTimes(1);
28
+ const cmd = mockExec.mock.calls[0][1];
29
+ expect(cmd).toContain('sudo');
30
+ expect(cmd).toContain('openssh-server');
31
+ expect(cmd).toContain('systemctl enable ssh');
32
+ expect(cmd).toContain('systemctl start ssh');
33
+ expect(mockClose).toHaveBeenCalledWith(mockConn);
34
+ expect(console.log).toHaveBeenCalledWith(expect.stringContaining('SSH server is enabled and running'));
35
+ });
36
+ it('throws when script exits non-zero', async () => {
37
+ mockExec.mockResolvedValueOnce({ stdout: '', stderr: 'E: Unable to locate package', code: 100 });
38
+ await expect(runInstallSsh({ target: 'user@host' })).rejects.toThrow(/install-ssh exited with code 100/);
39
+ expect(mockClose).toHaveBeenCalledWith(mockConn);
40
+ });
41
+ });
42
+ describe('runInstallSshLocal', () => {
43
+ it('calls execSync with sudo and install script', () => {
44
+ execSync.mockClear();
45
+ runInstallSshLocal();
46
+ expect(execSync).toHaveBeenCalledTimes(1);
47
+ const cmd = execSync.mock.calls[0][0];
48
+ expect(cmd).toContain('sudo');
49
+ expect(cmd).toContain('openssh-server');
50
+ expect(cmd).toContain('systemctl enable ssh');
51
+ expect(cmd).toContain('systemctl start ssh');
52
+ expect(console.log).toHaveBeenCalledWith(expect.stringContaining('SSH server is enabled and running'));
53
+ });
54
+ });
55
+ });
package/dist/install.js CHANGED
@@ -8,13 +8,17 @@ import { fileURLToPath } from 'url';
8
8
  import { createSSHClient, exec, writeFile, close, } from './ssh.js';
9
9
  const scriptDir = path.dirname(fileURLToPath(import.meta.url));
10
10
  const ASSETS_DIR = path.resolve(scriptDir, '..', 'assets');
11
+ /** Normalize to Unix LF so shebang and scripts run on Linux (avoids "No such file or directory" when CRLF). */
12
+ function toUnixLf(s) {
13
+ return s.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
14
+ }
11
15
  function getSetupScript() {
12
16
  const p = path.join(ASSETS_DIR, 'setup-dev-server-no-node.sh');
13
- return fs.readFileSync(p, 'utf8');
17
+ return toUnixLf(fs.readFileSync(p, 'utf8'));
14
18
  }
15
19
  function getNginxTemplate() {
16
20
  const p = path.join(ASSETS_DIR, 'builder', 'builder-server', 'nginx-builder-server.conf.template');
17
- return fs.readFileSync(p, 'utf8');
21
+ return toUnixLf(fs.readFileSync(p, 'utf8'));
18
22
  }
19
23
  export async function runInstall(options) {
20
24
  const dataDir = options.dataDir ?? '/opt/aifabrix/builder-server/data';
@@ -31,12 +35,14 @@ export async function runInstall(options) {
31
35
  await writeFile(conn, `${tmpDir}/setup.sh`, setupScript);
32
36
  await writeFile(conn, `${tmpDir}/builder/builder-server/nginx-builder-server.conf.template`, nginxTemplate);
33
37
  await exec(conn, `chmod +x ${tmpDir}/setup.sh`);
38
+ /** Quote for remote shell so paths with spaces (e.g. Git Bash expanding /opt on Windows) don't break sudo. */
39
+ const q = (v) => `'${String(v).replace(/'/g, "'\\''")}'`;
34
40
  const env = [
35
- `REPO_ROOT=${tmpDir}`,
36
- `DATA_DIR=${dataDir}`,
37
- `DEV_DOMAIN=${devDomain}`,
38
- `SSL_DIR=${sslDir}`,
39
- `BUILDER_SERVER_PORT=${builderServerPort}`,
41
+ `REPO_ROOT=${q(tmpDir)}`,
42
+ `DATA_DIR=${q(dataDir)}`,
43
+ `DEV_DOMAIN=${q(devDomain)}`,
44
+ `SSL_DIR=${q(sslDir)}`,
45
+ `BUILDER_SERVER_PORT=${q(String(builderServerPort))}`,
40
46
  ].join(' ');
41
47
  const cmd = `sudo ${env} ${tmpDir}/setup.sh`;
42
48
  const result = await exec(conn, cmd);
@@ -65,7 +71,7 @@ export function runInstallLocal(options = {}) {
65
71
  try {
66
72
  fs.writeFileSync(path.join(tmpDir, 'setup.sh'), getSetupScript(), { mode: 0o755 });
67
73
  fs.writeFileSync(path.join(builderSubdir, 'nginx-builder-server.conf.template'), getNginxTemplate());
68
- const env = `REPO_ROOT=${tmpDir} DATA_DIR=${dataDir} DEV_DOMAIN=${devDomain} SSL_DIR=${sslDir} BUILDER_SERVER_PORT=${builderServerPort}`;
74
+ const env = [`REPO_ROOT=${tmpDir}`, `DATA_DIR=${dataDir}`, `DEV_DOMAIN=${devDomain}`, `SSL_DIR=${sslDir}`, `BUILDER_SERVER_PORT=${builderServerPort}`].join(' ');
69
75
  execSync(`sudo ${env} ${tmpDir}/setup.sh`, { stdio: 'inherit' });
70
76
  }
71
77
  finally {
package/dist/restore.js CHANGED
@@ -45,7 +45,7 @@ export async function runRestore(options) {
45
45
  await exec(conn, `chown 1001:65533 ${dataDir}/${k}`);
46
46
  }
47
47
  }
48
- const restart = await exec(conn, 'docker restart builder-server 2>/dev/null || true');
48
+ const restart = await exec(conn, 'docker restart builder-server 2>/dev/null; docker restart aifabrix-builder-server 2>/dev/null || true');
49
49
  if (restart.stderr && !restart.stderr.includes('No such container')) {
50
50
  process.stderr.write(restart.stderr);
51
51
  }
@@ -89,7 +89,7 @@ export async function runRestoreLocal(options) {
89
89
  }
90
90
  }
91
91
  try {
92
- execSync('docker restart builder-server 2>/dev/null || true', { stdio: 'inherit' });
92
+ execSync('docker restart builder-server 2>/dev/null; docker restart aifabrix-builder-server 2>/dev/null || true', { stdio: 'inherit' });
93
93
  }
94
94
  catch {
95
95
  // container may not exist
package/dist/ssh.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * SSH client wrapper using ssh2. Used for install, backup, restore.
3
3
  * Never log private keys or sensitive data.
4
+ * When no private key is given, prompts for password via keyboard-interactive.
4
5
  */
5
6
  import { Client } from 'ssh2';
6
7
  export interface SSHConnectionOptions {
package/dist/ssh.js CHANGED
@@ -1,10 +1,14 @@
1
1
  /**
2
2
  * SSH client wrapper using ssh2. Used for install, backup, restore.
3
3
  * Never log private keys or sensitive data.
4
+ * When no private key is given, prompts for password via keyboard-interactive.
4
5
  */
5
6
  import { Client } from 'ssh2';
6
7
  import * as fs from 'fs';
8
+ import * as os from 'os';
7
9
  import * as path from 'path';
10
+ import read from 'read';
11
+ const DEFAULT_KEY_NAMES = ['id_ed25519', 'id_rsa'];
8
12
  export function parseTarget(target) {
9
13
  const at = target.lastIndexOf('@');
10
14
  if (at <= 0 || at === target.length - 1) {
@@ -26,18 +30,107 @@ export function createSSHClient(options) {
26
30
  }
27
31
  privateKey = fs.readFileSync(resolved, 'utf8');
28
32
  }
33
+ else {
34
+ const sshDir = path.join(os.homedir(), '.ssh');
35
+ for (const name of DEFAULT_KEY_NAMES) {
36
+ const keyPath = path.join(sshDir, name);
37
+ if (fs.existsSync(keyPath)) {
38
+ try {
39
+ privateKey = fs.readFileSync(keyPath, 'utf8');
40
+ break;
41
+ }
42
+ catch {
43
+ // skip unreadable key, try next
44
+ }
45
+ }
46
+ }
47
+ }
48
+ const usedDefaultKey = !options.privateKeyPath && !options.privateKey && !!privateKey;
49
+ function isAuthError(err) {
50
+ const msg = err.message || '';
51
+ return (msg.includes('All configured authentication methods failed') ||
52
+ msg.includes('authentication') ||
53
+ err.level === 'client-authentication');
54
+ }
29
55
  return new Promise((resolve, reject) => {
30
- const conn = new Client();
31
- conn
32
- .on('ready', () => resolve(conn))
33
- .on('error', (err) => reject(err))
34
- .connect({
35
- host,
36
- port,
37
- username: user,
38
- privateKey: privateKey || undefined,
39
- tryKeyboard: !privateKey,
40
- });
56
+ const doConnect = (password, useKey = true) => {
57
+ const keyForThisConnection = useKey ? privateKey : undefined;
58
+ const conn = new Client();
59
+ if (!keyForThisConnection && password !== undefined) {
60
+ conn.on('keyboard-interactive', (_name, _instructions, _lang, prompts, finish) => {
61
+ if (prompts.length === 0) {
62
+ finish([]);
63
+ return;
64
+ }
65
+ finish(prompts.map(() => password));
66
+ });
67
+ }
68
+ else if (!keyForThisConnection) {
69
+ const onKeyboardInteractive = (_name, _instructions, _lang, prompts, finish) => {
70
+ if (prompts.length === 0) {
71
+ finish([]);
72
+ return;
73
+ }
74
+ const next = (index, answers) => {
75
+ if (index >= prompts.length) {
76
+ finish(answers);
77
+ return;
78
+ }
79
+ const p = prompts[index];
80
+ read({
81
+ prompt: p.prompt || (p.echo ? 'Response: ' : 'Password: '),
82
+ silent: !p.echo,
83
+ }, (err, value) => {
84
+ if (err) {
85
+ finish(answers);
86
+ return;
87
+ }
88
+ next(index + 1, answers.concat(value || ''));
89
+ });
90
+ };
91
+ next(0, []);
92
+ };
93
+ conn.on('keyboard-interactive', onKeyboardInteractive);
94
+ }
95
+ conn
96
+ .on('ready', () => resolve(conn))
97
+ .on('error', (err) => {
98
+ if (usedDefaultKey &&
99
+ useKey &&
100
+ isAuthError(err)) {
101
+ read({ prompt: 'Password: ', silent: true }, (errRead, value) => {
102
+ if (errRead) {
103
+ reject(errRead);
104
+ return;
105
+ }
106
+ doConnect(value || '', false);
107
+ });
108
+ }
109
+ else {
110
+ reject(err);
111
+ }
112
+ })
113
+ .connect({
114
+ host,
115
+ port,
116
+ username: user,
117
+ privateKey: keyForThisConnection || undefined,
118
+ password: !keyForThisConnection ? password : undefined,
119
+ tryKeyboard: !keyForThisConnection,
120
+ });
121
+ };
122
+ if (!privateKey) {
123
+ read({ prompt: 'Password: ', silent: true }, (err, value) => {
124
+ if (err) {
125
+ reject(err);
126
+ return;
127
+ }
128
+ doConnect(value || '');
129
+ });
130
+ }
131
+ else {
132
+ doConnect(undefined);
133
+ }
41
134
  });
42
135
  }
43
136
  export function exec(conn, command) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aifabrix/server-setup",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "CLI to install, backup, and restore AI Fabrix builder-server (config + DB) over SSH",
5
5
  "type": "module",
6
6
  "main": "dist/cli.js",
@@ -24,6 +24,7 @@
24
24
  "better-sqlite3": "^11.6.0",
25
25
  "commander": "^12.1.0",
26
26
  "extract-zip": "^2.0.1",
27
+ "read": "^1.0.7",
27
28
  "ssh2": "^1.15.0"
28
29
  },
29
30
  "devDependencies": {
@@ -32,6 +33,7 @@
32
33
  "@types/better-sqlite3": "^7.6.11",
33
34
  "@types/jest": "^29.5.14",
34
35
  "@types/node": "^22.10.2",
36
+ "@types/read": "^0.0.32",
35
37
  "@types/ssh2": "^1.15.5",
36
38
  "@typescript-eslint/eslint-plugin": "^8.17.0",
37
39
  "@typescript-eslint/parser": "^8.17.0",