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
46 changes: 43 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -198,10 +198,16 @@ TMUX_SESSION_PREFIX=webssh
TAILSCALE_SSH_ENABLED=false
# Non-admin WebSSH usernames allowed to use the shared identity (comma-separated).
TAILSCALE_SSH_ALLOWED_WEBSSH_USERS=
# Optional exact target host/IP allowlist. Empty means no additional host filter.
# Required exact target/port allowlist when Tailscale SSH is enabled. A bare
# hostname, IPv4, or IPv6 address means port 22; use hostname:port, IPv4:port,
# or [IPv6]:port for another port. Production rejects malformed entries;
# homelab warns, ignores only those entries, and otherwise fails closed.
TAILSCALE_SSH_ALLOWED_TARGETS=
# Optional exact remote OS username allowlist. Empty means no additional filter.
TAILSCALE_SSH_ALLOWED_REMOTE_USERS=
# Kernel interface that every selected target address must route through. An
# empty value is rejected in production and makes homelab connections fail closed.
TAILSCALE_SSH_INTERFACE=tailscale0

# ─── Rate limiting ───────────────────────────────────────────────────────────
RATELIMIT_ENABLED=True
Expand All @@ -218,6 +224,16 @@ SSH_KEY_MAX_RECORDS=100
SSH_KEY_STORE_MAX_BYTES=8388608
# Per-user command and command-set mutation rate.
# COMMAND_MUTATION_RATELIMIT=60 per minute
# Per-user profile and jump-host mutation rate.
# CONNECTION_MUTATION_RATELIMIT=60 per minute
# Persistent saved-connection limits. Existing oversized stores are quarantined
# from normal UI/runtime access. They are readable and reducible only through
# the bounded offline `connection-store` Flask CLI while every WebSSH process
# using DATA_DIR is stopped; they cannot grow.
# PROFILE_MAX_RECORDS=500
# JUMP_HOST_MAX_RECORDS=100
# CONNECTION_STORE_MAX_BYTES=2097152
# CONNECTION_CONFIG_MAX_BYTES=4194304
# Storage backend for rate-limit counters.
# memory:// (default) — per-process, no external dependency.
# redis://host:port/db — counters survive app restarts while Redis is running.
Expand Down Expand Up @@ -253,8 +269,30 @@ MAX_PREVIEW_TAIL_LINES=10000
MAX_SUPPORTED_FILE_SIZE=1073741824
# Timeout for one SFTP channel operation in seconds.
SFTP_OPERATION_TIMEOUT=30
# Maximum declared SFTP protocol packet accepted before its body is read.
SFTP_MAX_PACKET_BYTES=1048576
# Maximum opaque SFTP directory-handle length accepted from a server.
SFTP_MAX_HANDLE_BYTES=16384
# Maximum UTF-8 bytes per remote filename/longname and per file-control path.
REMOTE_FILENAME_MAX_BYTES=4096
FILE_CONTROL_MAX_PATH_BYTES=4096
# Aggregate metadata and pagination limits for one SFTP directory listing.
REMOTE_LISTING_MAX_METADATA_BYTES=4194304
REMOTE_LISTING_PAGE_SIZE=500
# Per-user rolling byte budget for file-control metadata.
FILE_CONTROL_BYTES_PER_MINUTE=2097152
# Maximum text file size accepted by the inline editor (5 MiB).
MAX_EDITOR_FILE_SIZE=5242880
# Per-user rolling byte budget for accepted inline-editor save bodies (20 MiB).
EDITOR_SAVE_BYTES_PER_MINUTE=20971520
# Short-lived, bounded directory snapshots keep remote pagination stable and
# prevent every page from repeating the complete SFTP/SMB listing.
REMOTE_LISTING_SNAPSHOT_TTL_SECONDS=60
REMOTE_LISTING_SNAPSHOT_MAX_STATES=8
REMOTE_LISTING_SNAPSHOT_MAX_PER_USER=4
# Hard pre-parse ceiling used only by that bounded offline recovery CLI.
CONNECTION_STORE_RECOVERY_MAX_BYTES=16777216
CONNECTION_STORE_RECOVERY_MAX_RECORDS=10000
# Local disk used only for bounded fallback ZIP creation (default: DATA_DIR/tmp)
# TRANSFER_TEMP_DIR=/var/lib/webssh/tmp

