From 5fcc13e83e1b61c75bec358ec37fb1d5223ad830 Mon Sep 17 00:00:00 2001 From: bifrost0x Date: Mon, 9 Nov 2026 16:50:49 +0100 Subject: [PATCH 1/2] Update the Python base image to 3.14.10 Pin both build stages to the python:3.14-slim index digest for 3.14.10-slim-trixie. --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index d8f63323..d67ce8b0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.14-slim@sha256:35caba83af2fe6da9a5d922f9b0156f21d1246a48278c58a08bd253303ba42e8 AS ldap-builder +FROM python:3.14-slim@sha256:9aaa474097d094040649f2162a627c0049b408f52138d980916788d80f04802f AS ldap-builder WORKDIR /build @@ -17,7 +17,7 @@ RUN pip install --no-cache-dir --require-hashes \ --prefix=/install -r requirements.txt -FROM python:3.14-slim@sha256:35caba83af2fe6da9a5d922f9b0156f21d1246a48278c58a08bd253303ba42e8 +FROM python:3.14-slim@sha256:9aaa474097d094040649f2162a627c0049b408f52138d980916788d80f04802f ARG VCS_REF=unknown From 14a00ddb3d6b3057edecfb8d7efde6b18fd699c4 Mon Sep 17 00:00:00 2001 From: bifrost0x Date: Mon, 9 Nov 2026 17:00:08 +0100 Subject: [PATCH 2/2] Show the WebSSH version to signed-in users The account menu and the Settings side panel show the running version, with the short source commit for official images, so users can report it and administrators can confirm an upgrade. Sign-in and registration pages do not show it. app/version.py holds the release number. A test keeps it equal to the newest release notes, and the tag-triggered image build refuses a tag that differs from it. The image exposes its build commit as WEBSSH_REVISION after the last cached layer. Fixes #327 --- .github/workflows/docker-publish.yml | 7 +++ Dockerfile | 3 + app/__init__.py | 8 ++- app/version.py | 25 ++++++++ docs/project-planning.md | 4 +- docs/wiki/Upgrading-Rollback-and-FAQ.md | 9 +++ static/css/style.css | 13 ++++ static/js/i18n.js | 6 ++ templates/index.html | 1 + templates/security.html | 1 + tests/e2e/account-menu.spec.js | 6 ++ tests/test_version.py | 79 +++++++++++++++++++++++++ 12 files changed, 160 insertions(+), 2 deletions(-) create mode 100644 app/version.py create mode 100644 tests/test_version.py diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index bda2875d..6bf6be7c 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -57,6 +57,13 @@ jobs: shell: bash run: | test "$(git rev-parse HEAD)" = "$GITHUB_SHA" + if [ "$GITHUB_REF_TYPE" = tag ]; then + version="$(python3 -c "import runpy; print(runpy.run_path('app/version.py')['VERSION'])")" + if [ "$GITHUB_REF_NAME" != "v$version" ]; then + echo "Tag $GITHUB_REF_NAME does not match app/version.py ($version)" >&2 + exit 1 + fi + fi echo "IMAGE_REF=${REGISTRY}/${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV" - name: Set up Docker Buildx diff --git a/Dockerfile b/Dockerfile index d67ce8b0..8a3efc96 100644 --- a/Dockerfile +++ b/Dockerfile @@ -81,6 +81,9 @@ RUN chmod +x /app/entrypoint.sh # override with a named/bind volume (see docker-compose.yml) for real durability. VOLUME /app/data +# Last so a new revision never invalidates the cached layers above. +ENV WEBSSH_REVISION=$VCS_REF + USER appuser EXPOSE 5000 diff --git a/app/__init__.py b/app/__init__.py index 25c43a67..66e2e997 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -30,6 +30,7 @@ is_ssh_gateway_enabled, set_ssh_gateway_enabled) from .storage_errors import StorageCorruptionError from .tailscale_ssh import user_can_use_tailscale_ssh +from .version import display_version from .runtime_lifecycle import RuntimeLifecycle from .browser_identity import connection_history_scope from .auth_assurance import clear_browser_authentication, current_authentication_session @@ -634,7 +635,12 @@ def inject_url_prefix(): config.HOST_KEY_MANAGEMENT_ENABLED ), 'audit_export_enabled': config.AUDIT_EXPORT_ENABLED, - 'tailscale_ssh_allowed': user_can_use_tailscale_ssh(current_user) + 'tailscale_ssh_allowed': user_can_use_tailscale_ssh(current_user), + # Only signed-in users see the version, so the public sign-in + # pages do not help fingerprint an instance. + 'webssh_version': ( + display_version() if current_user.is_authenticated else '' + ), } @app.before_request diff --git a/app/version.py b/app/version.py new file mode 100644 index 00000000..14a93cae --- /dev/null +++ b/app/version.py @@ -0,0 +1,25 @@ +"""Release version shown to signed-in users. + +VERSION changes together with docs/releases/v.md; tests and the +tag-triggered image build reject a mismatch. The container build supplies +the source revision so builds between releases stay distinguishable. +""" + +import os +import re + + +VERSION = '3.0.0' + +_REVISION = re.compile(r'[0-9a-f]{40}') + + +def source_revision(environ=os.environ): + """Return the short source revision, or '' when it is not a full commit id.""" + value = environ.get('WEBSSH_REVISION', '') + return value[:7] if _REVISION.fullmatch(value) else '' + + +def display_version(environ=os.environ): + revision = source_revision(environ) + return f'{VERSION} ({revision})' if revision else VERSION diff --git a/docs/project-planning.md b/docs/project-planning.md index 3679c775..80eeae82 100644 --- a/docs/project-planning.md +++ b/docs/project-planning.md @@ -35,7 +35,9 @@ This setup does not add or reorganize a Project. 5. **Validate delivery.** Keep the release-readiness issue open after feature PRs merge. Check the exact candidate, required CI, native image scans, relevant real-environment acceptance and upgrade/recovery behavior. Link the evidence, not just a checkbox. -6. **Publish and verify.** Tag the reviewed candidate, publish release notes, and verify +6. **Publish and verify.** Set `VERSION` in `app/version.py` in the same change as the + release notes; the tag build refuses a tag that differs from it. Tag the reviewed + candidate, publish release notes, and verify the tag-triggered pipeline, versioned AMD64/ARM64 image and SBOM/provenance. Only then close the readiness issue and milestone; move the outcome into completed history. diff --git a/docs/wiki/Upgrading-Rollback-and-FAQ.md b/docs/wiki/Upgrading-Rollback-and-FAQ.md index e9b91a52..2a3f46a1 100644 --- a/docs/wiki/Upgrading-Rollback-and-FAQ.md +++ b/docs/wiki/Upgrading-Rollback-and-FAQ.md @@ -107,6 +107,7 @@ See [SFTP permissions](SFTP-File-Workspace-and-Transfers.md) and - `/health` returns 200. - `/ready` returns 200. +- The account menu and **Settings** show the expected WebSSH version. - Administrator and standard-user login work. - Existing stored-key metadata is readable. - A direct SSH terminal opens. @@ -206,6 +207,14 @@ WebSSH stores first-use trust persistently. A changed key can indicate a server rebuild, DNS/IP reassignment, or interception. Verify the new fingerprint out of band, remove the old trust record deliberately, and reconnect. +### Which WebSSH version is running? + +Signed-in users see it at the bottom of the account menu (on phones: **More**, +then the user name) and, on wide screens, in the **Settings** side panel, for +example `3.0.0 (1a2b3c4)`. The part in parentheses is the source commit of an +official image; builds between releases keep the last release number but a +different commit. Sign-in pages do not show the version. + ### Can I use a CDN for frontend libraries? The supported frontend is offline-capable and serves pinned vendored assets diff --git a/static/css/style.css b/static/css/style.css index 38c32366..2159e3f9 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -4157,6 +4157,19 @@ body.keyboard-open.notepad-focused .notepad-panel { display: none; } +.account-version { + margin: 6px 4px 0; + padding: 8px 10px 4px; + border-top: 1px solid var(--border-color); + color: var(--text-secondary); + font-size: 12px; + user-select: text; +} + +.account-version-value { + font-variant-numeric: tabular-nums; +} + .account-menu-group.show { display: block; } diff --git a/static/js/i18n.js b/static/js/i18n.js index 8d337c02..4544ce31 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -2018,6 +2018,7 @@ const translations = { 'settings.signedInAccount': 'Signed-in account', 'settings.primarySignIn': 'Primary sign-in', 'settings.scope': 'Settings scope', + 'settings.webSshVersion': 'WebSSH version', 'settings.personal': 'Personal', 'settings.personalAndAdministration': 'Personal + administration', 'settings.quickAccess': 'Quick access', @@ -4439,6 +4440,7 @@ const translations = { 'settings.signedInAccount': 'Tài khoản đã đăng nhập', 'settings.primarySignIn': 'Đăng nhập chính', 'settings.scope': 'Phạm vi cài đặt', + 'settings.webSshVersion': 'Phiên bản WebSSH', 'settings.personal': 'Cá nhân', 'settings.personalAndAdministration': 'Cá nhân + quản trị', 'settings.quickAccess': 'Truy cập nhanh', @@ -6882,6 +6884,7 @@ const translations = { 'settings.signedInAccount': 'Angemeldetes Konto', 'settings.primarySignIn': 'Primäre Anmeldung', 'settings.scope': 'Einstellungsumfang', + 'settings.webSshVersion': 'WebSSH-Version', 'settings.personal': 'Persönlich', 'settings.personalAndAdministration': 'Persönlich + Administration', 'settings.quickAccess': 'Schnellzugriff', @@ -9313,6 +9316,7 @@ const translations = { 'settings.signedInAccount': 'Compte connecté', 'settings.primarySignIn': 'Connexion principale', 'settings.scope': 'Portée des paramètres', + 'settings.webSshVersion': 'Version de WebSSH', 'settings.personal': 'Personnel', 'settings.personalAndAdministration': 'Personnel + administration', 'settings.quickAccess': 'Accès rapide', @@ -11733,6 +11737,7 @@ const translations = { 'settings.signedInAccount': 'Cuenta con sesión iniciada', 'settings.primarySignIn': 'Inicio de sesión principal', 'settings.scope': 'Ámbito de los ajustes', + 'settings.webSshVersion': 'Versión de WebSSH', 'settings.personal': 'Personal', 'settings.personalAndAdministration': 'Personal + administración', 'settings.quickAccess': 'Acceso rápido', @@ -14141,6 +14146,7 @@ const translations = { 'settings.signedInAccount': '已登录账户', 'settings.primarySignIn': '主要登录方式', 'settings.scope': '设置范围', + 'settings.webSshVersion': 'WebSSH 版本', 'settings.personal': '个人', 'settings.personalAndAdministration': '个人 + 管理', 'settings.quickAccess': '快速访问', diff --git a/templates/index.html b/templates/index.html index 63740ae8..f89f691b 100644 --- a/templates/index.html +++ b/templates/index.html @@ -146,6 +146,7 @@ + diff --git a/templates/security.html b/templates/security.html index a4b8ebfe..442d4187 100644 --- a/templates/security.html +++ b/templates/security.html @@ -419,6 +419,7 @@

