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 d8f63323..8a3efc96 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 @@ -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 @@ +

WebSSH version

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), + ('