diff --git a/Dockerfile b/Dockerfile index 4526a94..2d6f306 100644 --- a/Dockerfile +++ b/Dockerfile @@ -134,6 +134,11 @@ RUN id -u socktop &>/dev/null || useradd -m -s /bin/bash socktop && \ mkdir -p /home/socktop/.config/socktop && \ chown -R socktop:socktop /home/socktop +# Unprivileged user that per-session shells run as (see docker/session-shell.sh). +# Separate from socktop so a session cannot signal the server, the agent, or +# anything else that matters — the kernel refuses cross-UID signals. +RUN useradd -m -s /usr/sbin/nologin demo + # Set working directory WORKDIR /app @@ -155,7 +160,8 @@ COPY --from=node-builder /build/node_modules ./node_modules COPY docker/entrypoint.sh /entrypoint.sh COPY docker/init-config.sh /init-config.sh COPY docker/restricted-shell.sh /usr/local/bin/restricted-shell.sh -RUN chmod +x /entrypoint.sh /init-config.sh /usr/local/bin/restricted-shell.sh +COPY docker/session-shell.sh /usr/local/bin/session-shell.sh +RUN chmod +x /entrypoint.sh /init-config.sh /usr/local/bin/restricted-shell.sh /usr/local/bin/session-shell.sh # Expose ports # 8082 - webterm HTTP server @@ -166,18 +172,15 @@ EXPOSE 8082 3001 HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD curl -f http://localhost:8082/ || exit 1 -# Create a wrapper script that detects if running as root or socktop user -RUN echo '#!/bin/bash\n\ - if [ "$(id -u)" -eq 0 ]; then\n\ - # Running as root - use init-config.sh to set up and switch to socktop\n\ - exec /init-config.sh "$@"\n\ - else\n\ - # Running as socktop user - directly execute entrypoint\n\ - exec /entrypoint.sh "$@"\n\ - fi' > /docker-entrypoint.sh && chmod +x /docker-entrypoint.sh +# entrypoint.sh handles both cases itself: as root it prepares /home/demo, +# runs the agent as the socktop user, and keeps webterm-server as root so it +# can drop each session to the demo user (CAP_SETUID/CAP_SETGID); as non-root +# it behaves as before (single-UID, no session drop). +RUN ln -sf /entrypoint.sh /docker-entrypoint.sh # Set entrypoint to the wrapper ENTRYPOINT ["/docker-entrypoint.sh"] -# Default command - use restricted shell that only allows socktop commands -CMD ["webterm-server", "--host", "0.0.0.0", "--port", "8082", "--command", "/usr/local/bin/restricted-shell.sh"] +# Default command - sessions enter via session-shell.sh (per-session privilege +# drop when root) which execs the restricted shell that only allows socktop +CMD ["webterm-server", "--host", "0.0.0.0", "--port", "8082", "--command", "/usr/local/bin/session-shell.sh"] diff --git a/docker-compose.yml b/docker-compose.yml index 8636fbe..0578709 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,6 +36,9 @@ services: # Optional: Set timezone - TZ=America/New_York + # Disable socktop's local process-kill feature in every session + - SOCKTOP_NO_KILL=1 + # Optional: Logging level - RUST_LOG=info diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 8d32091..e0aebd2 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -54,12 +54,41 @@ setup_alacritty() { echo "Alacritty setup complete" } +# Prepare the home directory for the unprivileged `demo` user that sessions +# run as (see docker/session-shell.sh). Sessions need read access to the +# socktop profiles and CA certs, which are mounted under /home/socktop — copy +# them across and rewrite the cert paths, since demo cannot traverse another +# user's mounts reliably. Root-only: without root there is no demo user split. +prepare_demo_home() { + if [ "$(id -u)" -ne 0 ]; then + return + fi + echo "Preparing /home/demo for session user..." + mkdir -p /home/demo/.config/socktop/certs /home/demo/.config/alacritty + if [ -f /home/socktop/.config/socktop/profiles.json ]; then + cp /home/socktop/.config/socktop/profiles.json /home/demo/.config/socktop/profiles.json + sed -i 's|/home/socktop/|/home/demo/|g; s|/var/lib/socktop/|/home/demo/|g' /home/demo/.config/socktop/profiles.json + fi + cp /home/socktop/.config/socktop/certs/*.pem /home/demo/.config/socktop/certs/ 2>/dev/null || true + cp /home/socktop/.config/alacritty/*.toml /home/demo/.config/alacritty/ 2>/dev/null || true + chown -R demo:demo /home/demo + chmod -R go-w /home/demo + echo " ✓ /home/demo ready" +} + # Start socktop agent start_socktop_agent() { echo "Starting socktop-agent on port 3001..." - # Start socktop-agent in the background on port 3001 - /usr/bin/socktop_agent --port 3001 > /tmp/socktop-agent.log 2>&1 & + # Start socktop-agent in the background on port 3001. When root, drop it + # to the socktop user — it only reads /proc and system metrics, and a + # separate UID keeps it out of reach of the demo session user. + if [ "$(id -u)" -eq 0 ]; then + setpriv --reuid socktop --regid socktop --clear-groups --inh-caps -all --no-new-privs \ + /usr/bin/socktop_agent --port 3001 > /tmp/socktop-agent.log 2>&1 & + else + /usr/bin/socktop_agent --port 3001 > /tmp/socktop-agent.log 2>&1 & + fi AGENT_PID=$! echo "socktop-agent started (PID: $AGENT_PID)" @@ -85,6 +114,9 @@ main() { # Set up Alacritty setup_alacritty + # Home directory for the per-session demo user + prepare_demo_home + # Start socktop agent start_socktop_agent diff --git a/docker/restricted-shell.sh b/docker/restricted-shell.sh index 6c33a0f..addb9aa 100644 --- a/docker/restricted-shell.sh +++ b/docker/restricted-shell.sh @@ -12,7 +12,7 @@ CYAN='\033[0;36m' NC='\033[0m' # No Color # History file -HISTFILE="/home/socktop/.socktop_history" +HISTFILE="${HOME:-/tmp}/.socktop_history" HISTSIZE=1000 # Load history from file @@ -135,7 +135,7 @@ main() { # Allow socktop with validated arguments only if [ "$cmd" = "$input" ]; then # No arguments, use default (local profile) - /usr/bin/socktop -P local + /usr/bin/socktop --no-kill -P local else # Validate and sanitize arguments to prevent command injection # Only allow: -P or ws:// @@ -144,11 +144,11 @@ main() { if [[ "$args" =~ ^-P[[:space:]]+[a-zA-Z0-9_-]+$ ]]; then # Extract profile name and validate it profile=$(echo "$args" | sed 's/-P[[:space:]]\+//') - /usr/bin/socktop -P "$profile" + /usr/bin/socktop --no-kill -P "$profile" # Check for websocket URL (ws:// or wss://) elif [[ "$args" =~ ^wss?://[a-zA-Z0-9\.\:/_-]+$ ]]; then # Validate websocket URL format - /usr/bin/socktop "$args" + /usr/bin/socktop --no-kill "$args" else # Reject anything else as potentially dangerous echo -e "${RED}Error:${NC} Invalid arguments for socktop" diff --git a/kubernetes/03-deployment.yaml b/kubernetes/03-deployment.yaml index 8240bc8..dc0e61a 100644 --- a/kubernetes/03-deployment.yaml +++ b/kubernetes/03-deployment.yaml @@ -97,7 +97,7 @@ spec: "--port", "8082", "--command", - "/usr/local/bin/restricted-shell.sh", + "/usr/local/bin/session-shell.sh", ] ports: @@ -115,6 +115,13 @@ spec: value: "America/New_York" - name: RUST_LOG value: "info" + # Disable socktop's local process-kill feature for every socktop + # launched anywhere under this pod, regardless of command line. + # The restricted shell also passes --no-kill, but this is the layer + # a visitor cannot route around (no way to unset env from the + # restricted shell). + - name: SOCKTOP_NO_KILL + value: "1" resources: limits: @@ -158,14 +165,24 @@ spec: - name: socktop-home mountPath: /var/lib/socktop + # webterm-server runs as in-container root holding ONLY + # CAP_SETUID/CAP_SETGID (everything else dropped, no privilege + # escalation), so it can drop each websocket session to the + # unprivileged `demo` user via session-shell.sh. The kernel then + # refuses any signal a session aims at the server, the agent + # (running as `socktop`), or another session's UID — the kill + # feature's UI gating stops being the only line of defense. securityContext: allowPrivilegeEscalation: false capabilities: drop: - ALL + add: + - SETUID + - SETGID readOnlyRootFilesystem: false - runAsUser: 100 - runAsGroup: 101 + runAsUser: 0 + runAsGroup: 0 volumes: - name: config