tao-shen Claude Opus 4.6 commited on
Commit
2a081e2
Β·
1 Parent(s): ed449d2

feat: single-port SSH-over-WebSocket + full OS persistence

Browse files

- nginx multiplexes port 7860: / β†’ ttyd, /ssh β†’ ws-ssh-bridge β†’ sshd
- WebSocket-to-TCP bridge enables SSH through HF's single port
- Full system persistence: /usr/local, /home, /opt, /root, /etc + apt packages
- Dataset stores at root level (no subfolder)
- Client helper: scripts/ssh_connect.sh using websocat

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Dockerfile CHANGED
@@ -1,4 +1,6 @@
1
- # Ubuntu 24.04 Server on HuggingRun β€” ttyd web terminal on 7860, SSH on 2222
 
 
2
  FROM ubuntu:24.04
3
 
4
  ENV DEBIAN_FRONTEND=noninteractive
@@ -6,15 +8,16 @@ ENV DEBIAN_FRONTEND=noninteractive
6
  # System + Python (for HuggingRun persistence sync)
7
  RUN apt-get update && apt-get install -y --no-install-recommends \
8
  ca-certificates curl wget python3 python3-pip python3-venv git \
9
- && pip3 install --no-cache-dir --break-system-packages huggingface_hub \
10
  && rm -rf /var/lib/apt/lists/*
11
 
12
- # Server essentials + SSH + ttyd web terminal
13
  RUN apt-get update && apt-get install -y --no-install-recommends \
14
  openssh-server openssh-client \
15
- procps htop vim nano less tmux \
16
- build-essential \
17
  ttyd \
 
 
18
  && rm -rf /var/lib/apt/lists/*
19
 
20
  # Node.js 20 LTS (for Claude Code)
@@ -22,34 +25,32 @@ RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
22
  && apt-get install -y nodejs \
23
  && rm -rf /var/lib/apt/lists/*
24
 
25
- # Claude Code (native binary installer)
26
  RUN curl -fsSL https://claude.ai/install.sh | bash \
27
  || npm install -g @anthropic-ai/claude-code
28
 
29
- # HF Spaces run as user 1000; UID 1000 may already exist in base
30
- RUN (useradd -m -u 1000 -s /bin/bash user 2>/dev/null) || \
31
- (EXISTING=$(getent passwd 1000 | cut -d: -f1); \
32
- usermod -l user -s /bin/bash $EXISTING; usermod -d /home/user user; \
33
- mkdir -p /home/user && chown 1000:1000 /home/user)
34
- ENV HOME=/home/user
35
- RUN mkdir -p /data && chown 1000:1000 /data
36
 
37
- # Pre-generate SSH host key so sshd can start as non-root user
38
- RUN mkdir -p /home/user/.ssh && \
39
- ssh-keygen -t ed25519 -f /home/user/.ssh/ssh_host_ed25519_key -N "" -C "" && \
40
- chown -R 1000:1000 /home/user/.ssh
41
 
42
- # HuggingRun scripts
43
- COPY scripts /scripts
 
 
 
 
 
 
44
  COPY ubuntu-server/start-server.sh /opt/start-server.sh
 
45
  RUN chmod +x /scripts/entrypoint.sh /opt/start-server.sh
46
 
47
  ENV PERSIST_PATH=/data
48
  ENV RUN_CMD="stdbuf -oL -eL /opt/start-server.sh"
49
  ENV SSH_PORT=2222
50
- ENV SSH_LISTEN=0.0.0.0
51
- ENV TTYD_PORT=7860
52
 
53
- USER user
54
- EXPOSE 7860 2222
55
  ENTRYPOINT ["/scripts/entrypoint.sh"]
 
1
+ # Ubuntu 24.04 Server on HuggingRun
2
+ # Single port 7860: nginx β†’ ttyd (web terminal) + SSH-over-WebSocket
3
+ # Full system persistence: /usr/local, /home, /opt, /root, apt packages
4
  FROM ubuntu:24.04
5
 
6
  ENV DEBIAN_FRONTEND=noninteractive
 
8
  # System + Python (for HuggingRun persistence sync)
9
  RUN apt-get update && apt-get install -y --no-install-recommends \
10
  ca-certificates curl wget python3 python3-pip python3-venv git \
11
+ && pip3 install --no-cache-dir --break-system-packages huggingface_hub websockets \
12
  && rm -rf /var/lib/apt/lists/*
13
 
14
+ # Server: SSH + nginx + ttyd + tools
15
  RUN apt-get update && apt-get install -y --no-install-recommends \
16
  openssh-server openssh-client \
17
+ nginx \
 
18
  ttyd \
19
+ procps htop vim nano less tmux \
20
+ build-essential rsync \
21
  && rm -rf /var/lib/apt/lists/*
22
 
23
  # Node.js 20 LTS (for Claude Code)
 
25
  && apt-get install -y nodejs \
26
  && rm -rf /var/lib/apt/lists/*
27
 
28
+ # Claude Code
29
  RUN curl -fsSL https://claude.ai/install.sh | bash \
30
  || npm install -g @anthropic-ai/claude-code
31
 
32
+ # Snapshot base package list (to detect user-added packages later)
33
+ RUN dpkg-query -W -f='${Package}\n' | sort > /etc/base-packages.list
 
 
 
 
 
34
 
35
+ # SSH host keys (system-wide, for root sshd)
36
+ RUN ssh-keygen -A
 
 
37
 
38
+ # User account (for SSH login); container runs as root for system persistence
39
+ RUN (useradd -m -u 1000 -s /bin/bash user 2>/dev/null) || true
40
+ RUN echo "user:huggingrun" | chpasswd
41
+ RUN mkdir -p /data && chown 1000:1000 /data
42
+
43
+ # nginx + bridge + startup scripts
44
+ COPY ubuntu-server/nginx.conf /etc/nginx/nginx.conf
45
+ COPY ubuntu-server/ws-ssh-bridge.py /opt/ws-ssh-bridge.py
46
  COPY ubuntu-server/start-server.sh /opt/start-server.sh
47
+ COPY scripts /scripts
48
  RUN chmod +x /scripts/entrypoint.sh /opt/start-server.sh
49
 
50
  ENV PERSIST_PATH=/data
51
  ENV RUN_CMD="stdbuf -oL -eL /opt/start-server.sh"
52
  ENV SSH_PORT=2222
 
 
53
 
54
+ # Run as root (needed for: apt install persistence, bind mounts, sshd)
55
+ EXPOSE 7860
56
  ENTRYPOINT ["/scripts/entrypoint.sh"]
scripts/ssh_connect.sh ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # ─────────────────────────────────────────────────────────────────────
3
+ # SSH into HuggingRun via WebSocket (single port 7860)
4
+ # No jumphost needed β€” SSH tunnels through the HF Space's web port.
5
+ #
6
+ # Prerequisites:
7
+ # brew install websocat # macOS
8
+ # # or download from https://github.com/nicehash/websocat/releases
9
+ #
10
+ # Usage:
11
+ # bash scripts/ssh_connect.sh # default: tao-shen-huggingrun.hf.space
12
+ # bash scripts/ssh_connect.sh my-space.hf.space # custom space URL
13
+ # SSH_USER=root bash scripts/ssh_connect.sh # login as root
14
+ # ─────────────────────────────────────────────────────────────────────
15
+ set -euo pipefail
16
+
17
+ SPACE_HOST="${1:-tao-shen-huggingrun.hf.space}"
18
+ SSH_USER="${SSH_USER:-user}"
19
+ WS_URL="wss://${SPACE_HOST}/ssh"
20
+
21
+ # Check websocat
22
+ if ! command -v websocat &>/dev/null; then
23
+ echo "websocat not found. Install it:"
24
+ echo " macOS: brew install websocat"
25
+ echo " Linux: curl -L -o ~/.local/bin/websocat https://github.com/nicehash/websocat/releases/latest/download/websocat.x86_64-unknown-linux-musl && chmod +x ~/.local/bin/websocat"
26
+ exit 1
27
+ fi
28
+
29
+ echo "Connecting to ${SSH_USER}@${SPACE_HOST} via WebSocket SSH ..."
30
+ echo " WebSocket: ${WS_URL}"
31
+ echo " Password: huggingrun"
32
+ echo ""
33
+
34
+ # SSH with WebSocket as ProxyCommand
35
+ ssh -o "ProxyCommand=websocat --binary ${WS_URL}" \
36
+ -o StrictHostKeyChecking=no \
37
+ -o UserKnownHostsFile=/dev/null \
38
+ "${SSH_USER}@huggingrun"
scripts/sync_hf.py CHANGED
@@ -35,8 +35,8 @@ RUN_CMD = os.environ.get("RUN_CMD", "")
35
  APP_PORT = os.environ.get("APP_PORT", os.environ.get("PORT", "7860")) # Single port exposed by HF
36
  SYNC_INTERVAL = int(os.environ.get("SYNC_INTERVAL", "60"))
37
  AUTO_CREATE_DATASET = os.environ.get("AUTO_CREATE_DATASET", "false").lower() in ("true", "1", "yes")
38
- # In dataset we store under this path_in_repo subfolder
39
- DATASET_SUBFOLDER = "data"
40
 
41
  SPACE_ID = os.environ.get("SPACE_ID", "")
42
  HF_REPO_ID = os.environ.get("HF_DATASET_REPO", "")
@@ -100,48 +100,39 @@ class GenericSync:
100
 
101
  try:
102
  files = self.api.list_repo_files(repo_id=HF_REPO_ID, repo_type="dataset")
103
- prefix = f"{DATASET_SUBFOLDER}/"
104
- data_files = [f for f in files if f.startswith(prefix)]
105
  if not data_files:
106
- print(f"[HuggingRun] No {prefix} in dataset. Starting fresh.")
107
  PERSIST_PATH.mkdir(parents=True, exist_ok=True)
108
  return
109
 
110
- print(f"[HuggingRun] Restoring {PERSIST_PATH} from {HF_REPO_ID} ...")
111
  PERSIST_PATH.mkdir(parents=True, exist_ok=True)
112
- with tempfile.TemporaryDirectory() as tmpdir:
113
- for attempt in range(2):
114
- try:
115
- snapshot_download(
116
- repo_id=HF_REPO_ID,
117
- repo_type="dataset",
118
- allow_patterns=f"{prefix}**",
119
- local_dir=tmpdir,
120
- token=HF_TOKEN,
121
- )
122
- break
123
- except Exception as e:
124
- if attempt == 0:
125
- print(f"[HuggingRun] Restore attempt {attempt + 1} failed: {e}. Retrying...")
126
- time.sleep(3)
127
- else:
128
- raise
129
- src = Path(tmpdir) / DATASET_SUBFOLDER
130
- if src.exists():
131
- for item in src.rglob("*"):
132
- if item.is_file():
133
- rel = item.relative_to(src)
134
- dest = PERSIST_PATH / rel
135
- dest.parent.mkdir(parents=True, exist_ok=True)
136
- shutil.copy2(str(item), str(dest))
137
- print("[HuggingRun] Restore completed.")
138
  except Exception as e:
139
  print(f"[HuggingRun] Restore failed: {e}")
140
  traceback.print_exc()
141
  PERSIST_PATH.mkdir(parents=True, exist_ok=True)
142
 
143
  def save_to_repo(self):
144
- """Upload PERSIST_PATH β†’ dataset."""
145
  if not self.enabled or not self.dataset_exists:
146
  return
147
  if not PERSIST_PATH.exists():
@@ -152,12 +143,12 @@ class GenericSync:
152
  return
153
  self.api.upload_folder(
154
  folder_path=str(PERSIST_PATH),
155
- path_in_repo=DATASET_SUBFOLDER,
156
  repo_id=HF_REPO_ID,
157
  repo_type="dataset",
158
  token=HF_TOKEN,
159
  commit_message=f"HuggingRun sync β€” {datetime.now().isoformat()}",
160
- ignore_patterns=["__pycache__", "*.pyc", ".git"],
161
  )
162
  print(f"[HuggingRun] Upload completed.")
163
  except Exception as e:
 
35
  APP_PORT = os.environ.get("APP_PORT", os.environ.get("PORT", "7860")) # Single port exposed by HF
36
  SYNC_INTERVAL = int(os.environ.get("SYNC_INTERVAL", "60"))
37
  AUTO_CREATE_DATASET = os.environ.get("AUTO_CREATE_DATASET", "false").lower() in ("true", "1", "yes")
38
+ # Store directly at dataset root (no subfolder)
39
+ DATASET_SUBFOLDER = ""
40
 
41
  SPACE_ID = os.environ.get("SPACE_ID", "")
42
  HF_REPO_ID = os.environ.get("HF_DATASET_REPO", "")
 
100
 
101
  try:
102
  files = self.api.list_repo_files(repo_id=HF_REPO_ID, repo_type="dataset")
103
+ # Filter out metadata files (.gitattributes, README.md, etc.)
104
+ data_files = [f for f in files if not f.startswith(".") and f != "README.md"]
105
  if not data_files:
106
+ print(f"[HuggingRun] Dataset empty. Starting fresh.")
107
  PERSIST_PATH.mkdir(parents=True, exist_ok=True)
108
  return
109
 
110
+ print(f"[HuggingRun] Restoring {PERSIST_PATH} from {HF_REPO_ID} ({len(data_files)} files) ...")
111
  PERSIST_PATH.mkdir(parents=True, exist_ok=True)
112
+ for attempt in range(2):
113
+ try:
114
+ snapshot_download(
115
+ repo_id=HF_REPO_ID,
116
+ repo_type="dataset",
117
+ local_dir=str(PERSIST_PATH),
118
+ token=HF_TOKEN,
119
+ ignore_patterns=[".git*", "README.md"],
120
+ )
121
+ break
122
+ except Exception as e:
123
+ if attempt == 0:
124
+ print(f"[HuggingRun] Restore attempt {attempt + 1} failed: {e}. Retrying...")
125
+ time.sleep(3)
126
+ else:
127
+ raise
128
+ print("[HuggingRun] Restore completed.")
 
 
 
 
 
 
 
 
 
129
  except Exception as e:
130
  print(f"[HuggingRun] Restore failed: {e}")
131
  traceback.print_exc()
132
  PERSIST_PATH.mkdir(parents=True, exist_ok=True)
133
 
134
  def save_to_repo(self):
135
+ """Upload PERSIST_PATH β†’ dataset root."""
136
  if not self.enabled or not self.dataset_exists:
137
  return
138
  if not PERSIST_PATH.exists():
 
143
  return
144
  self.api.upload_folder(
145
  folder_path=str(PERSIST_PATH),
146
+ path_in_repo="", # directly at dataset root
147
  repo_id=HF_REPO_ID,
148
  repo_type="dataset",
149
  token=HF_TOKEN,
150
  commit_message=f"HuggingRun sync β€” {datetime.now().isoformat()}",
151
+ ignore_patterns=["__pycache__", "*.pyc", ".git", ".git*"],
152
  )
153
  print(f"[HuggingRun] Upload completed.")
154
  except Exception as e:
ubuntu-server/nginx.conf ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ worker_processes 1;
2
+ pid /tmp/nginx.pid;
3
+ error_log /tmp/nginx-error.log warn;
4
+
5
+ events {
6
+ worker_connections 256;
7
+ }
8
+
9
+ http {
10
+ access_log off;
11
+ client_body_temp_path /tmp/nginx-body;
12
+ proxy_temp_path /tmp/nginx-proxy;
13
+ fastcgi_temp_path /tmp/nginx-fastcgi;
14
+ uwsgi_temp_path /tmp/nginx-uwsgi;
15
+ scgi_temp_path /tmp/nginx-scgi;
16
+
17
+ map $http_upgrade $connection_upgrade {
18
+ default upgrade;
19
+ '' close;
20
+ }
21
+
22
+ server {
23
+ listen 7860;
24
+
25
+ # /ssh β†’ WebSocket-to-SSH bridge (Python asyncio on 7862)
26
+ location /ssh {
27
+ proxy_pass http://127.0.0.1:7862;
28
+ proxy_http_version 1.1;
29
+ proxy_set_header Upgrade $http_upgrade;
30
+ proxy_set_header Connection $connection_upgrade;
31
+ proxy_set_header Host $host;
32
+ proxy_read_timeout 86400;
33
+ proxy_send_timeout 86400;
34
+ }
35
+
36
+ # Everything else β†’ ttyd web terminal (on 7681)
37
+ location / {
38
+ proxy_pass http://127.0.0.1:7681;
39
+ proxy_http_version 1.1;
40
+ proxy_set_header Upgrade $http_upgrade;
41
+ proxy_set_header Connection $connection_upgrade;
42
+ proxy_set_header Host $host;
43
+ proxy_read_timeout 86400;
44
+ proxy_send_timeout 86400;
45
+ }
46
+ }
47
+ }
ubuntu-server/start-server.sh CHANGED
@@ -1,77 +1,123 @@
1
  #!/bin/bash
2
- # Start Ubuntu Server: ttyd web terminal on 7860 + sshd on 2222
3
- # All state persisted in /data via HuggingRun sync
 
 
 
4
  echo "[start-server] Starting ..." >&2
5
  set -e
6
 
7
  export PERSIST_PATH="${PERSIST_PATH:-/data}"
8
  export SSH_PORT="${SSH_PORT:-2222}"
9
- export SSH_LISTEN="${SSH_LISTEN:-0.0.0.0}"
10
- export TTYD_PORT="${TTYD_PORT:-7860}"
11
-
12
- # Persistent home: shell history, configs, installed tools survive restarts
13
- SERVER_HOME="$PERSIST_PATH/home"
14
- mkdir -p "$SERVER_HOME"
15
- export HOME="$SERVER_HOME"
16
-
17
- # Ensure basic dirs
18
- mkdir -p "$HOME/.ssh" "$HOME/.config" "$HOME/.local/bin"
19
-
20
- # Add persistent bin to PATH (user-installed tools survive restart)
21
- export PATH="$HOME/.local/bin:$PATH"
22
-
23
- # Copy bashrc if not yet personalized
24
- if [ ! -f "$HOME/.bashrc" ]; then
25
- cat > "$HOME/.bashrc" <<'BASHRC'
26
- # HuggingRun Ubuntu Server
27
- export PATH="$HOME/.local/bin:$PATH"
28
- export PS1='\[\033[01;32m\]\u@huggingrun\[\033[00m\]:\[\033[01;34m\]\w\[\033[00m\]\$ '
29
- alias ll='ls -la'
30
- alias la='ls -A'
31
- BASHRC
32
- fi
33
-
34
- # ── SSH ──────────────────────────────────────────────────────────────
35
- set +e
36
-
37
- # Host key: use pre-generated from Docker build, or generate at runtime
38
- HOST_KEY="$HOME/.ssh/ssh_host_ed25519_key"
39
- [ ! -f "$HOST_KEY" ] && cp /home/user/.ssh/ssh_host_ed25519_key "$HOST_KEY" 2>/dev/null
40
- [ ! -f "$HOST_KEY" ] && ssh-keygen -t ed25519 -f "$HOST_KEY" -N "" -C "" 2>/dev/null
41
-
42
- # If SSH_AUTHORIZED_KEYS env is set, use key-based auth; otherwise password auth for testing
43
- [ -n "${SSH_AUTHORIZED_KEYS-}" ] && echo "$SSH_AUTHORIZED_KEYS" > "$HOME/.ssh/authorized_keys" && chmod 600 "$HOME/.ssh/authorized_keys"
44
-
45
- if [ -f "$HOST_KEY" ]; then
46
- if [ -f "$HOME/.ssh/authorized_keys" ]; then
47
- echo "[start-server] Starting sshd (key auth) on $SSH_LISTEN:$SSH_PORT ..." >&2
48
- /usr/sbin/sshd -o "Port=$SSH_PORT" -o "HostKey=$HOST_KEY" \
49
- -o "AuthorizedKeysFile=$HOME/.ssh/authorized_keys" \
50
- -o "PermitEmptyPasswords=no" -o "PasswordAuthentication=no" \
51
- -o "ListenAddress=$SSH_LISTEN" -o "PidFile=$HOME/.ssh/sshd.pid" \
52
- -o "UsePAM=no" -o "PermitUserEnvironment=yes" -D -e &
53
- else
54
- echo "[start-server] Starting sshd (password-less, test mode) on $SSH_LISTEN:$SSH_PORT ..." >&2
55
- /usr/sbin/sshd -o "Port=$SSH_PORT" -o "HostKey=$HOST_KEY" \
56
- -o "PermitEmptyPasswords=yes" -o "PasswordAuthentication=yes" \
57
- -o "ListenAddress=$SSH_LISTEN" -o "PidFile=$HOME/.ssh/sshd.pid" \
58
- -o "UsePAM=no" -o "PermitRootLogin=no" -D -e &
59
  fi
60
- SSHD_PID=$!
61
- sleep 1
62
- echo "[start-server] sshd PID=$SSHD_PID" >&2
63
-
64
- # Reverse SSH tunnel (HF Spaces: outbound only on 80/443/8080)
65
- [ -n "${SSH_REVERSE_TARGET-}" ] && ssh -o StrictHostKeyChecking=no -o ServerAliveInterval=60 \
66
- -R "0.0.0.0:${SSH_PORT}:127.0.0.1:${SSH_PORT}" $SSH_REVERSE_TARGET -N &
67
- fi
68
- set -e
69
 
70
- # ── ttyd web terminal ───────────────────────────────────────────────
71
- echo "[start-server] Starting ttyd on 0.0.0.0:$TTYD_PORT ..." >&2
72
- # ttyd runs bash in foreground; writable=true allows input
73
- exec ttyd \
74
- --port "$TTYD_PORT" \
75
- --writable \
76
- --base-path / \
77
- bash --login
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  #!/bin/bash
2
+ # ─────────────────────────────────────────────────────────────────────
3
+ # HuggingRun Ubuntu Server: full system persistence + ttyd + SSH
4
+ # Port 7860 (nginx): web terminal + SSH-over-WebSocket
5
+ # Persists: /usr/local, /home, /opt, /root, /etc changes, apt packages
6
+ # ─────────────────────────────────────────────────────────────────────
7
  echo "[start-server] Starting ..." >&2
8
  set -e
9
 
10
  export PERSIST_PATH="${PERSIST_PATH:-/data}"
11
  export SSH_PORT="${SSH_PORT:-2222}"
12
+ export TTYD_PORT="${TTYD_PORT:-7681}"
13
+
14
+ # ── Phase 1: Restore persisted system files ──────────────────────────
15
+ echo "[start-server] Restoring persisted system state ..." >&2
16
+ restore_system() {
17
+ local P="$PERSIST_PATH"
18
+
19
+ # Restore /usr/local (pip, npm, manually installed tools)
20
+ [ -d "$P/usr-local" ] && rsync -a "$P/usr-local/" /usr/local/ 2>/dev/null && \
21
+ echo "[persist] Restored /usr/local" >&2
22
+
23
+ # Restore /home (user home dirs)
24
+ [ -d "$P/home" ] && rsync -a "$P/home/" /home/ 2>/dev/null && \
25
+ echo "[persist] Restored /home" >&2
26
+
27
+ # Restore /opt (optional software)
28
+ [ -d "$P/opt-persist" ] && rsync -a "$P/opt-persist/" /opt/ 2>/dev/null && \
29
+ echo "[persist] Restored /opt" >&2
30
+
31
+ # Restore /root (root home)
32
+ [ -d "$P/root" ] && rsync -a "$P/root/" /root/ 2>/dev/null && \
33
+ echo "[persist] Restored /root" >&2
34
+
35
+ # Restore /etc changes (selective)
36
+ [ -d "$P/etc" ] && rsync -a "$P/etc/" /etc/ 2>/dev/null && \
37
+ echo "[persist] Restored /etc" >&2
38
+
39
+ # Reinstall apt packages added by user
40
+ if [ -f "$P/system/apt-packages.list" ]; then
41
+ echo "[persist] Reinstalling user apt packages ..." >&2
42
+ apt-get update -qq 2>/dev/null
43
+ xargs -a "$P/system/apt-packages.list" apt-get install -y -qq 2>/dev/null || true
44
+ echo "[persist] Apt packages restored" >&2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  fi
 
 
 
 
 
 
 
 
 
46
 
47
+ # Refresh library cache
48
+ ldconfig 2>/dev/null || true
49
+ }
50
+ restore_system
51
+
52
+ # ── Phase 2: Save system state (periodic background) ────────────────
53
+ save_system() {
54
+ local P="$PERSIST_PATH"
55
+ mkdir -p "$P/system" "$P/usr-local" "$P/home" "$P/opt-persist" "$P/root" "$P/etc"
56
+
57
+ # Save /usr/local, /home, /opt, /root
58
+ rsync -a --delete /usr/local/ "$P/usr-local/" 2>/dev/null
59
+ rsync -a --delete /home/ "$P/home/" 2>/dev/null
60
+ rsync -a --delete /opt/ "$P/opt-persist/" 2>/dev/null
61
+ rsync -a --delete /root/ "$P/root/" 2>/dev/null
62
+
63
+ # Save /etc (selective: avoid overwriting container-specific files)
64
+ rsync -a \
65
+ /etc/apt/sources.list.d/ \
66
+ /etc/environment \
67
+ /etc/profile.d/ \
68
+ /etc/bash.bashrc \
69
+ "$P/etc/" 2>/dev/null || true
70
+
71
+ # Save user-added apt packages (diff against base image)
72
+ comm -23 \
73
+ <(dpkg-query -W -f='${Package}\n' | sort) \
74
+ <(sort /etc/base-packages.list 2>/dev/null) \
75
+ > "$P/system/apt-packages.list" 2>/dev/null || true
76
+
77
+ echo "[persist] System saved at $(date -u +%H:%M:%S)" >&2
78
+ }
79
+
80
+ # Background: save system every 120 seconds
81
+ (
82
+ sleep 60 # initial delay
83
+ while true; do
84
+ save_system
85
+ sleep 120
86
+ done
87
+ ) &
88
+ PERSIST_PID=$!
89
+ echo "[start-server] Persistence background PID=$PERSIST_PID" >&2
90
+
91
+ # Save on exit
92
+ trap 'echo "[start-server] Saving system before exit..." >&2; save_system; exit 0' SIGTERM SIGINT
93
+
94
+ # ── Phase 3: Start sshd ─────────────────────────────────────────────
95
+ echo "[start-server] Starting sshd on 127.0.0.1:$SSH_PORT ..." >&2
96
+ /usr/sbin/sshd -o "Port=$SSH_PORT" \
97
+ -o "ListenAddress=127.0.0.1" \
98
+ -o "PermitRootLogin=yes" \
99
+ -o "PasswordAuthentication=yes" \
100
+ -o "PermitEmptyPasswords=no" \
101
+ -o "UsePAM=no" \
102
+ -D -e &
103
+ SSHD_PID=$!
104
+ sleep 1
105
+ echo "[start-server] sshd PID=$SSHD_PID" >&2
106
+
107
+ # ── Phase 4: Start WebSocket-to-SSH bridge ───────────────────────────
108
+ echo "[start-server] Starting WS-SSH bridge on 127.0.0.1:7862 ..." >&2
109
+ python3 /opt/ws-ssh-bridge.py &
110
+ BRIDGE_PID=$!
111
+ sleep 1
112
+ echo "[start-server] WS-SSH bridge PID=$BRIDGE_PID" >&2
113
+
114
+ # ── Phase 5: Start ttyd (web terminal) ──────────────────────────────
115
+ echo "[start-server] Starting ttyd on 127.0.0.1:$TTYD_PORT ..." >&2
116
+ ttyd --port "$TTYD_PORT" --writable --base-path / bash --login &
117
+ TTYD_PID=$!
118
+ sleep 1
119
+ echo "[start-server] ttyd PID=$TTYD_PID" >&2
120
+
121
+ # ── Phase 6: Start nginx (foreground, port 7860) ────────────────────
122
+ echo "[start-server] Starting nginx on 0.0.0.0:7860 ..." >&2
123
+ exec nginx -g 'daemon off;'
ubuntu-server/ws-ssh-bridge.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """WebSocket-to-SSH bridge: accepts WebSocket connections on port 7862,
3
+ bridges each to sshd on 127.0.0.1:2222. Handles concurrent connections.
4
+
5
+ Used by nginx to provide SSH-over-WebSocket through the single port 7860.
6
+ Client usage:
7
+ ssh -o 'ProxyCommand=websocat -b wss://tao-shen-huggingrun.hf.space/ssh' user@huggingrun
8
+ """
9
+ import asyncio
10
+ import os
11
+ import signal
12
+ import sys
13
+
14
+ try:
15
+ import websockets
16
+ from websockets.server import serve
17
+ except ImportError:
18
+ print("[ws-ssh-bridge] websockets not installed, pip installing...", file=sys.stderr)
19
+ import subprocess
20
+ subprocess.check_call([sys.executable, "-m", "pip", "install", "websockets", "-q"])
21
+ import websockets
22
+ from websockets.server import serve
23
+
24
+ SSH_HOST = "127.0.0.1"
25
+ SSH_PORT = int(os.environ.get("SSH_PORT", "2222"))
26
+ WS_PORT = 7862
27
+
28
+
29
+ async def bridge(websocket):
30
+ """Bridge a single WebSocket connection to sshd via TCP."""
31
+ try:
32
+ reader, writer = await asyncio.open_connection(SSH_HOST, SSH_PORT)
33
+ except Exception as e:
34
+ print(f"[ws-ssh-bridge] Cannot connect to sshd: {e}", file=sys.stderr)
35
+ await websocket.close(1011, f"sshd unreachable: {e}")
36
+ return
37
+
38
+ async def ws_to_tcp():
39
+ try:
40
+ async for msg in websocket:
41
+ if isinstance(msg, bytes):
42
+ writer.write(msg)
43
+ elif isinstance(msg, str):
44
+ writer.write(msg.encode())
45
+ await writer.drain()
46
+ except websockets.ConnectionClosed:
47
+ pass
48
+ finally:
49
+ if not writer.is_closing():
50
+ writer.close()
51
+
52
+ async def tcp_to_ws():
53
+ try:
54
+ while True:
55
+ data = await reader.read(65536)
56
+ if not data:
57
+ break
58
+ await websocket.send(data)
59
+ except (websockets.ConnectionClosed, ConnectionResetError):
60
+ pass
61
+
62
+ try:
63
+ await asyncio.gather(ws_to_tcp(), tcp_to_ws())
64
+ except Exception:
65
+ pass
66
+ finally:
67
+ if not writer.is_closing():
68
+ writer.close()
69
+
70
+
71
+ async def main():
72
+ print(f"[ws-ssh-bridge] Listening on 127.0.0.1:{WS_PORT} β†’ sshd {SSH_HOST}:{SSH_PORT}",
73
+ file=sys.stderr)
74
+ async with serve(bridge, "127.0.0.1", WS_PORT,
75
+ ping_interval=30, ping_timeout=120,
76
+ max_size=None):
77
+ stop = asyncio.Event()
78
+ loop = asyncio.get_event_loop()
79
+ for sig in (signal.SIGINT, signal.SIGTERM):
80
+ loop.add_signal_handler(sig, stop.set)
81
+ await stop.wait()
82
+
83
+
84
+ if __name__ == "__main__":
85
+ asyncio.run(main())