Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ ENV/
.venv*/
.uv-cache*/
.task*/
.test-tmp/
.e2e-tmp/
.test-run.tmp/
.worktrees/

Expand Down
19 changes: 19 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,25 @@ MAX_RECOVERY_JSON_SIZE=4096
# OIDC_ALLOWED_SUBJECTS=
# OIDC_ALLOWED_DOMAINS=example.com
# OIDC_LOGIN_RATE_LIMIT=10 per minute

# Optional LDAP/LDAPS authentication (disabled by default). The ldap:// scheme
# always uses StartTLS; ldaps:// uses TLS from connection start. Both verify the
# server certificate against LDAP_CA_FILE. Store the bind password and CA with
# the documented `docker-compose.ldap.yml` helper instead of placing secrets
# here.
LDAP_ENABLED=false
LDAP_PROVIDER_ID=default
LDAP_URL=
LDAP_BASE_DN=
LDAP_BIND_DN=
LDAP_BIND_PASSWORD_FILE=/run/webssh-auth/ldap_bind_password
LDAP_CA_FILE=/run/webssh-auth/ldap_ca.pem
LDAP_USER_FILTER=
LDAP_UNIQUE_ID_ATTRIBUTE=
LDAP_CONNECT_TIMEOUT=5
LDAP_OPERATION_TIMEOUT=5
LDAP_SESSION_REVALIDATION_SECONDS=300
LDAP_LOGIN_RATE_LIMIT=5 per minute
# Block SSH connections to loopback/link-local addresses (SSRF protection).
# Keep false for homelab use where connecting to internal IPs is intended.
BLOCK_INTERNAL_SSH=false
Expand Down
20 changes: 20 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,11 @@ jobs:
requirements.txt
requirements-test.txt

- name: Install LDAP build dependencies
run: |
sudo apt-get update
sudo apt-get install --yes --no-install-recommends libldap2-dev libsasl2-dev

- name: Install dependencies
run: |
python -m pip install --require-hashes -r requirements-test.txt
Expand Down Expand Up @@ -146,6 +151,11 @@ jobs:
requirements.txt
requirements-test.txt

- name: Install LDAP build dependencies
run: |
sudo apt-get update
sudo apt-get install --yes --no-install-recommends libldap2-dev libsasl2-dev

- name: Install dependencies
run: |
python -m pip install --require-hashes -r requirements-test.txt
Expand Down Expand Up @@ -173,6 +183,11 @@ jobs:
requirements.txt
requirements-test.txt

- name: Install LDAP build dependencies
run: |
sudo apt-get update
sudo apt-get install --yes --no-install-recommends libldap2-dev libsasl2-dev

- name: Install dependencies
run: |
python -m pip install --require-hashes -r requirements-test.txt
Expand Down Expand Up @@ -200,6 +215,11 @@ jobs:
requirements.txt
requirements-test.txt

- name: Install LDAP build dependencies
run: |
sudo apt-get update
sudo apt-get install --yes --no-install-recommends libldap2-dev libsasl2-dev

- name: Install Python dependencies
run: |
python -m pip install --require-hashes -r requirements-test.txt
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ plans/

# Knowledge graph
graphify-out/
.test-tmp/
.e2e-tmp/
AGENTS.md
.codex/hooks.json

Expand Down
30 changes: 27 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,3 +1,19 @@
FROM python:3.14-slim@sha256:a7fb1e634c4a578f9e0bd6327f11a3cde11b7a9395f48e24360c0988bcc5c2bc AS ldap-builder

WORKDIR /build

RUN apt-get update \
&& apt-get install --yes --no-install-recommends \
build-essential \
libldap-dev \
libsasl2-dev \
&& rm -rf /var/lib/apt/lists/*

COPY requirements.txt /build/
RUN pip install --no-cache-dir --require-hashes \
--prefix=/install -r requirements.txt


FROM python:3.14-slim@sha256:a7fb1e634c4a578f9e0bd6327f11a3cde11b7a9395f48e24360c0988bcc5c2bc

ARG VCS_REF=unknown
Expand All @@ -21,19 +37,27 @@ WORKDIR /app
RUN adduser --disabled-password --gecos "" appuser

COPY requirements.txt /app/
RUN pip install --no-cache-dir -r requirements.txt \
COPY --from=ldap-builder /install /usr/local
RUN apt-get update \
&& apt-get install --yes --no-install-recommends \
ca-certificates \
libldap2 \
libsasl2-2 \
&& rm -rf /var/lib/apt/lists/* \
&& python -m pip check \
&& python -m pip uninstall --yes pip \
&& rm -rf /usr/local/lib/python*/ensurepip

