Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 97 additions & 30 deletions api.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* websh — PHP proxy.
*
* Forwards AJAX requests from the browser to the Python REST API
* running on 127.0.0.1. This allows all traffic to flow through
* over a Unix socket. This allows all traffic to flow through
* the hosting provider's HTTPS — no exposed ports, no WebSocket.
*
* Compatible with PHP 5.3+ and requires only the curl extension.
Expand All @@ -28,23 +28,43 @@
}

if (!extension_loaded('curl')) {
header('HTTP/1.1 500 Internal Server Error');
echo '{"error":"PHP curl extension is required"}';
exit;
config_error('PHP curl extension is required');
}
if (!defined('CURLOPT_UNIX_SOCKET_PATH')) {
config_error('PHP curl lacks Unix socket support (needs PHP 7.0.7+ and libcurl 7.40+)');
}

if ($action !== 'stream') {
header('Content-Type: application/json');
header('Cache-Control: no-cache, no-store, must-revalidate');
}

$BACKEND = 'http://127.0.0.1:' . (getenv('WEBSH_PORT') ?: '8765');

// Path to config file (must be OUTSIDE the web root for security).
$WEBSH_CONFIG = getenv('WEBSH_CONFIG') ?: dirname(__FILE__) . '/../../websh.json';

// The backend listens on a Unix socket, not a TCP port: on shared
// hosting 127.0.0.1 is common to every account, so any co-tenant could
// reach a TCP backend past the web server's auth (and use the
// server-side connections in websh.json), or take the port while the
// backend is down and receive what users type. server.py creates the
// socket 0600 next to websh.json, outside the web root, so only this
// account can connect to it or create it there.
$config_dir = @realpath(dirname($WEBSH_CONFIG)) ?: dirname($WEBSH_CONFIG);
$WEBSH_SOCKET = trim((string) getenv('WEBSH_SOCKET')) ?: $config_dir . '/websh.sock';
// sun_path holds 107 bytes; past that bind() and connect() just fail.
if (strlen($WEBSH_SOCKET) > 107) {
config_error('backend socket path is too long; set WEBSH_SOCKET to a '
. 'shorter path in a directory only this account can write');
}
$problem = socket_path_problem($WEBSH_SOCKET);
if ($problem !== '') {
config_error($problem);
}
// Only the Host header sees this; the connection goes to the socket.
$BACKEND = 'http://localhost';

// Auto-start: launch server.py if it's not running.
ensure_backend($BACKEND, $WEBSH_CONFIG);
ensure_backend($BACKEND, $WEBSH_CONFIG, $WEBSH_SOCKET);

