@aifabrix/server-setup 1.3.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 +64 -7
- package/assets/aifabrix-apply-dev-users.sh +123 -0
- package/assets/setup-dev-server-no-node.sh +168 -54
- package/dist/backup.spec.js +1 -1
- package/dist/cli.js +20 -0
- package/dist/install-ssh.d.ts +10 -0
- package/dist/install-ssh.js +39 -0
- package/dist/install-ssh.spec.d.ts +4 -0
- package/dist/install-ssh.spec.js +55 -0
- package/dist/install.js +13 -7
- package/dist/ssh.d.ts +1 -0
- package/dist/ssh.js +104 -11
- package/package.json +3 -1
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.
|
|
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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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).
|
|
@@ -64,6 +120,7 @@ After install, your builder-server is up. Use the **AI Fabrix Builder CLI** for
|
|
|
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
|
|
@@ -5,6 +5,7 @@
|
|
|
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
6
|
|
|
7
7
|
set -e
|
|
8
|
+
export DEBIAN_FRONTEND=noninteractive
|
|
8
9
|
|
|
9
10
|
REPO_ROOT="${REPO_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
|
|
10
11
|
DATA_DIR="${DATA_DIR:-/opt/aifabrix/builder-server/data}"
|
|
@@ -25,6 +26,9 @@ sanitize_path() {
|
|
|
25
26
|
*) return 0 ;;
|
|
26
27
|
esac
|
|
27
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
|
|
28
32
|
if ! sanitize_domain "$DEV_DOMAIN"; then
|
|
29
33
|
echo "Invalid DEV_DOMAIN (use only letters, digits, dots, hyphens)."
|
|
30
34
|
exit 1
|
|
@@ -58,7 +62,19 @@ fi
|
|
|
58
62
|
|
|
59
63
|
# --- System updates ---
|
|
60
64
|
apt-get update -qq
|
|
61
|
-
|
|
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
|
|
62
78
|
|
|
63
79
|
# --- Docker ---
|
|
64
80
|
if ! command -v docker >/dev/null 2>&1; then
|
|
@@ -152,23 +168,6 @@ MUTAGEN_EOF
|
|
|
152
168
|
fi
|
|
153
169
|
fi
|
|
154
170
|
|
|
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)."
|
|
169
|
-
fi
|
|
170
|
-
fi
|
|
171
|
-
|
|
172
171
|
# --- Builder-server data dir and container ---
|
|
173
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.
|
|
174
173
|
# Nginx uses DATA_DIR/ca.crt for ssl_client_certificate; container must use the same host path so CA matches.
|
|
@@ -217,6 +216,7 @@ if command -v docker >/dev/null 2>&1; then
|
|
|
217
216
|
-e PORT=3000 \
|
|
218
217
|
-e DATA_DIR="$CONTAINER_DATA_PATH" \
|
|
219
218
|
-e ENCRYPTION_KEY_PATH="${CONTAINER_DATA_PATH}/secrets-encryption.key" \
|
|
219
|
+
-e DOCKER_ENDPOINT="tcp://${DEV_DOMAIN}:2376" \
|
|
220
220
|
"$IMG_TO_USE"
|
|
221
221
|
else
|
|
222
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."
|
|
@@ -227,50 +227,164 @@ if command -v docker >/dev/null 2>&1; then
|
|
|
227
227
|
fi
|
|
228
228
|
fi
|
|
229
229
|
|
|
230
|
-
# ---
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
if
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
230
|
+
# --- Docker TLS (daemon.json + certs): use website cert for server, builder-server CA for client verification ---
|
|
231
|
+
if [ "$SKIP_DOCKER_TLS" != "1" ] && [ -d /etc/docker ]; then
|
|
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
|
|
270
|
+
{
|
|
271
|
+
"data-root": "$DATA_ROOT",
|
|
272
|
+
"tls": true,
|
|
273
|
+
"tlsverify": true,
|
|
274
|
+
"tlscacert": "/etc/docker/ca.pem",
|
|
275
|
+
"tlscert": "/etc/docker/server-cert.pem",
|
|
276
|
+
"tlskey": "/etc/docker/server-key.pem",
|
|
277
|
+
"hosts": ["tcp://0.0.0.0:2376", "unix:///var/run/docker.sock"]
|
|
278
|
+
}
|
|
279
|
+
DOCKER_EOF
|
|
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
|
|
308
|
+
fi
|
|
243
309
|
fi
|
|
244
310
|
|
|
245
|
-
# --- Workspace
|
|
311
|
+
# --- Workspace and ssh-keys dirs (per-developer users; apply script manages OS users) ---
|
|
246
312
|
mkdir -p "${DATA_DIR}/workspace"
|
|
247
|
-
|
|
313
|
+
mkdir -p "${DATA_DIR}/ssh-keys"
|
|
248
314
|
|
|
249
|
-
# --- Host job: apply
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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"
|
|
321
|
+
else
|
|
322
|
+
# Inline fallback when run from builder/builder-server context
|
|
323
|
+
cat > "$APPLY_DEV_USERS_SCRIPT" << 'APPLY_DEV_EOF'
|
|
253
324
|
#!/bin/sh
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
if [ -f "
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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"
|
|
262
337
|
fi
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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"
|
|
267
374
|
fi
|
|
268
|
-
|
|
269
|
-
|
|
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"
|
|
270
384
|
fi
|
|
271
|
-
if [ -d /etc/cron.d ] && [ ! -f /etc/cron.d/aifabrix-
|
|
272
|
-
echo "*/2 * * * * root $
|
|
273
|
-
chmod 644 /etc/cron.d/aifabrix-
|
|
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
|
|
274
388
|
fi
|
|
275
389
|
|
|
276
390
|
echo "Setup complete. Ensure manual prerequisites (SSL at $SSL_DIR: wildcard.crt, wildcard.key; DNS $DEV_DOMAIN) are done; see SETUP.md."
|
package/dist/backup.spec.js
CHANGED
|
@@ -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(
|
|
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,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);
|
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
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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.
|
|
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",
|