COPY . /app

RUN chown -R appuser:appuser /app && \
mkdir -p /app/data/logs /app/data/keys && \
mkdir -p /app/data/logs /app/data/keys /run/webssh-auth && \
chown -R appuser:appuser /app/data && \
chown appuser:appuser /run/webssh-auth && \
chmod 700 /app/data && \
chmod 700 /app/data/logs && \
chmod 700 /app/data/keys
chmod 700 /app/data/keys && \
chmod 700 /run/webssh-auth

COPY entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh
Expand Down
92 changes: 92 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ WebSSH is a secure, self-hosted workspace for SSH terminals and SFTP file operat
- **Host Key Auditing** - Persistent `known_hosts` policy with change detection
- **Host Trust Center** - Users can inspect and revoke their SSH trust records; administrators manage the global trust store
- **Passkeys** - Optional username-less WebAuthn sign-in with discoverable credentials and a safe legacy-passkey replacement flow
- **LDAP / Active Directory** - Optional fail-closed LDAP or LDAPS sign-in with explicit stable-identity linking, strict TLS verification, and an opt-in Compose overlay
- **Recovery Codes** - One-time account recovery codes stored only as hashes
- **OpenID Connect** - Optional authorization-code flow with PKCE and explicit administrator linking by stable issuer and subject
- **Audit Logging & Export** - Structured JSON logs for auth, SSH, and file events, plus bounded administrator export and configurable retention
Expand Down Expand Up @@ -495,6 +496,19 @@ docker build -t webssh:local .
| `OIDC_ALLOWED_SUBJECTS` | No | - | Optional comma-separated subject allowlist |
| `OIDC_ALLOWED_DOMAINS` | No | - | Optional comma-separated email-domain policy; identity linking still uses issuer and subject only |
| `OIDC_LOGIN_RATE_LIMIT` | No | `10 per minute` | Per-IP rate limit for starting OIDC login |
| `LDAP_ENABLED` | No | `false` | Enable optional LDAP/LDAPS authentication; the provided `docker-compose.ldap.yml` overlay sets this to `true` |
| `LDAP_PROVIDER_ID` | With LDAP | `default` | Stable local identifier for this directory; do not change it after linking users |
| `LDAP_URL` | With LDAP | - | Exact `ldap://host:port` (mandatory StartTLS) or `ldaps://host:port` URL |
| `LDAP_BASE_DN` | With LDAP | - | Subtree base for directory user searches |
| `LDAP_BIND_DN` | With LDAP | - | DN of the least-privilege read-only search account |
| `LDAP_BIND_PASSWORD_FILE` | With LDAP | `/run/webssh-auth/ldap_bind_password` | Private bind-password file populated through the bundled helper |
| `LDAP_CA_FILE` | With LDAP | `/run/webssh-auth/ldap_ca.pem` | PEM CA bundle used for mandatory server-certificate verification |
| `LDAP_USER_FILTER` | With LDAP | - | LDAP filter containing exactly one `{username}` placeholder |
| `LDAP_UNIQUE_ID_ATTRIBUTE` | With LDAP | - | Stable identity attribute, normally `entryUUID` or `objectGUID` |
| `LDAP_CONNECT_TIMEOUT` | No | `5` | Bounded LDAP connect/bind timeout in seconds (1-15) |
| `LDAP_OPERATION_TIMEOUT` | No | `5` | Bounded LDAP operation timeout in seconds (1-30) |
| `LDAP_SESSION_REVALIDATION_SECONDS` | No | `300` | Fail-closed revalidation interval for active LDAP sessions (60-3600) |
| `LDAP_LOGIN_RATE_LIMIT` | No | `5 per minute` | Per-IP LDAP login and diagnostic limit |
| `ADMIN_USERS` | No | - | Compatibility option: comma-separated existing usernames granted admin on startup. Prefer `create-admin` for explicit bootstrap |
| `ADMIN_PANEL_ENABLED` | No | `True` | Expose the role-gated Admin Panel and its API routes |
| `SESSION_TIMEOUT` | No | `1800` | Idle SSH session timeout in seconds (30 minutes) |
Expand Down Expand Up @@ -531,6 +545,83 @@ provider's stable `(issuer, subject)` identity to an existing local account.
When OIDC runs in Docker, mount the client-secret file read-only and point
`OIDC_CLIENT_SECRET_FILE` at its path inside the container.