SSH host trust

{% else %}{{ primary_method.label }}{% endif %}
Account protection
{% if account_mfa_enabled %}MFA enabled{% else %}MFA optional{% endif %}
+
WebSSH version
{{ webssh_version }}
Settings scope
{% if admin_panel_enabled and is_admin %}Personal + administration{% else %}Personal{% endif %}
diff --git a/tests/e2e/account-menu.spec.js b/tests/e2e/account-menu.spec.js index b7dd83d5..d27a0762 100644 --- a/tests/e2e/account-menu.spec.js +++ b/tests/e2e/account-menu.spec.js @@ -122,6 +122,12 @@ test('account menu and settings stay inside a mobile viewport', async ({ page }) expect(bounds.x + bounds.width).toBeLessThanOrEqual(390); await expect(page.locator('#accountSettingsBtn')).toBeVisible(); await expect(page.locator('#logoutBtn')).toBeVisible(); + const version = page.locator('#accountDropdownHeader [data-webssh-version]'); + await version.scrollIntoViewIfNeeded(); + await expect(version).toBeVisible(); + await expect(version).toHaveText(/^\d+\.\d+\.\d+( \([0-9a-f]{7}\))?$/); + const versionBounds = await version.boundingBox(); + expect(versionBounds.x + versionBounds.width).toBeLessThanOrEqual(390); await page.locator('#accountSettingsBtn').click(); await expect(page).toHaveURL(/\/settings#preferences$/); diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 00000000..f4eab029 --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,79 @@ +"""Signed-in users can read the running WebSSH version; anonymous pages cannot.""" + +from pathlib import Path +import re + +import pytest + +from app import version +from tests.test_webssh2_shell import _create_login + + +ROOT = Path(__file__).resolve().parents[1] +REVISION = '0123456789abcdef0123456789abcdef01234567' + + +def _release_key(path): + return tuple(int(part) for part in path.stem[1:].split('.')) + + +def test_version_matches_the_newest_release_notes(): + assert re.fullmatch(r'[0-9]+\.[0-9]+\.[0-9]+', version.VERSION) + notes = sorted( + (ROOT / 'docs/releases').glob('v*.*.*.md'), key=_release_key, + ) + assert notes[-1].name == f'v{version.VERSION}.md' + + +@pytest.mark.parametrize(('value', 'expected'), [ + (REVISION, f'{version.VERSION} (0123456)'), + ('', version.VERSION), + ('unknown', version.VERSION), + (REVISION[:12], version.VERSION), + (REVISION.upper(), version.VERSION), + (REVISION + '\n', version.VERSION), + ('