@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 +65 -8
- package/assets/aifabrix-apply-dev-users.sh +123 -0
- package/assets/builder/builder-server/nginx-builder-server.conf.template +3 -7
- package/assets/setup-dev-server-no-node.sh +225 -69
- 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 +14 -8
- package/dist/restore.js +2 -2
- 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).
|
|
@@ -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
|
|
5
|
-
#
|
|
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 $
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
# ---
|
|
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
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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
|
-
# ---
|
|
174
|
-
|
|
175
|
-
mkdir -p "$DATA_DIR"
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
185
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
204
|
-
|
|
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."
|
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);
|
|
@@ -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
|
|
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",
|