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
7 changes: 7 additions & 0 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions app/version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""Release version shown to signed-in users.

VERSION changes together with docs/releases/v<VERSION>.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
4 changes: 3 additions & 1 deletion docs/project-planning.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
9 changes: 9 additions & 0 deletions docs/wiki/Upgrading-Rollback-and-FAQ.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions static/css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
6 changes: 6 additions & 0 deletions static/js/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -14141,6 +14146,7 @@ const translations = {
'settings.signedInAccount': '已登录账户',
'settings.primarySignIn': '主要登录方式',
'settings.scope': '设置范围',
'settings.webSshVersion': 'WebSSH 版本',
'settings.personal': '个人',
'settings.personalAndAdministration': '个人 + 管理',
'settings.quickAccess': '快速访问',
Expand Down
1 change: 1 addition & 0 deletions templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@

<div class="account-divider" role="separator"></div>
<button id="logoutBtn" class="account-item account-action" type="button"><span class="account-item-icon material-icons" aria-hidden="true">logout</span> <span data-i18n="auth.logout">Logout</span></button>
<p class="account-version"><span data-i18n="settings.webSshVersion">WebSSH version</span> <span class="account-version-value" data-webssh-version>{{ webssh_version }}</span></p>
</div>
</div>
</div>
Expand Down
1 change: 1 addition & 0 deletions templates/security.html
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,7 @@ <h2 data-i18n="security.sshHostTrust">SSH host trust</h2>
{% else %}<span data-i18n="security.methodUnknown">{{ primary_method.label }}</span>{% endif %}
</dd></div>
<div><dt data-i18n="security.accountProtection">Account protection</dt><dd>{% if account_mfa_enabled %}<span data-i18n="security.mfaEnabled">MFA enabled</span>{% else %}<span data-i18n="security.mfaOptional">MFA optional</span>{% endif %}</dd></div>
<div><dt data-i18n="settings.webSshVersion">WebSSH version</dt><dd data-webssh-version>{{ webssh_version }}</dd></div>
<div><dt data-i18n="settings.scope">Settings scope</dt><dd>{% if admin_panel_enabled and is_admin %}<span data-i18n="settings.personalAndAdministration">Personal + administration</span>{% else %}<span data-i18n="settings.personal">Personal</span>{% endif %}</dd></div>
</dl>
</section>
Expand Down
6 changes: 6 additions & 0 deletions tests/e2e/account-menu.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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$/);
Expand Down
79 changes: 79 additions & 0 deletions tests/test_version.py
Original file line number Diff line number Diff line change
@@ -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),
('<script>' + REVISION[8:], version.VERSION),
])
def test_display_version_only_appends_a_full_source_revision(value, expected):
assert version.display_version({'WEBSSH_REVISION': value}) == expected


def test_display_version_without_revision_variable():
assert version.display_version({}) == version.VERSION


def test_signed_in_pages_show_the_version(app, client, monkeypatch):
monkeypatch.setenv('WEBSSH_REVISION', REVISION)
_create_login(app, client)
expected = f'{version.VERSION} (0123456)'.encode()
for route in ('/', '/settings'):
page = client.get(route)
assert page.status_code == 200
assert b'data-i18n="settings.webSshVersion"' in page.data
assert re.search(
rb'data-webssh-version>' + re.escape(expected) + rb'<', page.data,
), route


def test_anonymous_pages_do_not_reveal_the_version(app, client, monkeypatch):
monkeypatch.setenv('WEBSSH_REVISION', REVISION)
_create_login(app, client)
client.post('/logout')
app.config['REGISTRATION_ENABLED'] = True
for route in ('/login', '/register'):
page = client.get(route)
assert page.status_code == 200
assert b'data-webssh-version' not in page.data
assert b'0123456' not in page.data


def test_image_and_release_build_carry_the_version_contract():
dockerfile = (ROOT / 'Dockerfile').read_text(encoding='utf-8')
runtime = dockerfile.split('\nFROM ')[-1]
assert 'ENV WEBSSH_REVISION=$VCS_REF' in runtime
# The revision changes every build; keep it below the cached layers.
tail = runtime.split('ENV WEBSSH_REVISION=$VCS_REF', 1)[1]
assert not re.search(r'^(RUN|COPY|ADD) ', tail, re.MULTILINE)
workflow = (ROOT / '.github/workflows/docker-publish.yml').read_text(encoding='utf-8')
assert '"$GITHUB_REF_NAME" != "v$version"' in workflow
assert "runpy.run_path('app/version.py')['VERSION']" in workflow
Loading