diff --git a/.env.example b/.env.example index 30cdf3d..623f73d 100644 --- a/.env.example +++ b/.env.example @@ -136,6 +136,7 @@ LDAP_ENABLED=false LDAP_AUTO_PROVISION=false LDAP_PROVIDER_ID=default LDAP_URL= +LDAP_BACKUP_URL= LDAP_BASE_DN= LDAP_BIND_DN= LDAP_BIND_PASSWORD_FILE=/run/webssh-auth/ldap_bind_password diff --git a/app/ldap_service.py b/app/ldap_service.py index d185299..ad1f9ed 100644 --- a/app/ldap_service.py +++ b/app/ldap_service.py @@ -10,7 +10,7 @@ import os import ssl import stat -from dataclasses import dataclass +from dataclasses import dataclass, replace from pathlib import Path from urllib.parse import urlsplit @@ -39,6 +39,7 @@ class LDAPSettings: unique_id_attribute: str connect_timeout: int operation_timeout: int + backup_url: str = '' @classmethod def from_config(cls): @@ -55,8 +56,15 @@ def from_config(cls): unique_id_attribute=config.LDAP_UNIQUE_ID_ATTRIBUTE, connect_timeout=config.LDAP_CONNECT_TIMEOUT, operation_timeout=config.LDAP_OPERATION_TIMEOUT, + backup_url=getattr(config, 'LDAP_BACKUP_URL', ''), ) + def endpoints(self): + return tuple(filter(None, (self.url, self.backup_url))) + + def for_url(self, url): + return replace(self, url=url, backup_url='') + @dataclass(frozen=True) class LDAPIdentity: @@ -239,6 +247,26 @@ class LDAPDirectory: def __init__(self, settings=None, *, backend=None): self.settings = settings or LDAPSettings.from_config() self.backend = backend or BonsaiBackend() + self.active_url = None + + def _endpoint_settings(self): + urls = list(self.settings.endpoints()) + if self.active_url in urls: + urls.remove(self.active_url) + urls.insert(0, self.active_url) + return tuple(self.settings.for_url(url) for url in urls) + + def _with_failover(self, operation): + last_error = None + for endpoint_settings in self._endpoint_settings(): + try: + result = operation(endpoint_settings) + except LDAPUnavailable as exc: + last_error = exc + continue + self.active_url = endpoint_settings.url + return result + raise LDAPUnavailable('LDAP servers are unavailable') from last_error def lookup(self, username): normalized_username = str(username or '').strip() @@ -249,10 +277,12 @@ def lookup(self, username): ) bind_password = _read_secret(self.settings.bind_password_file) try: - entries = self.backend.search_user( - self.settings, - bind_password, - filter_expression, + entries = self._with_failover( + lambda endpoint_settings: self.backend.search_user( + endpoint_settings, + bind_password, + filter_expression, + ) ) finally: bind_password = None @@ -278,15 +308,22 @@ def lookup(self, username): def verify_password(self, distinguished_name, password): if not password: return False - return bool(self.backend.verify_password( - self.settings, - distinguished_name, - password, + return bool(self._with_failover( + lambda endpoint_settings: self.backend.verify_password( + endpoint_settings, + distinguished_name, + password, + ) )) def probe(self): bind_password = _read_secret(self.settings.bind_password_file) try: - return bool(self.backend.probe(self.settings, bind_password)) + return bool(self._with_failover( + lambda endpoint_settings: self.backend.probe( + endpoint_settings, + bind_password, + ) + )) finally: bind_password = None diff --git a/app/security_features.py b/app/security_features.py index 733eea5..717a52d 100644 --- a/app/security_features.py +++ b/app/security_features.py @@ -48,6 +48,7 @@ 'ldap': ( 'LDAP_ENABLED', 'LDAP_URL', + 'LDAP_BACKUP_URL', 'LDAP_BASE_DN', 'LDAP_BIND_DN', 'LDAP_BIND_PASSWORD_FILE', diff --git a/app/session_insights.py b/app/session_insights.py index 1f15365..afe9fe3 100644 --- a/app/session_insights.py +++ b/app/session_insights.py @@ -56,6 +56,25 @@ """ +LINUX_NETWORK_AWK_PROGRAM = r"""NR > 2 { + separator = index($0, ":") + if (separator == 0) next + interface = substr($0, 1, separator - 1) + gsub(/^[[:space:]]+|[[:space:]]+$/, "", interface) + if (interface == "lo") next + counters = substr($0, separator + 1) + sub(/^[[:space:]]+/, "", counters) + count = split(counters, fields, /[[:space:]]+/) + if (count >= 16) { + received += fields[1] + transmitted += fields[9] + } + } END { + print "network_received_bytes=" received + 0 + print "network_transmitted_bytes=" transmitted + 0 + }""" + + LINUX_DIAGNOSTICS_COMMAND = LINUX_STATS_COMMAND + r""" if [ -r /proc/meminfo ] && command -v awk >/dev/null 2>&1; then awk ' @@ -76,15 +95,7 @@ }' /proc/stat 2>/dev/null fi if [ -r /proc/net/dev ] && command -v awk >/dev/null 2>&1; then - awk -F '[: ]+' 'NR > 2 { - if ($2 != "lo") { - received += $3 - transmitted += $11 - } - } END { - print "network_received_bytes=" received + 0 - print "network_transmitted_bytes=" transmitted + 0 - }' /proc/net/dev 2>/dev/null + awk '""" + LINUX_NETWORK_AWK_PROGRAM + r"""' /proc/net/dev 2>/dev/null fi process_probe=$(ps -p $$ -o pid= 2>&1) diff --git a/config.py b/config.py index e03efa5..b03b965 100644 --- a/config.py +++ b/config.py @@ -91,6 +91,7 @@ ) LDAP_PROVIDER_ID = os.environ.get('LDAP_PROVIDER_ID', 'default').strip() LDAP_URL = os.environ.get('LDAP_URL', '').strip() +LDAP_BACKUP_URL = os.environ.get('LDAP_BACKUP_URL', '').strip() LDAP_BASE_DN = os.environ.get('LDAP_BASE_DN', '').strip() LDAP_BIND_DN = os.environ.get('LDAP_BIND_DN', '').strip() LDAP_BIND_PASSWORD_FILE = os.environ.get( @@ -707,28 +708,54 @@ def _canonical_smb_target(raw_value): 'LDAP_ENABLED is true' ) - ldap_url_error = ( - 'SECURITY ERROR: LDAP_URL must be an exact ldap:// or ' - 'ldaps:// server URL without credentials, path, query, or ' - 'fragment and with a valid port' - ) - try: - parsed_ldap_url = urlsplit(LDAP_URL) - ldap_hostname = parsed_ldap_url.hostname - ldap_port = parsed_ldap_url.port - except ValueError as exc: - raise RuntimeError(ldap_url_error) from exc - if ( - parsed_ldap_url.scheme not in {'ldap', 'ldaps'} - or not ldap_hostname - or (ldap_port is not None and ldap_port < 1) - or parsed_ldap_url.username is not None - or parsed_ldap_url.password is not None - or parsed_ldap_url.path - or parsed_ldap_url.query - or parsed_ldap_url.fragment - ): - raise RuntimeError(ldap_url_error) + def _validate_ldap_url(setting_name, value): + ldap_url_error = ( + f'SECURITY ERROR: {setting_name} must be an exact ldap:// or ' + 'ldaps:// server URL without credentials, path, query, or ' + 'fragment and with a valid port' + ) + try: + parsed_ldap_url = urlsplit(value) + ldap_hostname = parsed_ldap_url.hostname + ldap_port = parsed_ldap_url.port + except ValueError as exc: + raise RuntimeError(ldap_url_error) from exc + if ( + parsed_ldap_url.scheme not in {'ldap', 'ldaps'} + or not ldap_hostname + or (ldap_port is not None and ldap_port < 1) + or parsed_ldap_url.username is not None + or parsed_ldap_url.password is not None + or parsed_ldap_url.path + or parsed_ldap_url.query + or parsed_ldap_url.fragment + ): + raise RuntimeError(ldap_url_error) + return parsed_ldap_url + + primary_ldap_url = _validate_ldap_url('LDAP_URL', LDAP_URL) + if LDAP_BACKUP_URL: + backup_ldap_url = _validate_ldap_url( + 'LDAP_BACKUP_URL', + LDAP_BACKUP_URL, + ) + + def _ldap_endpoint_identity(parsed_url): + default_port = 636 if parsed_url.scheme == 'ldaps' else 389 + return ( + parsed_url.scheme, + parsed_url.hostname.casefold().rstrip('.'), + parsed_url.port or default_port, + ) + + if ( + _ldap_endpoint_identity(backup_ldap_url) + == _ldap_endpoint_identity(primary_ldap_url) + ): + raise RuntimeError( + 'SECURITY ERROR: LDAP_BACKUP_URL must identify a ' + 'different server than LDAP_URL' + ) if LDAP_USER_FILTER.count('{username}') != 1: raise RuntimeError( 'SECURITY ERROR: LDAP_USER_FILTER must contain exactly one ' diff --git a/docker-compose.ldap.yml b/docker-compose.ldap.yml index 0ddcc8f..1af3a16 100644 --- a/docker-compose.ldap.yml +++ b/docker-compose.ldap.yml @@ -7,6 +7,8 @@ services: LDAP_AUTO_PROVISION: "false" LDAP_PROVIDER_ID: default LDAP_URL: "" + # Optional failover endpoint for the same directory and CA trust. + LDAP_BACKUP_URL: "" LDAP_BASE_DN: "" LDAP_BIND_DN: "" LDAP_USER_FILTER: "" diff --git a/docs/ldap-authentication.md b/docs/ldap-authentication.md index 813cce3..6ae0766 100644 --- a/docs/ldap-authentication.md +++ b/docs/ldap-authentication.md @@ -41,7 +41,8 @@ the provided `docker-compose.ldap.yml` overlay with the same WebSSH image; no Collect these values before activation: -1. An LDAP server DNS name whose TLS certificate matches that name. +1. A primary LDAP server DNS name whose TLS certificate matches that name and, + optionally, a backup server with its own certificate-matching DNS name. 2. Whether to use `ldap://host:389` with StartTLS or `ldaps://host:636`. 3. The user search base DN. 4. A read-only bind account DN and its password. It needs only enough access to @@ -67,6 +68,7 @@ LDAP_ENABLED: "true" LDAP_AUTO_PROVISION: "false" LDAP_PROVIDER_ID: corp-ad LDAP_URL: ldaps://dc01.ad.example.com:636 +LDAP_BACKUP_URL: ldaps://dc02.ad.example.com:636 LDAP_BASE_DN: OU=People,DC=ad,DC=example,DC=com LDAP_BIND_DN: CN=svc-webssh,OU=Service Accounts,DC=ad,DC=example,DC=com LDAP_USER_FILTER: "(&(objectCategory=person)(objectClass=user)(sAMAccountName={username})(!(userAccountControl:1.2.840.113556.1.4.803:=2)))" @@ -77,6 +79,14 @@ The final filter clause excludes disabled AD accounts. If the organization has a dedicated WebSSH access group, add a directory-approved `memberOf` clause. Nested group semantics vary and must be validated by the AD administrator. +`LDAP_BACKUP_URL` is optional and must point to the same logical directory as +`LDAP_URL`. WebSSH tries it only when the primary endpoint is unavailable. Each +URL is connected to directly, so the CA bundle must validate the DNS name in +that endpoint's certificate; a round-robin alias is not required. A completed +bind that rejects a user's password is authoritative and is not retried against +the other endpoint. Keep the provider ID, base DN, bind account, filter, and +stable-ID attribute identical across both endpoints. + ### Generic OpenLDAP example ```yaml @@ -84,6 +94,7 @@ LDAP_ENABLED: "true" LDAP_AUTO_PROVISION: "false" LDAP_PROVIDER_ID: primary-openldap LDAP_URL: ldap://ldap.example.com:389 +# LDAP_BACKUP_URL: ldap://ldap-backup.example.com:389 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})(!(pwdAccountLockedTime=*)))" @@ -204,8 +215,10 @@ remain in the separate secret volume and are intentionally not included. administrator. WebSSH never chooses one result from an ambiguous search. - **AD user is found but cannot bind:** check disabled/locked/expired state, logon restrictions, and whether the DC accepts the supplied username flow. -- **LDAP outage signs users out:** this is intentional fail-closed behavior. - Restore directory/TLS service; do not enable a password fallback. +- **LDAP outage signs users out:** WebSSH tries the optional backup endpoint + after a transport failure. If neither endpoint is available, the intentional + fail-closed behavior applies. Restore directory/TLS service; do not enable a + password fallback. - **Provider ID changed:** restore the original `LDAP_PROVIDER_ID`. It is part of every stable mapping and should remain constant for that directory. diff --git a/docs/wiki/LDAP-and-Active-Directory.md b/docs/wiki/LDAP-and-Active-Directory.md index 8eca756..b7e9c23 100644 --- a/docs/wiki/LDAP-and-Active-Directory.md +++ b/docs/wiki/LDAP-and-Active-Directory.md @@ -61,6 +61,7 @@ services: LDAP_ENABLED: "true" LDAP_PROVIDER_ID: corp-ad LDAP_URL: ldaps://dc01.ad.example.com:636 + LDAP_BACKUP_URL: ldaps://dc02.ad.example.com:636 LDAP_BASE_DN: OU=People,DC=ad,DC=example,DC=com LDAP_BIND_DN: CN=svc-webssh,OU=Service Accounts,DC=ad,DC=example,DC=com LDAP_USER_FILTER: "(&(objectCategory=person)(objectClass=user)(sAMAccountName={username})(!(userAccountControl:1.2.840.113556.1.4.803:=2)))" @@ -71,6 +72,12 @@ The final filter clause excludes disabled AD accounts. If access is restricted to a group, use a directory-approved `memberOf` rule. Nested group semantics vary and must be validated by the AD administrator. +`LDAP_BACKUP_URL` is optional and must identify another server for the same +logical directory. It is tried only after the primary endpoint is unavailable. +WebSSH connects to each URL directly, so every endpoint DNS name must match its +own TLS certificate and be trusted by `LDAP_CA_FILE`; a round-robin alias is not +needed. Invalid user credentials are not retried against the backup endpoint. + ### OpenLDAP example ```yaml @@ -80,6 +87,7 @@ services: LDAP_ENABLED: "true" LDAP_PROVIDER_ID: primary-openldap LDAP_URL: ldap://ldap.example.com:389 + # LDAP_BACKUP_URL: ldap://ldap-backup.example.com:389 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})(!(pwdAccountLockedTime=*)))" @@ -96,6 +104,7 @@ attribute is not available. | `LDAP_ENABLED` | `false` | Enable the subsystem | | `LDAP_PROVIDER_ID` | `default` | Stable 1-64 character local provider identifier | | `LDAP_URL` | empty | Exact `ldap://` or `ldaps://` server URL | +| `LDAP_BACKUP_URL` | empty | Optional second URL for the same directory, tried after transport failure | | `LDAP_BASE_DN` | empty | User subtree base | | `LDAP_BIND_DN` | empty | Least-privilege search account DN | | `LDAP_BIND_PASSWORD_FILE` | `/run/webssh-auth/ldap_bind_password` | Absolute private file path | diff --git a/static/css/session-workspace.css b/static/css/session-workspace.css index 9c7c178..d26da5a 100644 --- a/static/css/session-workspace.css +++ b/static/css/session-workspace.css @@ -158,7 +158,7 @@ .fm-embedded-mode .fm-toolbar-right { display: grid; - grid-template-columns: repeat(5, minmax(0, 1fr)); + grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 4px; } diff --git a/static/css/sftp-file-manager.css b/static/css/sftp-file-manager.css index 425e490..b3a8af6 100644 --- a/static/css/sftp-file-manager.css +++ b/static/css/sftp-file-manager.css @@ -2604,6 +2604,11 @@ background: var(--bg-primary); } +.fm-source-search:focus-within { + outline: 2px solid var(--ws2-focus, var(--accent-primary)); + outline-offset: 2px; +} + .fm-source-search .material-icons { color: var(--text-muted); font-size: 19px; @@ -2618,6 +2623,10 @@ color: var(--text-primary); } +.fm-source-search input:focus-visible { + outline: 0 !important; +} + .fm-source-groups { display: flex; min-height: 0; diff --git a/static/css/webssh-2.css b/static/css/webssh-2.css index 71cb491..5cabb2f 100644 --- a/static/css/webssh-2.css +++ b/static/css/webssh-2.css @@ -1694,6 +1694,14 @@ body:has(.auth-container) { gap: 10px; } +.primary-workspace-surface .management-panel-actions > .btn { + display: inline-flex; + height: 38px; + align-items: center; + justify-content: center; + gap: 6px; +} + .primary-workspace-surface .profile-management-list { gap: 0; margin-top: 14px; @@ -1976,7 +1984,15 @@ body:has(.auth-container) { } .primary-workspace-surface #commandLibraryPanel { - gap: 14px; + gap: 0; +} + +.primary-workspace-surface #commandLibraryPanel > :is( + .os-filter-toolbar, + .commands-table-wrapper, + .command-help +) { + margin-top: 14px; } .primary-workspace-surface .command-toolbar { diff --git a/static/js/command-set-manager.js b/static/js/command-set-manager.js index be442fe..eba24ea 100644 --- a/static/js/command-set-manager.js +++ b/static/js/command-set-manager.js @@ -211,6 +211,9 @@ window.CommandSetManager = { this.returnToConnection = false; this.showManagementList(); this.openModal(); + setTimeout(() => { + document.getElementById('commandSetManagementSearch')?.focus(); + }, 0); }, openBuilder(id = null, returnToConnection = false) { diff --git a/static/js/command-workspace.js b/static/js/command-workspace.js index 6c6efb5..7d8b7a2 100644 --- a/static/js/command-workspace.js +++ b/static/js/command-workspace.js @@ -60,7 +60,7 @@ window.CommandWorkspace = { if (focusContent) { const target = next === 'library' ? document.getElementById('commandSearchInput') - : document.getElementById('newCommandSetBtn'); + : document.getElementById('commandSetManagementSearch'); setTimeout(() => target?.focus(), 0); } }, diff --git a/static/js/i18n.js b/static/js/i18n.js index 25a66a0..7b868d1 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -38,6 +38,7 @@ const translations = { 'connection.recentConnections': 'Recent Connections', 'connection.recentConnectionsHint': 'Stored only in this browser for your account.', 'connection.noRecentConnections': 'Your recent connections will appear here.', + 'time.justNow': 'Just now', 'connection.details': 'Connection Details', 'connection.detailsHint': 'Enter the destination and choose how to authenticate.', 'connection.savedContext': 'Saved connection', @@ -1289,6 +1290,7 @@ const translations = { 'connection.recentConnections': 'Kết nối gần đây', 'connection.recentConnectionsHint': 'Chỉ được lưu trong trình duyệt này cho tài khoản của bạn.', 'connection.noRecentConnections': 'Các kết nối gần đây của bạn sẽ xuất hiện tại đây.', + 'time.justNow': 'Vừa xong', 'connection.details': 'Chi tiết kết nối', 'connection.detailsHint': 'Nhập đích đến và chọn cách xác thực.', 'connection.savedContext': 'Kết nối đã lưu', @@ -2539,6 +2541,7 @@ const translations = { 'connection.recentConnections': 'Letzte Verbindungen', 'connection.recentConnectionsHint': 'Wird nur in diesem Browser für deinen Account gespeichert.', 'connection.noRecentConnections': 'Deine letzten Verbindungen erscheinen hier.', + 'time.justNow': 'Gerade eben', 'connection.details': 'Verbindungsdetails', 'connection.detailsHint': 'Gib das Ziel ein und wähle die Authentifizierung.', 'connection.savedContext': 'Gespeicherte Verbindung', @@ -3788,6 +3791,7 @@ const translations = { 'connection.recentConnections': 'Connexions récentes', 'connection.recentConnectionsHint': 'Stockées uniquement dans ce navigateur pour votre compte.', 'connection.noRecentConnections': 'Vos connexions récentes apparaîtront ici.', + 'time.justNow': 'À l’instant', 'connection.details': 'Détails de connexion', 'connection.detailsHint': 'Saisissez la destination et choisissez le mode d’authentification.', 'connection.savedContext': 'Connexion enregistrée', @@ -5037,6 +5041,7 @@ const translations = { 'connection.recentConnections': 'Conexiones recientes', 'connection.recentConnectionsHint': 'Se guardan solo en este navegador para tu cuenta.', 'connection.noRecentConnections': 'Tus conexiones recientes aparecerán aquí.', + 'time.justNow': 'Ahora mismo', 'connection.details': 'Detalles de conexión', 'connection.detailsHint': 'Introduce el destino y elige cómo autenticarte.', 'connection.savedContext': 'Conexión guardada', @@ -6286,6 +6291,7 @@ const translations = { 'connection.recentConnections': '最近连接', 'connection.recentConnectionsHint': '仅在此浏览器中为你的账户保存。', 'connection.noRecentConnections': '你的最近连接将显示在这里。', + 'time.justNow': '刚刚', 'connection.details': '连接详情', 'connection.detailsHint': '输入目标并选择身份验证方式。', 'connection.savedContext': '已保存的连接', diff --git a/templates/admin.html b/templates/admin.html index c115735..c25c168 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -14,7 +14,7 @@ - +