switch ($action) {
case 'config': proxy_get($BACKEND . '/api/config'); break;
Expand Down Expand Up @@ -112,15 +132,48 @@

// ── Helpers ──────────────────────────────────────────────────────────

// A deployment problem only the operator can fix: 500 with the reason.
function config_error($msg) {
http_response_code(500);
header('Content-Type: application/json');
echo json_encode(array('error' => $msg));
exit;
}

// A socket path other accounts can create files at is a trap: a
// co-tenant plants a socket there first and receives what users type.
// So the directory must belong to this account and be closed to other
// users' writes, and an existing socket must be ours. Returns the
// problem, or '' when the path is safe.
function socket_path_problem($socket) {
$uid = function_exists('posix_geteuid') ? posix_geteuid() : getmyuid();
$gid = function_exists('posix_getegid') ? posix_getegid() : getmygid();
$dir = @stat(dirname($socket));
if (!$dir) {
return 'cannot access the backend socket directory (open_basedir?); '
. 'set WEBSH_SOCKET to a path in a directory this account owns';
}
if ($dir['uid'] !== $uid || ($dir['mode'] & 0002)
|| (($dir['mode'] & 0020) && $dir['gid'] !== $gid)) {
return 'the backend socket directory must belong to this account '
. 'and be writable only by it; set WEBSH_SOCKET';
}
$owner = @fileowner($socket);
if ($owner !== false && $owner !== $uid) {
return 'the backend socket belongs to another user';
}
return '';
}

// Headers every proxied backend request must carry. The X-Forwarded-For
// is the load-bearing one: all backend calls originate from 127.0.0.1
// (this PHP process), so without it the server sees every browser client
// is the load-bearing one: all backend calls come from this PHP process
// over the socket, so without it the server sees every browser client
// as the loopback address and the entire per-IP defense layer collapses
// to a single shared bucket — one client exhausts RATE_LIMIT_MAX for
// everyone, MAX_SESSIONS_PER_IP caps all users combined, and the access
// log records 127.0.0.1 for every connect. The backend trusts only the
// FIRST token of X-Forwarded-For from a trusted proxy (loopback by
// default), so we send exactly one token and OVERWRITE rather than
// FIRST token of X-Forwarded-For from a trusted proxy (any client of
// its socket), so we send exactly one token and OVERWRITE rather than
// append. REMOTE_ADDR is the real client on the typical shared-hosting
// layout where PHP faces the browser directly; if PHP itself sits behind
// another proxy, configure that proxy to populate REMOTE_ADDR.
Expand All @@ -132,6 +185,17 @@ function backend_headers($extra = array()) {
return $h;
}

// Every backend request goes over the socket.
function backend_curl($url) {
global $WEBSH_SOCKET;
$ch = curl_init($url);
if (!curl_setopt($ch, CURLOPT_UNIX_SOCKET_PATH, $WEBSH_SOCKET)) {
// libcurl built without Unix sockets: never fall back to TCP.
config_error('PHP curl cannot connect to Unix sockets');
}
return $ch;
}

// curl_close() only frees the handle before PHP 8.0; since then handles
// are objects freed when they go out of scope, and 8.5 deprecates the
// call. Hosts with display_errors on would print that notice into the
Expand All @@ -142,7 +206,7 @@ function release_curl($ch) {
}

function ping_backend($backend) {
$ch = curl_init($backend . '/api/ping');
$ch = backend_curl($backend . '/api/ping');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 2);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 1);
Expand All @@ -153,7 +217,7 @@ function ping_backend($backend) {

function proxy_post($url) {
$body = file_get_contents('php://input');
$ch = curl_init($url);
$ch = backend_curl($url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
Expand All @@ -174,7 +238,7 @@ function proxy_post($url) {
}

function proxy_get($url) {
$ch = curl_init($url);
$ch = backend_curl($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, backend_headers());
curl_setopt($ch, CURLOPT_TIMEOUT, 50);
Expand All @@ -201,7 +265,7 @@ function proxy_upload($url) {
return;
}

$ch = curl_init($url);
$ch = backend_curl($url);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
curl_setopt($ch, CURLOPT_UPLOAD, true);
curl_setopt($ch, CURLOPT_INFILE, $in);
Expand Down Expand Up @@ -232,7 +296,7 @@ function proxy_download($url) {
@ob_implicit_flush(true);

$sent_headers = false;
$ch = curl_init($url);
$ch = backend_curl($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
curl_setopt($ch, CURLOPT_HTTPHEADER, backend_headers());
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5);
Expand Down Expand Up @@ -277,7 +341,7 @@ function ($ch, $header) use (&$sent_headers) {
// DELETE helper for /api/save. Mirrors proxy_post/proxy_get by
// forwarding the backend's status code, including 204 No Content.
function proxy_delete($url) {
$ch = curl_init($url);
$ch = backend_curl($url);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, backend_headers());
Expand Down Expand Up @@ -316,7 +380,7 @@ function proxy_stream($url) {
@ob_implicit_flush(true);

$sent_headers = false;
$ch = curl_init($url);
$ch = backend_curl($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
curl_setopt($ch, CURLOPT_HEADER, false);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5);
Expand Down Expand Up @@ -369,13 +433,19 @@ function ($ch, $header) use (&$sent_headers) {
}
}

function ensure_backend($backend, $config_path) {
function ensure_backend($backend, $config_path, $socket) {
if (ping_backend($backend)) return;

// Lock to prevent double-start.
$lock = fopen(sys_get_temp_dir() . '/websh_start.lock', 'c');
if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) {
if ($lock) fclose($lock);
// Lock to prevent double-start. It sits next to the socket, not in
// the system temp dir: with a shared /tmp every account on the host
// would contend for one lock file owned by whoever created it.
$lock = @fopen($socket . '.lock', 'c');
if (!$lock) {
config_error('cannot create the auto-start lock next to the backend '
. 'socket; its directory must be writable by this account');
}
if (!flock($lock, LOCK_EX | LOCK_NB)) {
fclose($lock);
usleep(1500000);
ping_backend($backend);
return;
Expand All @@ -386,15 +456,12 @@ function ensure_backend($backend, $config_path) {

$script = dirname(__FILE__) . '/server.py';
if (file_exists($script)) {
// Pass PORT so the spawned server.py binds the SAME port this proxy
// pings ($BACKEND uses WEBSH_PORT). Without it the backend always
// binds its own default 8765, so on a non-default WEBSH_PORT the
// ping never succeeds, every request re-execs python3, and the
// browser sees permanent 502s.
// WEBSH_SOCKET makes the spawned server.py listen on the socket
// this proxy connects to instead of a loopback TCP port.
$cmd = sprintf(
'WEBSH_CONFIG=%s PORT=%s nohup python3 %s </dev/null >/dev/null 2>&1 &',
'WEBSH_CONFIG=%s WEBSH_SOCKET=%s nohup python3 %s </dev/null >/dev/null 2>&1 &',
escapeshellarg($config_path),
escapeshellarg(getenv('WEBSH_PORT') ?: '8765'),
escapeshellarg($socket),
escapeshellarg($script)
);
exec($cmd);
Expand Down
17 changes: 9 additions & 8 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,16 @@ below — including the `WEBSH_` prefix where the table has one, e.g.
`1`/`true` (env string or JSON literal).

Env-only exceptions: `WEBSH_CONFIG` (it locates the JSON), and
`HOST` / `TRUSTED_PROXIES` — a writable `websh.json` must not be able
to rebind the loopback-only server to a public interface or take over
X-Forwarded-For trust.
`HOST` / `TRUSTED_PROXIES` / `WEBSH_SOCKET` — a writable `websh.json`
must not be able to rebind the loopback-only server to a public
interface or a path of its choosing, or take over X-Forwarded-For
trust.

| Variable | Default | Description |
|---|---|---|
| `PORT` | `8765` | Listen port |
| `HOST` | `127.0.0.1` | Bind address |
| `WEBSH_SOCKET` | *(unset)* | Listen on this Unix socket path instead of `HOST`:`PORT`. The socket is created `0600`, so only the websh user can connect; clients on it are trusted like `TRUSTED_PROXIES`. Its directory must belong to the websh user and not be writable by others (no `/tmp`). The PHP proxy always uses one (default `websh.sock` next to `websh.json`) — see [`deployment.md`](deployment.md#shared-hosting-php--python). |
| `SESSION_TIMEOUT` | `300` | Idle timeout in seconds |
| `MAX_SESSIONS` | `50` | Max concurrent SSH sessions |
| `MAX_SESSIONS_PER_IP` | `0` | Max concurrent sessions per source IP (`0` disables; counts foreground + background together) |
Expand Down Expand Up @@ -61,8 +63,7 @@ X-Forwarded-For trust.
| `WEBSH_RECORD_DIR` | *(unset)* | Opt-in session recording: directory for one [asciicast v2](https://docs.asciinema.org/manual/asciicast/v2/) `.cast` file per session (created `0600`, named `<timestamp>-<sid>.cast`; replayable with `asciinema play`). Output-only by default. Best-effort: a write failure disables recording for that session, never the session itself. Mind retention/privacy — see [`security.md`](security.md#session-recording). |
| `WEBSH_RECORD_INPUT` | `0` | With recording on, `1` also records keystrokes (`"i"` events). **Everything typed into the remote shell lands in the file — including passwords typed at prompts inside the session.** The browser-form ssh password is not recorded as input (the auto-type bypasses the tee); see the echo caveat in [`security.md`](security.md#session-recording). |

The PHP proxy reads `WEBSH_PORT` (default `8765`) to find the backend —
and since the alias rule above makes `server.py` honor `WEBSH_PORT`
too, in the shared-hosting flow (where the auto-started backend
inherits PHP's environment) that one variable points both sides at the
same port. Under systemd the unit's `PORT=` still pins the backend.
The PHP proxy talks to the backend over `WEBSH_SOCKET` (default
`websh.sock` next to `websh.json`) and starts `server.py` with that
same value, so `PORT` and `HOST` play no part in the shared-hosting
flow. Under systemd the unit's `PORT=` still pins the backend.
33 changes: 28 additions & 5 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ A typical shared-hosting directory layout:
/home/user/
example.com/ <- site root
websh.json <- config (OUTSIDE www — not accessible via HTTP)
websh.sock <- backend socket (created by server.py)
www/ <- web root (public)
console/
index.html <- frontend
Expand All @@ -27,15 +28,27 @@ A typical shared-hosting directory layout:

That's it. `api.php` starts `server.py` automatically on the first request.

The backend listens on a Unix socket, `websh.sock`, next to `websh.json`,
not on a TCP port. On shared hosting `127.0.0.1` is shared by every account
on the server, so a port would let other customers reach the backend
around your web server's authentication. The socket is created with
owner-only permissions, so only your account can connect to it — provided
PHP runs as your account (suPHP, per-user PHP-FPM pools, LSAPI: the usual
shared-hosting setup). Override the path with `WEBSH_SOCKET`: under 108
bytes, in a directory that belongs to your account and that no one else
can write to — never `/tmp`. Both `api.php` and `server.py` refuse a
directory other users could plant a socket in.

> **Path details:** `api.php` looks for `websh.json` two directories up from itself
> (i.e. the site root, above `www/`). This works for most hosting providers.
> If your layout is different, set the `WEBSH_CONFIG` environment variable
> or edit the `$WEBSH_CONFIG` default near the top of `api.php`.

For manual control (e.g. custom config path):
For manual control (e.g. custom config path), start it on the socket
`api.php` connects to:

```bash
WEBSH_CONFIG=/path/to/websh.json nohup python3 server.py &
WEBSH_CONFIG=/path/to/websh.json WEBSH_SOCKET=/path/to/websh.sock nohup python3 server.py &
```

### Troubleshooting
Expand All @@ -50,9 +63,19 @@ WEBSH_CONFIG=/path/to/websh.json nohup python3 server.py &
- Set `WEBSH_CONFIG=/full/path/to/websh.json` environment variable if your layout differs
- Check JSON syntax: `python3 -c "import json; json.load(open('websh.json'))"`

**Port already in use:**
- Another instance of `server.py` may be running: `ps aux | grep server.py`
- Change the port: `PORT=8766 python3 server.py`
**Upgrading from a version that listened on a TCP port:**
- The old backend keeps running on `127.0.0.1:8765` (or your
`WEBSH_PORT`), reachable by anyone on the server, until it is stopped:
`pkill -f server.py` (the next request starts the new one), or ask your
host to restart your processes.

**An error about the backend socket or its directory:**
- The directory holding `websh.sock` (by default the one with `websh.json`)
must belong to your account and be writable by it alone; with
`open_basedir`, it must also be inside the allowed paths. Otherwise point
`WEBSH_SOCKET` at such a directory.
- A stale `websh.sock` left by a crashed backend is replaced automatically;
a regular file at that path is not — remove it.

## Python only (no PHP)

Expand Down
9 changes: 8 additions & 1 deletion docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ It is meant to be lightweight — add access control at the web server level:
- **Cloudflare Access**, **Tailscale Funnel**, or similar zero-trust tools
- IP allowlisting via firewall rules

That only holds if nothing reaches the backend around the web server.
On shared hosting `127.0.0.1` is common to every account on the server,
so the PHP proxy runs the backend on an owner-only Unix socket
(`WEBSH_SOCKET`) instead of a loopback port. With the default TCP bind,
run websh on a machine you don't share.

## SSH host keys

The backend connects with `StrictHostKeyChecking=no` by default to avoid
Expand Down Expand Up @@ -200,7 +206,8 @@ once** (an appending proxy would otherwise let a client-smuggled first
value win), and reads it only from `TRUSTED_PROXIES` peers. Two
operational notes: with the defaults (`HOST=127.0.0.1`,
`TRUSTED_PROXIES=127.0.0.1`) any local process on a shared box can
mint identities — inherent to loopback trust; and enable
mint identities — inherent to loopback trust, which `WEBSH_SOCKET`
avoids; and enable
`WEBSH_AUTH_HEADER` at startup only — sessions are stamped at creation,
so flipping it on over a live registry orphans existing sessions
(deny-by-default, by design). Cross-user probes are recorded in the
Expand Down
Loading