diff --git a/.dockerignore b/.dockerignore index 2a39733..2d46005 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,6 +2,10 @@ .gitignore .omc/ .vscode/ +.github/ +.railway/ +node_modules/ +package*.json *.md !README.md LICENSE diff --git a/.gitattributes b/.gitattributes index 6030a96..ff8b7c3 100644 --- a/.gitattributes +++ b/.gitattributes @@ -11,7 +11,7 @@ s6-overlay/** text eol=lf sshd_config text eol=lf fail2ban/** text eol=lf banner.txt text eol=lf -supervisord.conf text eol=lf +tmux.conf text eol=lf # Dockerfile Dockerfile text eol=lf diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 373d4de..074acb6 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -10,13 +10,13 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Build image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . push: false diff --git a/.github/workflows/lint-dockerfile.yml b/.github/workflows/lint-dockerfile.yml index 29933fd..bbd7463 100644 --- a/.github/workflows/lint-dockerfile.yml +++ b/.github/workflows/lint-dockerfile.yml @@ -12,9 +12,9 @@ jobs: hadolint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Lint Dockerfile - uses: hadolint/hadolint-action@v3.1.0 + uses: hadolint/hadolint-action@v3.5.0 with: dockerfile: Dockerfile diff --git a/.github/workflows/shellcheck.yml b/.github/workflows/shellcheck.yml index 1cdc3dd..6deaa3b 100644 --- a/.github/workflows/shellcheck.yml +++ b/.github/workflows/shellcheck.yml @@ -12,7 +12,7 @@ jobs: shellcheck: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Run ShellCheck uses: ludeeus/action-shellcheck@2.0.0 diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml index 31f19a2..06aa299 100644 --- a/.github/workflows/trivy.yml +++ b/.github/workflows/trivy.yml @@ -14,13 +14,13 @@ jobs: permissions: security-events: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Build image for scanning - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . push: false @@ -29,7 +29,7 @@ jobs: cache-from: type=gha - name: Run Trivy vulnerability scanner - uses: aquasecurity/trivy-action@0.28.0 + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: image-ref: claude-code-ssh:scan format: sarif @@ -37,7 +37,7 @@ jobs: severity: CRITICAL,HIGH - name: Upload Trivy scan results - uses: github/codeql-action/upload-sarif@v3 + uses: github/codeql-action/upload-sarif@v4 if: always() with: sarif_file: trivy-results.sarif diff --git a/.gitignore b/.gitignore index b149a9f..91ac027 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ Thumbs.db .vscode/ .idea/ +# Railway IaC SDK (npm install) +node_modules/ + # OMC state .omc/ diff --git a/.hadolint.yaml b/.hadolint.yaml index 6ce56b4..bd94f56 100644 --- a/.hadolint.yaml +++ b/.hadolint.yaml @@ -1,8 +1,10 @@ --- ignored: - # apt pin versions — impractical on rolling base image (node:24-slim) + # apt pin versions — impractical on rolling base image (node:26-slim) - DL3008 # Last USER should not be root — s6-overlay requires root as PID 1 - DL3002 + # Non-numeric USER — claude is created in this image and only switched to at build time + - DL3066 # Single quotes intentional — writes literal $HOME/$PATH to profile script - SC2016 diff --git a/.railway/railway.ts b/.railway/railway.ts new file mode 100644 index 0000000..c923679 --- /dev/null +++ b/.railway/railway.ts @@ -0,0 +1,50 @@ +import { defineRailway, github, project, service, volume } from "railway/iac"; + +// This repository manages only its own resources, so applying it into an +// existing project never touches the other services there. +// See https://docs.railway.com/infrastructure-as-code#multi-repo-projects +export const partial = "claude-code-ssh"; + +export default defineRailway(() => { + // EU West (Amsterdam). The service and its volume must share a region; pick + // one near you before the first apply, since moving a volume is destructive. + const region = "europe-west4-drams3a"; + + // Name matches the volume created by the template, so existing deployments + // keep their data instead of getting a new, empty volume. + const data = volume("claude-code-mobile-volume", { region, sizeMB: 5000 }); + + const claudeCodeSsh = service("claude-code-ssh", { + // Deploying from a fork? Point this at your fork. + source: github("Ntelikatos/claude-code-ssh", { branch: "master", checkSuites: true }), + // Railway always builds with the root Dockerfile when one exists. + build: { + buildEnvironment: "V3", + watchPatterns: [ + "Dockerfile", + "sshd_config", + "banner.txt", + "tmux.conf", + "scripts/**", + "s6-overlay/**", + "fail2ban/**", + ], + }, + // Restart policy type defaults to ON_FAILURE; only the retry limit differs. + deploy: { + restartPolicyMaxRetries: 5, + }, + replicas: { [region]: 1 }, + tcp: [22], + volumeMounts: { + "/data": data, + }, + }); + + return project("claude-code-ssh", { + // Secrets (SSH_PUBLIC_KEY, ANTHROPIC_API_KEY, GITHUB_TOKEN, ...) stay in + // Railway variables and are never written to this file. + variables: { managed: false }, + resources: [claudeCodeSsh, data], + }); +}); diff --git a/Dockerfile b/Dockerfile index 7ce3bdb..e170611 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,13 +1,13 @@ # ============================================================================== # claude-code-ssh — SSH-accessible Claude Code container -# Base: node:24-slim (Debian Bookworm) +# Base: node:26-slim (Node.js 26 LTS, Debian 13 Trixie) # PID 1: s6-overlay v3 # ============================================================================== -FROM node:24-slim AS base +FROM node:26-slim AS base # ---------- build args -------------------------------------------------------- -ARG S6_OVERLAY_VERSION=3.2.0.2 +ARG S6_OVERLAY_VERSION=3.2.3.2 # ---------- locale ------------------------------------------------------------ ENV LANG=en_US.UTF-8 \ @@ -47,7 +47,7 @@ RUN install -m 0755 -d /etc/apt/keyrings \ # ---------- Node version manager (n) + pnpm ----------------------------------- # hadolint ignore=DL3016 -RUN npm install -g n@10.1.0 pnpm@10.4.1 \ +RUN npm install -g n@11.0.0 pnpm@12.16.1 \ && npm cache clean --force # ---------- s6-overlay v3 ----------------------------------------------------- @@ -87,6 +87,7 @@ RUN printf '%s\n' \ RUN mkdir -p /run/sshd /var/log /data \ && chown claude:claude /data \ && sed -i 's/^module(load="imklog")/#module(load="imklog")/' /etc/rsyslog.conf \ + && echo 'auth,authpriv.* /dev/stdout' > /etc/rsyslog.d/50-auth-stdout.conf \ && rm -f /etc/ssh/ssh_host_* # ---------- config files ------------------------------------------------------ @@ -99,6 +100,7 @@ COPY tmux.conf /etc/tmux.conf # ---------- s6-overlay service definitions ------------------------------------ COPY s6-overlay/s6-rc.d/ /etc/s6-overlay/s6-rc.d/ +COPY s6-overlay/user-bundles.d/ /etc/s6-overlay/user-bundles.d/ # ---------- scripts ----------------------------------------------------------- COPY scripts/init-setup.sh /etc/s6-overlay/scripts/init-setup.sh @@ -107,16 +109,13 @@ RUN chmod +x /etc/s6-overlay/scripts/init-setup.sh /usr/local/bin/stderr-to-json # ---------- register s6 services in user bundle ------------------------------- RUN for svc in /etc/s6-overlay/s6-rc.d/*/type; do \ - svc_name="$(basename "$(dirname "$svc")")"; \ - if [ "$svc_name" != "user" ] && [ "$svc_name" != "user2" ]; then \ - touch "/etc/s6-overlay/s6-rc.d/user/contents.d/${svc_name}"; \ - fi; \ + touch "/etc/s6-overlay/user-bundles.d/user/contents.d/$(basename "$(dirname "$svc")")"; \ done # ---------- runtime ----------------------------------------------------------- EXPOSE 22 HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ - CMD ssh-keyscan -T 5 localhost >/dev/null 2>&1 || exit 1 + CMD ["/bin/sh", "-c", "ssh-keyscan -T 5 localhost >/dev/null 2>&1 || exit 1"] ENTRYPOINT ["/bin/bash", "-c", "exec /init 2> >(stderr-to-json)"] diff --git a/README.md b/README.md index 8ccfa8e..ef31c46 100644 --- a/README.md +++ b/README.md @@ -14,13 +14,14 @@ Run Claude Code from anywhere via SSH. Deploy on Railway and code from your phon - **Claude Code CLI** ready to use over SSH from any device - **Persistent sessions** — tmux is pre-configured for mobile. Switch WiFi, close the app, come back later. Your session is still there. -- **Hardened SSH** — key-only auth, modern ciphers, sshaudit.com compliant. No passwords, no weak algorithms. -- **Brute-force protection** — fail2ban bans attackers automatically (1 hour ban after 3 failures, 1 week for repeat offenders) +- **Hardened SSH** — key-only auth, post-quantum key exchange (ML-KEM + X25519), modern ciphers, zero ssh-audit failures. No passwords, no weak algorithms. +- **Brute-force protection** — key-only auth everywhere; on a VPS or direct Docker, fail2ban and OpenSSH per-source penalties also ban attackers (1 hour after 3 failures, 1 week for repeat offenders) - **Mobile-optimized tmux** — pre-tuned for Termius, Blink, and JuiceSSH. No broken characters, no escape sequence leaks. -- **Node.js 24 + pnpm** — ready for full-stack development. Override versions with `NODE_VERSION` and `PNPM_VERSION` env vars. +- **Node.js 26 LTS + pnpm 12** — ready for full-stack development. Override versions with `NODE_VERSION` and `PNPM_VERSION` env vars. - **GitHub integration** — clone private repos with a fine-grained PAT scoped to specific repos only - **Persistent storage** — your projects, auth, and config survive redeployments via Railway volumes - **Multi-device access** — add SSH keys from your laptop, phone, and tablet. Connect from anywhere. +- **Railway Infrastructure as Code** — the whole deployment (service, Dockerfile build, TCP proxy, volume, restart policy) is declared in [`.railway/railway.ts`](.railway/railway.ts). Review it with `railway config plan`, roll it out with `railway config apply`. --- @@ -30,7 +31,9 @@ Run Claude Code from anywhere via SSH. Deploy on Railway and code from your phon [![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/claude-code-mobile?referralCode=fhlcDU&utm_medium=integration&utm_source=template&utm_campaign=generic) -Click the button above, or manually create a project on [railway.com](https://railway.com) and deploy from this GitHub repo. +The template already sets up the TCP proxy on port `22` and a persistent volume at `/data`. + +Prefer to manage the project from code? See [Deploy with Infrastructure as Code](#deploy-with-infrastructure-as-code). ### Step 2: Set Your SSH Public Key @@ -59,44 +62,33 @@ ssh-ed25519 AAAA...laptop-key... you@laptop ssh-ed25519 AAAA...phone-key... mobile ``` -### Step 3: Enable TCP Proxy - -1. Go to your service **Settings** > **Networking** -2. Under **TCP Proxy**, click **Enable** on port `22` -3. Railway assigns you a domain and port, e.g. `shuttle.proxy.rlwy.net:15140` - -Save these — you need them to connect. - -### Step 4: Create and Attach a Volume +### Step 3: Find Your SSH Address -1. In Railway dashboard, right-click on your project canvas > **"Add Volume"** -2. Attach it to your service -3. Set the **mount path** to `/data` -4. Redeploy if prompted +Go to your service **Settings** > **Networking** > **TCP Proxy**. Railway shows the public address for port `22`, e.g. `shuttle.proxy.rlwy.net:15140`. -This persists your projects, SSH host keys, tmux sessions, and Claude Code auth across redeployments. Without it, all data is lost on restart. +Save it — you need the host and port to connect. -### Step 5: Connect +### Step 4: Connect ```bash ssh claude@shuttle.proxy.rlwy.net -p 15140 -i ~/.ssh/id_ed25519 ``` -Replace the domain and port with your values from Step 3. +Replace the host and port with your values from Step 3. -### Step 6: Authenticate Claude Code +### Step 5: Authenticate Claude Code Choose one method: -**Option A: Claude Account (Pro/Max subscription)** — recommended +**Option A: Claude subscription (Pro, Max, Ultra, Team, or Enterprise)** — recommended ```bash -claude login +claude auth login ``` -This prints a URL. Open it in any browser, sign in with your Claude account, done. No API key needed. +Open the URL it prints in any browser, sign in with your Claude account, and paste the code back into the terminal if asked. No API key needed. -**Option B: API Key** +**Option B: API key from the Claude Console** Add `ANTHROPIC_API_KEY` in Railway Variables (triggers redeploy), or set it manually: @@ -107,7 +99,7 @@ echo 'export ANTHROPIC_API_KEY=sk-ant-your-key-here' >> ~/.bashrc Auth persists across restarts via the `/data` volume. -### Step 7: Start Coding +### Step 6: Start Coding ```bash tmux new -s dev @@ -116,20 +108,19 @@ claude Detach anytime with `Ctrl+B` then `D`. Reattach later with `tmux attach -t dev`. -### Step 8: Clone Your Repos (optional) +### Step 7: Clone Your Repos (optional) Use a fine-grained Personal Access Token to grant access to **specific repos only**. -1. Go to [GitHub > Settings > Developer settings > Fine-grained tokens](https://github.com/settings/tokens?type=beta) -2. Click **Generate new token** -3. Set **Resource owner** to your account or org -4. Set **Repository access** to **Only select repositories** and pick the repos you need -5. Under **Permissions > Repository permissions**, grant: +1. Go to [GitHub > Settings > Developer settings > Personal access tokens > Fine-grained tokens](https://github.com/settings/personal-access-tokens/new) +2. Set **Resource owner** to your account or org +3. Set **Repository access** to **Only select repositories** and pick the repos you need +4. Under **Permissions > Repository permissions**, grant: - **Contents**: Read and write (clone, push, branches) - **Pull requests**: Read and write (create/merge PRs) - **Issues**: Read and write (create/comment on issues) -6. Generate and copy the token -7. In Railway dashboard, add it as a variable: +5. Generate and copy the token +6. In Railway dashboard, add it as a variable: | Variable | Value | | -------------- | -------------------- | @@ -151,6 +142,54 @@ Auth persists across restarts via the `/data` volume. --- +## Deploy with Infrastructure as Code + +The Railway project is defined in [`.railway/railway.ts`](.railway/railway.ts) using [Railway Infrastructure as Code](https://docs.railway.com/infrastructure-as-code): one `claude-code-ssh` service built from the `Dockerfile`, a TCP proxy on port `22`, a volume mounted at `/data`, and the restart policy. Secrets are never stored in the file: the project sets `variables: { managed: false }`, so your Railway variables stay untouched. + +Requires the [Railway CLI](https://docs.railway.com/cli) (`railway upgrade` for the latest) and Node.js 22+. + +1. Fork this repo, clone your fork, and install the IaC SDK: + + ```bash + git clone https://github.com//claude-code-ssh + cd claude-code-ssh + npm install + ``` + +2. In `.railway/railway.ts`, point `github(...)` at your fork and pick a `region` near you. + +3. Create a project (or `railway link` an existing one), then preview and apply: + + ```bash + railway login + railway init --name claude-code-ssh + railway config plan + railway config apply + ``` + +4. Add your SSH key, then continue from [Step 3](#step-3-find-your-ssh-address): + + ```bash + railway variable set SSH_PUBLIC_KEY --stdin --service claude-code-ssh < ~/.ssh/id_ed25519.pub + ``` + +The file exports `partial = "claude-code-ssh"`, so applying it into a project that already has other services only manages this service and its volume. + +### Moving an existing deployment off `railway.json` + +Railway's old Config as Code (`railway.json` / `railway.toml`) is [deprecated](https://docs.railway.com/config-as-code), and those files stop being read on **2026-12-01**. This repo no longer ships `railway.json`. If your service was created while it did, switch it to IaC once: + +```bash +railway link # pick the project and environment +railway config migrate cutover # stop reading railway.json (undo: railway config migrate undo) +railway config plan # review: should only list the settings now owned by IaC +railway config apply +``` + +Keep the volume name in `.railway/railway.ts` (`claude-code-mobile-volume`, the name the template uses) and its `region`/`sizeMB` matching your live volume. Changing any of them replaces the volume, and the plan marks that as destructive. + +--- + ## Mobile Setup ### Termius (iOS / Android) @@ -205,33 +244,37 @@ tmux ls ssh claude@ -p -i ~/.ssh/id_ed25519 -t "tmux attach -t dev || tmux new -s dev" ``` -The included tmux config is tuned for mobile: 256-color support, mouse/touch scrolling, low escape-time for mobile latency, and 50k line scrollback. +The included tmux config is tuned for mobile: true-color support, mouse/touch scrolling, low escape-time for mobile latency, and 50k line scrollback. + +> Railway's [Serverless](https://docs.railway.com/deployments/serverless) (app sleeping) stops idle containers, which ends tmux sessions. Leave it off if you rely on detached sessions. --- ## Environment Variables -| Variable | Required | Default | Description | -| ------------------- | -------- | -------- | -------------------------------------------------------------------------- | -| `SSH_PUBLIC_KEY` | Yes | — | Ed25519 public key(s) for SSH auth. Multiple keys supported, one per line. | -| `ANTHROPIC_API_KEY` | No | — | Claude API key. Alternative to `claude login`. | -| `GITHUB_TOKEN` | No | — | Fine-grained PAT for cloning private repos. Scoped to specific repos only. | -| `NODE_VERSION` | No | `24` | Node.js version (e.g. `22`, `20.18.0`). Uses `n` to switch at boot. | -| `PNPM_VERSION` | No | `latest` | pnpm version (e.g. `9.15.0`, `10`). Uses corepack to switch at boot. | -| `TZ` | No | `UTC` | Timezone (e.g. `America/New_York`, `Europe/London`) | +| Variable | Required | Default | Description | +| ------------------- | -------- | --------- | -------------------------------------------------------------------------- | +| `SSH_PUBLIC_KEY` | Yes | — | Ed25519 public key(s) for SSH auth. Multiple keys supported, one per line. | +| `ANTHROPIC_API_KEY` | No | — | Claude Console API key. Alternative to `claude auth login`. | +| `GITHUB_TOKEN` | No | — | Fine-grained PAT for cloning private repos. Scoped to specific repos only. | +| `NODE_VERSION` | No | `26` | Node.js version (e.g. `24`, `22.22.0`). Uses `n` to switch at boot. | +| `PNPM_VERSION` | No | `12.16.1` | pnpm version (e.g. `11`, `10.34.9`). Installed with npm at boot. | +| `TZ` | No | `UTC` | Timezone (e.g. `America/New_York`, `Europe/London`) | --- ## Security - **Key-only authentication** — passwords completely disabled +- **Post-quantum key exchange** — ML-KEM-768 + X25519 and sntrup761 + X25519 hybrids (OpenSSH 10), Curve25519 and DH Group 16/18 for older clients - **Modern ciphers** — ChaCha20-Poly1305, AES-256-GCM (AEAD only) -- **Modern key exchange** — Curve25519, DH Group 16/18 - **ETM-only MACs** — HMAC-SHA2-512-ETM, HMAC-SHA2-256-ETM - **fail2ban** — 3 failed attempts = 1 hour ban, repeat offenders = 1 week ban +- **OpenSSH per-source penalties** — sshd also throttles addresses that keep failing to authenticate +- **Proxy-aware bans** — behind Railway's TCP proxy every client shares Railway-internal addresses, so those are exempt from bans and penalties (otherwise one scanner would lock everyone out). On Railway, key-only auth is the protection; on a VPS or direct Docker, bans apply to real client IPs. - **Non-root user** — all operations run as unprivileged `claude` user - **DH moduli filtered** — only >= 3071-bit entries -- **sshaudit.com compliant** — passes audit with modern-only algorithms +- **Zero [ssh-audit](https://github.com/jtesta/ssh-audit) failures** — the only warnings are the classic Curve25519/DH fallbacks, kept for older mobile clients --- @@ -243,14 +286,16 @@ The included tmux config is tuned for mobile: 256-color support, mouse/touch scr ssh-keygen -R "[your-host]:port" ``` -**Connection refused**: Ensure TCP Proxy is enabled in Railway on port 22. +**Connection refused**: Check that the TCP Proxy for port 22 exists in **Settings** > **Networking** (the template and `.railway/railway.ts` both create it). **Permission denied (publickey)**: Verify `SSH_PUBLIC_KEY` contains your public key. On mobile, re-export from Termius and update the Railway variable. -**Claude Code not authenticated**: Run `claude login` inside the container, or set `ANTHROPIC_API_KEY`. +**Claude Code not authenticated**: Run `claude auth login` inside the container (check with `claude auth status`), or set `ANTHROPIC_API_KEY`. **tmux session lost**: Sessions persist while the container runs. If the container restarted, start a new session. Your files on `/data` are safe. +**`railway config plan` says the service is managed by `/railway.json`**: Run `railway config migrate cutover` first. See [Moving an existing deployment off `railway.json`](#moving-an-existing-deployment-off-railwayjson). + --- ## Cost @@ -258,7 +303,7 @@ ssh-keygen -R "[your-host]:port" | Platform | Cost | Notes | | ----------------- | --------- | --------------------------------- | | Railway Hobby | ~$5-10/mo | Pay-as-you-go, TCP proxy included | -| Hetzner CX22 | ~$4/mo | 2 vCPU, 4GB RAM, full iptables | +| Any Docker VPS | ~$5+/mo | Hetzner, DigitalOcean, etc. | | Oracle Cloud Free | $0 | ARM A1, always-free tier | See [docs/vps-deployment.md](docs/vps-deployment.md) for VPS and local Docker deployment instructions. diff --git a/docs/architecture.md b/docs/architecture.md index 7f413e6..a0c89f2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,16 +2,16 @@ ## Process Tree -The container uses **s6-overlay v3** as PID 1 for process supervision: +The container uses **s6-overlay v3** as PID 1 for process supervision. Service definitions live in `s6-overlay/s6-rc.d/`; the `user` bundle that starts them lives in `s6-overlay/user-bundles.d/`. ``` s6-overlay (PID 1) ├── init-setup (oneshot) │ └── Volume init, SSH keys, DH moduli filtering, user setup ├── syslog (longrun, depends: init-setup) -│ └── rsyslogd — routes auth logs to /var/log/auth.log for fail2ban +│ └── rsyslogd — writes sshd auth logs to /var/log/auth.log (for fail2ban) and stdout (for Railway logs) ├── sshd (longrun, depends: init-setup) -│ └── Hardened OpenSSH daemon (key-only, modern ciphers) +│ └── Hardened OpenSSH daemon (key-only, post-quantum KEX, modern ciphers) └── fail2ban (longrun, depends: syslog, sshd) └── Monitors auth.log, bans IPs via /etc/hosts.deny (hostsdeny action) ``` diff --git a/docs/railway-template-description.md b/docs/railway-template-description.md index e2b3eb5..0301a3a 100644 --- a/docs/railway-template-description.md +++ b/docs/railway-template-description.md @@ -1,53 +1,57 @@ -# Deploy and Host claude-code-ssh on Railway +# Deploy and Host Claude Code SSH with Railway -Claude Code SSH gives you instant SSH access to Anthropic's Claude Code CLI from any device — phone, tablet, or laptop. Deploy on Railway in minutes and start AI-powered coding from anywhere, with persistent sessions that survive disconnects. +Claude Code SSH puts Anthropic's Claude Code CLI on an always-on Railway service you can reach over SSH from your phone, tablet, or laptop. Sessions survive dropped connections, and your projects, login, and keys live on a persistent volume. Hardened, post-quantum SSH is on by default. -## About Hosting claude-code-ssh +## About Hosting Claude Code SSH -Claude Code is powerful, but it requires a terminal. That's fine at your desk — not so much from your phone on the couch, or your tablet at a coffee shop. Claude Code SSH solves this by packaging Claude Code into a secure, always-on Railway service you can SSH into from any device. Your coding sessions persist through WiFi switches, app closures, and even Railway redeployments. Just reconnect and pick up where you left off. Set one Railway variable (your SSH key), enable the TCP proxy, attach a volume, and you're coding with Claude in under 5 minutes. +Claude Code needs a terminal. That's easy at your desk and awkward everywhere else. Claude Code SSH packages Claude Code into a hardened container that you SSH into from any device. Your tmux sessions keep running through WiFi switches, app closures, and Railway redeploys, so you can reconnect and pick up where you left off. The template already creates the TCP proxy on port 22 and a volume at `/data`. Paste your SSH public key into `SSH_PUBLIC_KEY`, connect with the host and port Railway shows, and run `claude`. Prefer infrastructure as code? The repository describes the same setup in a Railway IaC file, `.railway/railway.ts`. ## Common Use Cases -- **Code from your phone** — Use Claude Code from Termius, Blink Shell, or JuiceSSH with a mobile-optimized terminal experience. Switch WiFi, lock your phone, come back later — your session is still there. -- **AI pair programming from anywhere** — Access Claude Code without installing anything locally. Works with your Claude Pro/Max subscription or API key. -- **Secure remote development** — Clone private repos, build full-stack projects, create PRs — all from an isolated Railway service with hardened SSH and brute-force protection. +- **Code from your phone** — Use Claude Code from Termius, Blink Shell, or JuiceSSH with a mobile-tuned terminal. Lock your phone, come back later, and your session is still there. +- **Long-running agent sessions** — Start a task in tmux, detach, and check on it from another device. +- **AI pair programming from anywhere** — No local install. Works with your Claude subscription or a Claude Console API key. +- **Secure remote development** — Clone private repos with a scoped GitHub token, build full-stack projects, and open PRs from an isolated service. -## Dependencies for claude-code-ssh Hosting +## Dependencies for Claude Code SSH Hosting -- **Claude Code authentication** — A Claude Pro/Max subscription (via `claude login`) or an Anthropic API key -- **SSH key pair** — Ed25519 key for secure access (generate in Termius on mobile or via `ssh-keygen` on desktop) +- **Claude Code authentication** — A Claude Pro, Max, Ultra, Team, or Enterprise subscription (`claude auth login`), or a Claude Console API key +- **SSH key pair** — An Ed25519 key (generate one in Termius on mobile or with `ssh-keygen` on desktop) ### Deployment Dependencies -- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) — Anthropic's official AI coding assistant -- [Railway TCP Proxy](https://docs.railway.com/reference/tcp-proxy) — Required for SSH access to the Railway service -- [Railway Volumes](https://docs.railway.com/reference/volumes) — Persistent storage for projects, auth, and sessions +- [Claude Code](https://code.claude.com/docs) — Anthropic's agentic coding tool +- [Railway TCP Proxy](https://docs.railway.com/networking/tcp-proxy) — Public SSH access on port 22 +- [Railway Volumes](https://docs.railway.com/volumes) — Persistent storage for projects, auth, and host keys +- [Railway Infrastructure as Code](https://docs.railway.com/infrastructure-as-code) — The deployment, defined in `.railway/railway.ts` +- [Source code on GitHub](https://github.com/Ntelikatos/claude-code-ssh) ### Implementation Details -Connect to your Railway service and start coding: +Find HOST and PORT under **Settings > Networking > TCP Proxy**, then connect and start coding: ```bash -ssh claude@ -p -i ~/.ssh/id_ed25519 +ssh claude@HOST -p PORT -i ~/.ssh/id_ed25519 tmux new -s dev claude ``` **What's included out of the box:** -- **Hardened SSH** — Key-only auth, modern ciphers (ChaCha20-Poly1305, AES-256-GCM), sshaudit.com compliant -- **Brute-force protection** — fail2ban bans attackers after 3 failures, repeat offenders blocked for a week -- **Persistent tmux** — Mobile-optimized config with touch scrolling, 256-color support, and 50k line scrollback -- **Node.js 24 + pnpm** — Full-stack ready, with optional version overrides via Railway variables -- **GitHub CLI** — Clone private repos using fine-grained PATs configured through Railway variables -- **Multi-device access** — Add SSH keys from every device you own, connect from anywhere -- **Auto-updating** — Claude Code updates automatically on every Railway redeploy +- **Railway Infrastructure as Code** — Service, Dockerfile build, TCP proxy, volume, and restart policy declared in `.railway/railway.ts`. Preview with `railway config plan`, roll out with `railway config apply`. Secrets stay in Railway variables. +- **Post-quantum SSH** — ML-KEM-768 + X25519 hybrid key exchange (OpenSSH 10), key-only auth, AEAD ciphers, zero ssh-audit failures +- **Key-only access** — No passwords to brute-force. Self-hosting on a VPS? fail2ban and OpenSSH per-source penalties ban attackers too +- **Persistent tmux** — Mobile-tuned config with touch scrolling, true color, and 50k lines of scrollback +- **Node.js 26 LTS + pnpm 12** — On Debian 13, with optional version overrides through Railway variables +- **GitHub CLI** — Clone private repos with fine-grained tokens set as Railway variables +- **Multi-device access** — Add an SSH key for every device you own +- **Self-updating Claude Code** — Installed with Anthropic's native installer, which auto-updates in the background -## Why Deploy claude-code-ssh on Railway? +### Why Deploy Claude Code SSH on Railway? Railway is a singular platform to deploy your infrastructure stack. Railway will host your infrastructure so you don't have to deal with configuration, while allowing you to vertically and horizontally scale it. -By deploying claude-code-ssh on Railway, you are one step closer to supporting a complete full-stack application with minimal burden. Host your servers, databases, AI agents, and more on Railway. +By deploying Claude Code SSH on Railway, you are one step closer to supporting a complete full-stack application with minimal burden. Host your servers, databases, AI agents, and more on Railway. --- diff --git a/docs/vps-deployment.md b/docs/vps-deployment.md index 6517874..46a1569 100644 --- a/docs/vps-deployment.md +++ b/docs/vps-deployment.md @@ -20,24 +20,18 @@ ssh claude@localhost -p 2222 -i ~/.ssh/id_ed25519 ## VPS Deployment (Hetzner, DigitalOcean, Oracle Cloud) -On a VPS you get full `iptables` support for fail2ban. Add `--cap-add=NET_ADMIN`: +fail2ban's default `hostsdeny` ban action works on any host, with no extra capabilities: ```bash docker run -d \ -e SSH_PUBLIC_KEY="$(cat ~/.ssh/id_ed25519.pub)" \ -p 22:22 \ -v claude-data:/data \ - --cap-add=NET_ADMIN \ --name claude-ssh \ claude-code-ssh ``` -To switch fail2ban from `hostsdeny` to `iptables`, edit `/etc/fail2ban/jail.local` inside the container: - -```ini -[DEFAULT] -banaction = iptables-multiport -``` +To ban at the kernel firewall instead, add `nftables` to the apt packages in the `Dockerfile`, set `banaction = nftables` in both jails of `fail2ban/jail.local`, rebuild, and add `--cap-add=NET_ADMIN` to `docker run`. ## Debugging @@ -49,7 +43,7 @@ docker logs -f claude-ssh docker exec -it claude-ssh bash # Check service status -docker exec claude-ssh bash -c 's6-rc -l list' +docker exec claude-ssh /command/s6-rc -a list # Check fail2ban status docker exec claude-ssh bash -c 'fail2ban-client status sshd' diff --git a/fail2ban/jail.local b/fail2ban/jail.local index 2c02f93..16865e8 100644 --- a/fail2ban/jail.local +++ b/fail2ban/jail.local @@ -5,6 +5,9 @@ bantime = 1h findtime = 10m maxretry = 3 allowipv6 = auto +# Never ban Railway-internal addresses: behind Railway's TCP proxy all clients +# share them, so a ban would lock everyone out. Bans apply on VPS / direct Docker. +ignoreip = 127.0.0.1/8 ::1 10.0.0.0/8 100.64.0.0/10 fd00::/8 # Log to file for container visibility logtarget = /var/log/fail2ban.log @@ -12,6 +15,8 @@ logtarget = /var/log/fail2ban.log enabled = true port = 22 filter = sshd[mode=aggressive] +# Debian defaults sshd to the systemd journal, which containers don't have +backend = auto logpath = /var/log/auth.log maxretry = 3 bantime = 3600 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..c69412f --- /dev/null +++ b/package-lock.json @@ -0,0 +1,46 @@ +{ + "name": "claude-code-ssh", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "railway": "^3.16.0" + } + }, + "node_modules/@graphql-typed-document-node/core": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", + "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", + "license": "MIT", + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/graphql": { + "version": "16.14.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.2.tgz", + "integrity": "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==", + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/railway": { + "version": "3.16.0", + "resolved": "https://registry.npmjs.org/railway/-/railway-3.16.0.tgz", + "integrity": "sha512-//vKXubZpO07iJgNyoS+9Ee3pKk+3wE+0KAJPI28GuSKqXG30UeWT4xBkkd6TYuZ4HNh7QPvkVR72Swbmq5kXw==", + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.2.0", + "graphql": "^16.10.0" + }, + "bin": { + "railway-iac-ts": "dist/iac/bin.js" + }, + "engines": { + "node": ">=22" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9f8bd63 --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "railway": "^3.16.0" + } +} diff --git a/railway.json b/railway.json deleted file mode 100644 index 268bcda..0000000 --- a/railway.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "$schema": "https://railway.com/railway.schema.json", - "build": { - "builder": "DOCKERFILE", - "dockerfilePath": "./Dockerfile", - "watchPatterns": [ - "Dockerfile", - "sshd_config", - "banner.txt", - "tmux.conf", - "scripts/**", - "s6-overlay/**", - "fail2ban/**" - ] - }, - "deploy": { - "restartPolicyType": "ON_FAILURE", - "restartPolicyMaxRetries": 5 - } -} diff --git a/s6-overlay/s6-rc.d/sshd/run b/s6-overlay/s6-rc.d/sshd/run index ede3a14..174bcff 100644 --- a/s6-overlay/s6-rc.d/sshd/run +++ b/s6-overlay/s6-rc.d/sshd/run @@ -1,3 +1,3 @@ #!/command/execlineb -P fdmove -c 2 1 -/usr/sbin/sshd -D -e +/usr/sbin/sshd -D diff --git a/s6-overlay/s6-rc.d/user/contents.d/fail2ban b/s6-overlay/user-bundles.d/user/contents.d/fail2ban similarity index 100% rename from s6-overlay/s6-rc.d/user/contents.d/fail2ban rename to s6-overlay/user-bundles.d/user/contents.d/fail2ban diff --git a/s6-overlay/s6-rc.d/user/contents.d/sshd b/s6-overlay/user-bundles.d/user/contents.d/sshd similarity index 100% rename from s6-overlay/s6-rc.d/user/contents.d/sshd rename to s6-overlay/user-bundles.d/user/contents.d/sshd diff --git a/s6-overlay/s6-rc.d/user/contents.d/syslog b/s6-overlay/user-bundles.d/user/contents.d/syslog similarity index 100% rename from s6-overlay/s6-rc.d/user/contents.d/syslog rename to s6-overlay/user-bundles.d/user/contents.d/syslog diff --git a/s6-overlay/s6-rc.d/user/type b/s6-overlay/user-bundles.d/user/type similarity index 100% rename from s6-overlay/s6-rc.d/user/type rename to s6-overlay/user-bundles.d/user/type diff --git a/sshd_config b/sshd_config index 6a7ef5c..377de0d 100644 --- a/sshd_config +++ b/sshd_config @@ -1,7 +1,7 @@ ################################################################################ # Hardened OpenSSH Server Configuration -# Generated: 2026-02-09 -# Standards: sshaudit.com 2025+ guidelines +# Updated: 2026-11-02 (OpenSSH 10, Debian 13 Trixie) +# Standards: ssh-audit.com hardening guidelines + post-quantum key exchange # Security: Modern algorithms only, key-based auth, no root login ################################################################################ @@ -32,9 +32,10 @@ HostKey /etc/ssh/ssh_host_rsa_key ################################################################################ # Key Exchange Algorithms -# curve25519: Modern ECDH, high security +# mlkem768x25519 / sntrup761x25519: Post-quantum hybrid (OpenSSH 10 default) +# curve25519: Modern ECDH, fallback for older clients # diffie-hellman-group16/18: Strong DH groups (4096/8192-bit) -KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org,diffie-hellman-group18-sha512,diffie-hellman-group16-sha512 +KexAlgorithms mlkem768x25519-sha256,sntrup761x25519-sha512,sntrup761x25519-sha512@openssh.com,curve25519-sha256,curve25519-sha256@libssh.org,diffie-hellman-group18-sha512,diffie-hellman-group16-sha512 # Ciphers # ChaCha20-Poly1305: Fast authenticated encryption @@ -61,7 +62,6 @@ PubkeyAuthentication yes # Password authentication disabled (keys only) PasswordAuthentication no -ChallengeResponseAuthentication no KbdInteractiveAuthentication no # PAM disabled (not needed for key-only auth) @@ -80,6 +80,11 @@ LoginGraceTime 30 # Start dropping 50% of new connections after 3, reject all after 10 MaxStartups 3:50:10 +# Per-source penalties (OpenSSH 10 default) throttle addresses that keep failing. +# Behind Railway's TCP proxy every client arrives from Railway-internal addresses +# (CGNAT / IPv6 ULA), so exempt those to avoid locking out all users at once. +PerSourcePenaltyExemptList 10.0.0.0/8,100.64.0.0/10,fd00::/8 + ################################################################################ # Session Management ################################################################################ diff --git a/tmux.conf b/tmux.conf index d5b28f6..32a8534 100644 --- a/tmux.conf +++ b/tmux.conf @@ -3,7 +3,7 @@ # ---------- Terminal & Color Support ------------------------------------------ set -g default-terminal "tmux-256color" -set -ga terminal-overrides ",xterm-256color:Tc" +set -as terminal-features ",xterm-256color:RGB" # ---------- Escape Sequence Handling ------------------------------------------ # CRITICAL: Must be 500+ for SSH connections to prevent Device Attributes