Expand All @@ -269,12 +307,14 @@ BACKUP_MAX_FILE_SIZE=1073741824
BACKUP_MAX_TOTAL_SIZE=10737418240
BACKUP_MAX_COMPRESSION_RATIO=200
BACKUP_MAX_MANIFEST_SIZE=10485760
# Native Admin backup and restore are always available to administrators.
# Operational limits only; BACKUP_TEMP_DIR is a shared base outside DATA_DIR.
# Native Admin backup is available to administrators. Online restore additionally
# requires a private, durable BACKUP_TEMP_DIR outside DATA_DIR and an explicit
# durability acknowledgement. Ephemeral /tmp is intentionally insufficient.
BACKUP_UPLOAD_MAX_SIZE=1073741824
BACKUP_OPERATION_TIMEOUT=1800
BACKUP_DOWNLOAD_TTL=600
# BACKUP_TEMP_DIR=/tmp/webssh-backup-operations
# BACKUP_RECOVERY_DURABLE=false
RATELIMIT_BACKUP_CREATE=3 per hour
RATELIMIT_BACKUP_UPLOAD=5 per hour
RATELIMIT_BACKUP_DOWNLOAD=10 per hour
Expand Down
108 changes: 92 additions & 16 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,30 @@ jobs:
include:
- python_version: '3.14'
check_name: pytest
pytest_targets: tests/
pytest_ignores: --ignore=tests/integration
- python_version: '3.11'
check_name: pytest (Python 3.11 minimum)
# The production runtime runs the complete suite above. The
# documented minimum version keeps a representative compatibility
# gate across startup, authentication, storage migrations, remote
# protocols, pagination, and threaded transport admission.
pytest_targets: >-
tests/test_admin_cli.py
tests/test_auth.py
tests/test_entrypoint.py
tests/test_factor_bootstrap.py
tests/test_file_service.py
tests/test_gunicorn_command.py
tests/test_ldap_auth.py
tests/test_network_policy.py
tests/test_production_config.py
tests/test_sftp_handler.py
tests/test_smb_protocol_contract.py
tests/test_ssh_manager.py
tests/test_storage_migrations.py
tests/test_threaded_runtime.py
pytest_ignores: ''
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
Expand All @@ -97,8 +119,9 @@ jobs:

- name: Run tests
run: >-
pytest tests/
--ignore=tests/integration
python -m compileall -q app tests &&
pytest ${{ matrix.pytest_targets }}
${{ matrix.pytest_ignores }}
-q
-n 2
--dist=loadscope
Expand Down Expand Up @@ -311,8 +334,10 @@ jobs:
run: |
image_tag="webssh-ci:${GITHUB_SHA}"
container_name="webssh-ci-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
recovery_volume="webssh-ci-recovery-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
ownership_label="webssh.ci-run=${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
ownership_value="${ownership_label#*=}"
recovery_volume_created=0