#### Enable LDAP or LDAPS with Docker Compose

LDAP is disabled by default. A normal `docker compose up -d` creates no LDAP
volume, mount, or helper service. Enabling it does not require a `.env` file or
a hand-written secret mount: use the supplied `docker-compose.ldap.yml` overlay
and keep it on the same WebSSH release or commit as `docker-compose.yml`.

1. Edit `docker-compose.ldap.yml` and fill in the required values under
`services.webssh.environment`. For example:

```yaml
LDAP_ENABLED: "true"
LDAP_PROVIDER_ID: primary-directory
LDAP_URL: ldaps://ldap.example.com:636
LDAP_BASE_DN: ou=people,dc=example,dc=com
LDAP_BIND_DN: cn=svc-webssh,ou=services,dc=example,dc=com
LDAP_USER_FILTER: "(&(objectClass=inetOrgPerson)(uid={username}))"
LDAP_UNIQUE_ID_ATTRIBUTE: entryUUID
```

For Active Directory, the usual filter attribute is `sAMAccountName` and
the stable ID is `objectGUID`. For OpenLDAP, they are commonly `uid` and
`entryUUID`. Use a dedicated, least-privilege, read-only bind account. Keep
`LDAP_PROVIDER_ID` stable after users have been linked.

2. Choose one encrypted transport. `ldap://ldap.example.com:389` means
mandatory StartTLS before any bind; `ldaps://ldap.example.com:636` starts TLS
immediately. WebSSH rejects plaintext LDAP and invalid certificates. The
DNS name in `LDAP_URL` must match the server certificate.

3. Store the bind password and the issuing CA certificate in WebSSH's managed
secret volume. The password is prompted without being placed in Compose or
shell history:

```bash
docker compose -f docker-compose.yml -f docker-compose.ldap.yml --profile ldap-tools run --rm ldap-tools set-password
docker compose -f docker-compose.yml -f docker-compose.ldap.yml --profile ldap-tools run --rm -T ldap-tools install-ca --stdin < company-ca.pem
docker compose -f docker-compose.yml -f docker-compose.ldap.yml --profile ldap-tools run --rm ldap-tools status
```

PowerShell users can install the same PEM-encoded CA with:

```powershell
Get-Content -Raw .\company-ca.pem | docker compose -f docker-compose.yml -f docker-compose.ldap.yml --profile ldap-tools run --rm -T ldap-tools install-ca --stdin
```

4. Start WebSSH with the LDAP overlay:

```bash
docker compose -f docker-compose.yml -f docker-compose.ldap.yml up -d
```

For the production reverse-proxy profile, apply the production overlay last:

```bash
docker compose -f docker-compose.yml -f docker-compose.ldap.yml -f docker-compose.production.yml up -d
```

5. Sign in with the existing local break-glass administrator. In **Admin**,
create or select a non-admin WebSSH account and use **Link LDAP** to attach
the directory's stable identity. WebSSH deliberately does not auto-provision
accounts and never grants administrator rights through LDAP. Test **Sign in
with LDAP** before relying on it.

To disable LDAP again, recreate WebSSH from the standard Compose file only:

```bash
docker compose -f docker-compose.yml up -d --force-recreate
```

The LDAP UI and routes then disappear. Existing users and links remain stored,
but a linked account does not regain an old local password as a fallback. Keep
the local break-glass administrator and its recovery material available. See
[Optional LDAP and Active Directory authentication](docs/ldap-authentication.md)
for complete Active Directory/OpenLDAP filters, certificate requirements,
troubleshooting, rollback details, and the disposable local test laboratory.

Passkey sign-in uses username-less discoverable credentials so the
authentication-options endpoint does not reveal whether an account exists.
Passkeys created by an older release as non-discoverable credentials cannot be
Expand Down Expand Up @@ -1186,6 +1277,7 @@ webssh/
├── start.py # Native and Gunicorn entry point
├── Dockerfile # Non-root production image
├── docker-compose.yml # Zero-config homelab deployment
├── docker-compose.ldap.yml # Optional LDAP/AD overlay and secret helper
└── docker-compose.production.yml # Strict reverse-proxy overlay
```

Expand Down
Loading
Loading