cleanup_start_failure() {
status=$?
Expand All @@ -321,7 +346,16 @@ jobs:
if [ "$status" -ne 0 ] && docker container inspect "$container_name" >/dev/null 2>&1; then
if actual_label="$(docker container inspect --format '{{ index .Config.Labels "webssh.ci-run" }}' "$container_name" 2>/dev/null)"; then
if [ "$actual_label" = "$ownership_value" ]; then
docker rm --force "$container_name" || true
docker rm --force --volumes "$container_name" || true
fi
fi
fi

if [ "$status" -ne 0 ] && [ "$recovery_volume_created" = "1" ] \
&& docker volume inspect "$recovery_volume" >/dev/null 2>&1; then
if actual_label="$(docker volume inspect --format '{{ index .Labels "webssh.ci-run" }}' "$recovery_volume" 2>/dev/null)"; then
if [ "$actual_label" = "$ownership_value" ]; then
docker volume rm "$recovery_volume" || true
fi
fi
fi
Expand All @@ -334,43 +368,75 @@ jobs:
echo "Refusing to reuse pre-existing container $container_name" >&2
exit 1
fi
if docker volume inspect "$recovery_volume" >/dev/null 2>&1; then
echo "Refusing to reuse pre-existing volume $recovery_volume" >&2
exit 1
fi

docker build --build-arg VCS_REF="${GITHUB_SHA}" --tag "$image_tag" .
image_revision="$(docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}' "$image_tag")"
if [ "$image_revision" != "$GITHUB_SHA" ]; then
echo "Built image revision does not match the checkout" >&2
exit 1
fi
docker volume create \
--label "$ownership_label" \
"$recovery_volume" >/dev/null
recovery_volume_created=1
docker run --detach --name "$container_name" \
--label "$ownership_label" \
--env SECRET_KEY="container-smoke-test-secret" \
--env BACKUP_TEMP_DIR=/app/recovery \
--env BACKUP_RECOVERY_DURABLE=true \
--mount type=volume,source="$recovery_volume",target=/app/recovery \
--publish 127.0.0.1::5000 \
"$image_tag"

{
echo "WEBSSH_CI_CONTAINER=$container_name"
echo "WEBSSH_CI_CONTAINER_LABEL_VALUE=$ownership_value"
echo "WEBSSH_CI_CONTAINER_CREATED=1"
echo "WEBSSH_CI_RECOVERY_VOLUME=$recovery_volume"
echo "WEBSSH_CI_RECOVERY_VOLUME_CREATED=1"
} >> "$GITHUB_ENV"
trap - EXIT

- name: Verify gthread worker and readiness
- name: Verify recovery volume, gthread worker and readiness
shell: bash
run: |
binding="$(docker port "$WEBSSH_CI_CONTAINER" 5000/tcp | head --lines=1)"
port="${binding##*:}"
ready=0

for attempt in {1..30}; do
ready_status="$(curl --output /dev/null --silent --show-error --write-out '%{http_code}' "http://127.0.0.1:${port}/ready" || true)"
if docker logs "$WEBSSH_CI_CONTAINER" 2>&1 | grep --quiet --fixed-strings "Using worker: gthread" \
&& [ "$ready_status" = "200" ]; then
exit 0
ready=1
break
fi
sleep 1
done

docker logs "$WEBSSH_CI_CONTAINER" >&2
exit 1
if [ "$ready" != "1" ]; then
docker logs "$WEBSSH_CI_CONTAINER" >&2
exit 1
fi

mounted_volume="$(docker inspect --format '{{ range .Mounts }}{{ if eq .Destination "/app/recovery" }}{{ .Name }}{{ end }}{{ end }}' "$WEBSSH_CI_CONTAINER")"
if [ "$mounted_volume" != "$WEBSSH_CI_RECOVERY_VOLUME" ]; then
echo "Recovery path is not backed by the fresh CI volume" >&2
exit 1
fi
docker exec "$WEBSSH_CI_CONTAINER" sh -eu -c '
expected_owner="$(id -u):$(id -g):700"
actual_owner="$(stat --format=%u:%g:%a /app/recovery)"
test "$actual_owner" = "$expected_owner"
test -w /app/recovery
probe=/app/recovery/.webssh-ci-write-probe
: > "$probe"
rm -f "$probe"
'

- name: Verify graceful gthread shutdown
shell: bash
Expand All @@ -385,17 +451,27 @@ jobs:
test "$exit_code" = "0"

- name: Clean up created container
if: always() && env.WEBSSH_CI_CONTAINER_CREATED == '1'
if: always()
shell: bash
run: |
if ! docker container inspect "$WEBSSH_CI_CONTAINER" >/dev/null 2>&1; then
exit 0
fi
if [ "${WEBSSH_CI_CONTAINER_CREATED:-0}" = "1" ] \
&& docker container inspect "$WEBSSH_CI_CONTAINER" >/dev/null 2>&1; then
actual_label="$(docker container inspect --format '{{ index .Config.Labels "webssh.ci-run" }}' "$WEBSSH_CI_CONTAINER")"
if [ "$actual_label" != "$WEBSSH_CI_CONTAINER_LABEL_VALUE" ]; then
echo "Refusing to remove container with unexpected ownership label" >&2
exit 1
fi

actual_label="$(docker container inspect --format '{{ index .Config.Labels "webssh.ci-run" }}' "$WEBSSH_CI_CONTAINER")"
if [ "$actual_label" != "$WEBSSH_CI_CONTAINER_LABEL_VALUE" ]; then
echo "Refusing to remove container with unexpected ownership label" >&2
exit 1
docker rm --force --volumes "$WEBSSH_CI_CONTAINER"
fi

docker rm --force "$WEBSSH_CI_CONTAINER"
if [ "${WEBSSH_CI_RECOVERY_VOLUME_CREATED:-0}" = "1" ] \
&& docker volume inspect "$WEBSSH_CI_RECOVERY_VOLUME" >/dev/null 2>&1; then
actual_label="$(docker volume inspect --format '{{ index .Labels "webssh.ci-run" }}' "$WEBSSH_CI_RECOVERY_VOLUME")"
if [ "$actual_label" != "$WEBSSH_CI_CONTAINER_LABEL_VALUE" ]; then
echo "Refusing to remove volume with unexpected ownership label" >&2
exit 1
fi

docker volume rm "$WEBSSH_CI_RECOVERY_VOLUME"
fi
4 changes: 3 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,14 @@ RUN apt-get update \
COPY . /app

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

COPY entrypoint.sh /app/entrypoint.sh
Expand Down
Loading
Loading