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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,10 @@ BLOCK_INTERNAL_SSH=false
# Exact comma-separated hostnames a ProxyJump bastion may resolve remotely.
# Leave empty unless a trusted bastion-only DNS name cannot be validated locally.
# PROXY_JUMP_REMOTE_DNS_ALLOWLIST=bastion-only.internal
# Exact names a trusted SOCKS5 proxy may resolve while internal targets are blocked.
# Other names are resolved locally and sent as policy-validated IP addresses.
# This setting does not allow a blocked proxy endpoint or a loopback proxy.
# SOCKS_REMOTE_DNS_ALLOWLIST=proxy-only.internal

# Interactive SSH input resource limits. Byte values are hard-bounded by the
# application; defaults support normal typing and ordered large-paste chunking.
Expand Down Expand Up @@ -262,6 +266,7 @@ SSH_KEY_STORE_MAX_BYTES=8388608
# using DATA_DIR is stopped; they cannot grow.
# PROFILE_MAX_RECORDS=500
# JUMP_HOST_MAX_RECORDS=100
# SOCKS_PROXY_MAX_RECORDS=100
# CONNECTION_STORE_MAX_BYTES=2097152
# CONNECTION_CONFIG_MAX_BYTES=4194304
# Storage backend for rate-limit counters.
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,8 @@ depth. Upgrading from 2.x? Read the

- Status overview with host readiness, open sessions, automation results,
reachability and recent activity, in a layout you arrange per account.
- **Needs attention** lists unverified host keys, missing jump hosts, changed
saved passwords and failed runs.
- **Needs attention** lists unverified host keys, missing jump hosts or SOCKS5
proxies, changed saved passwords and failed runs.
- **Find hosts** scans a network range you name for SSH and SFTP servers, only
on request, and opens the host form prefilled.
- Automation runs commands, command sets and templates now or on a cron
Expand All @@ -93,6 +93,8 @@ See the [automation guide](docs/automation.md) and
- Multiple SSH sessions with tabs and 1-, 2-, or 4-pane layouts.
- Saved connections, jump hosts, key, password, SSH server PAM MFA, and
optional Tailscale SSH authentication paths.
- Per-host [SOCKS5 proxy routes](docs/wiki/Profiles-Jump-Hosts-and-Commands.md#socks5-proxies)
for terminal sessions and their SFTP files, with optional proxy login and remote DNS.
- Optional persistent remote shells through tmux.
- Broadcast input, terminal search, transcripts, reconnect controls, and
configurable scrollback.
Expand Down
3 changes: 3 additions & 0 deletions app/automation_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,9 @@ def target(profile):
if route is not None:
entry.update(via=route['name'], route_id=route['id'], route_auth_type=route.get('auth_type'),
route_credential_status=status(route))
if profile.get('socks_proxy_id'):
entry.update(socks_proxy_id=profile['socks_proxy_id'],
unsupported_reason='SOCKS5 routes are not supported for automation yet')
return entry

return jsonify(enabled=True, credentials_enabled=is_credentials_enabled(), runbooks=runbooks(current_user.id),
Expand Down
1 change: 1 addition & 0 deletions app/automation_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ def password_resources(targets):

def supported_login(item):
return (item is not None and item.get('auth_type') in {'key', 'password'}
and not item.get('socks_proxy_id') and item.get('kind') != 'socks'
and bool(item.get('username')) and ':' not in item['username'])


Expand Down
6 changes: 5 additions & 1 deletion app/automation_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,12 @@ def profile_fingerprint(profile):
)}
# Preserve the existing SSH binding for v1 vaults. Namespaced resources have
# explicit types and their additional security-relevant connection fields.
if profile.get('kind') in {'jump', 'smb'}:
if profile.get('socks_proxy_id'):
fields['socks_proxy_id'] = profile['socks_proxy_id']
if profile.get('kind') in {'jump', 'smb', 'socks'}:
fields['kind'] = profile['kind']
if profile.get('kind') == 'socks':
fields['remote_dns'] = profile.get('remote_dns', True)
if profile.get('kind') == 'smb':
fields.update(domain=profile.get('domain', ''), share=profile.get('share'))
if profile.get('route_fingerprint'):
Expand Down
4 changes: 4 additions & 0 deletions app/automation_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,10 @@ def connect_profile(user_id, profile, password, cancelled, own=lambda _resource:
Both hops use the account's verified host keys, the SSH network policy
and their own credentials; nothing is accepted on first use.
"""
if profile.get('socks_proxy_id') or profile.get('kind') == 'socks' or (
route and (route.get('kind') == 'socks' or route.get('socks_proxy_id'))
):
raise AutomationError('unsupported_target')
opened = []

def hold(resource):
Expand Down
17 changes: 12 additions & 5 deletions app/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,12 +259,12 @@ def _connection_store_user(username):
@click.option(
'--kind',
required=True,
type=click.Choice(('profiles', 'jump-hosts'), case_sensitive=True),
type=click.Choice(('profiles', 'jump-hosts', 'socks-proxies'), case_sensitive=True),
)
@click.option('--confirm-offline', is_flag=True)
def connection_store_list(username, kind, confirm_offline):
"""List bounded, non-secret record summaries for offline recovery."""
from . import jump_host_manager, profile_manager
from . import jump_host_manager, profile_manager, socks_proxy_manager
from .backup_coordination import OperationBusyError, operation_lock
from .connection_storage_policy import ConnectionStorageLimitError
from .storage_errors import StorageCorruptionError
Expand All @@ -279,6 +279,8 @@ def connection_store_list(username, kind, confirm_offline):
)
if error:
raise click.ClickException(error)
elif kind == 'socks-proxies':
records = socks_proxy_manager.load_socks_proxy_recovery_summaries(user.id)
else:
records = (
jump_host_manager.load_jump_host_recovery_summaries(
Expand All @@ -303,7 +305,7 @@ def connection_store_list(username, kind, confirm_offline):
@click.option(
'--kind',
required=True,
type=click.Choice(('profiles', 'jump-hosts'), case_sensitive=True),
type=click.Choice(('profiles', 'jump-hosts', 'socks-proxies'), case_sensitive=True),
)
@click.option('--selector', required=True, metavar='SELECTOR')
@click.option('--confirm-offline', is_flag=True)
Expand All @@ -314,7 +316,7 @@ def connection_store_delete(
confirm_offline,
):
"""Delete one exact legacy record while WebSSH is stopped."""
from . import jump_host_manager, profile_manager
from . import jump_host_manager, profile_manager, socks_proxy_manager
from .backup_coordination import OperationBusyError, operation_lock
from .storage_errors import StorageCorruptionError

Expand All @@ -329,6 +331,10 @@ def connection_store_delete(
selector,
)
)
elif kind == 'socks-proxies':
deleted, error, _usages = (
socks_proxy_manager.delete_socks_proxy_recovery_record(user.id, selector)
)
else:
deleted, error, _usages = (
jump_host_manager.delete_jump_host_recovery_record(
Expand All @@ -346,7 +352,8 @@ def connection_store_delete(
kind=kind,
selector=selector,
)
label = 'profile' if kind == 'profiles' else 'jump-host'
label = {'profiles': 'profile', 'jump-hosts': 'jump-host',
'socks-proxies': 'SOCKS-proxy'}[kind]
click.echo(f'Deleted one {label} recovery record.')
except (OperationBusyError, StorageCorruptionError) as exc:
raise click.ClickException(str(exc)) from exc
Expand Down
26 changes: 23 additions & 3 deletions app/connection_storage_policy.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Prospective quotas for saved connection and jump-host metadata."""
"""Prospective quotas for saved connection and route metadata."""

from hashlib import sha256
import hmac
Expand All @@ -20,6 +20,8 @@ class ConnectionStorageLimitError(ValueError):
PROFILE_REFERENCE_MAX_BYTES = 128
PROFILE_STARTUP_MAX_BYTES = 64 * 1024
JUMP_HOST_NAME_MAX_BYTES = 512
SOCKS_PROXY_NAME_MAX_BYTES = 512
SOCKS_PROXY_USERNAME_MAX_BYTES = 255
_RECOVERY_SELECTOR_PATTERN = re.compile(
r'r1:(0|[1-9][0-9]{0,9}):([0-9a-f]{64})'
)
Expand Down Expand Up @@ -61,6 +63,7 @@ def validate_profile(profile, legacy=None):
('group', PROFILE_GROUP_MAX_BYTES, 'profile group'),
('key_id', PROFILE_REFERENCE_MAX_BYTES, 'key reference'),
('jump_host_id', PROFILE_REFERENCE_MAX_BYTES, 'jump-host reference'),
('socks_proxy_id', PROFILE_REFERENCE_MAX_BYTES, 'SOCKS-proxy reference'),
('command_id', PROFILE_REFERENCE_MAX_BYTES, 'command reference'),
('command_set_id', PROFILE_REFERENCE_MAX_BYTES, 'command-set reference'),
('startup_commands', PROFILE_STARTUP_MAX_BYTES, 'startup commands'),
Expand Down Expand Up @@ -99,6 +102,18 @@ def _file_size(path):
return 0


def validate_socks_proxy(proxy, legacy=None):
"""Enforce UTF-8 field budgets without storing proxy credentials."""
legacy = legacy if isinstance(legacy, dict) else {}
for field, maximum, label in (
('id', PROFILE_REFERENCE_MAX_BYTES, 'SOCKS-proxy id'),
('name', SOCKS_PROXY_NAME_MAX_BYTES, 'SOCKS-proxy name'),
('host', PROFILE_HOST_MAX_BYTES, 'SOCKS-proxy host'),
('username', SOCKS_PROXY_USERNAME_MAX_BYTES, 'SOCKS-proxy username'),
):
bounded_text(proxy.get(field), maximum, label, legacy.get(field))


def _serialize_document(document, *, compact=False):
try:
kwargs = {'separators': (',', ':')} if compact else {'indent': 2}
Expand Down Expand Up @@ -181,7 +196,8 @@ def resolve_recovery_record_selector(scope, records, selector):
def enforce_store_transition(
*,
path,
other_path,
other_path=None,
other_paths=(),
prospective_document,
prospective_count,
previous_count,
Expand All @@ -198,7 +214,11 @@ def enforce_store_transition(
"""
path = Path(path)
current_size = _file_size(path)
other_size = _file_size(other_path)
siblings = {Path(sibling).resolve() for sibling in other_paths}
if other_path is not None:
siblings.add(Path(other_path).resolve())
siblings.discard(path.resolve())
other_size = sum(_file_size(sibling) for sibling in siblings)
if prospective_count > maximum_count and (
previous_count is None or prospective_count > previous_count
):
Expand Down
48 changes: 33 additions & 15 deletions app/credential_connections.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,36 @@
from paramiko.auth_strategy import AuthStrategy


def _saved_route(data):
route = data.get('proxy_jump')
if route and (not isinstance(route, dict) or not isinstance(route.get('jump_host_id'), str)
or not route['jump_host_id']):
def _saved_route(data, kind='jump'):
field, reference = ('socks_proxy', 'socks_proxy_id') if kind == 'socks' else ('proxy_jump', 'jump_host_id')
route = data.get(field)
if route is not None and (not isinstance(route, dict) or not isinstance(route.get(reference), str)
or not route[reference]):
raise AutomationError('credential_target_changed')
return route.get('jump_host_id') if isinstance(route, dict) else None
return route.get(reference) if isinstance(route, dict) else None


def _connection_target(kind, data):
"""Keep the proxy endpoint distinct from a host's nested route."""
from .credential_service import CredentialService
connection = dict(data)
if kind in {'ssh', 'jump', 'socks'}:
connection['kind'] = kind
connection.setdefault('auth_type', 'password')
connection.setdefault('port', 1080 if kind == 'socks' else 22)
if kind == 'jump':
connection['jump_host_id'] = _saved_route(data)
connection = CredentialService._normalize_target(connection)
if kind == 'ssh':
# Only nested routes are consumed by the SSH transport. A claimed
# profile reference alone must not authorize a direct connection.
connection['jump_host_id'] = _saved_route(data)
socks_id = _saved_route(data, 'socks')
if socks_id:
connection['socks_proxy_id'] = socks_id
else:
connection.pop('socks_proxy_id', None)
return connection


class SavedPasswordAuth(AuthStrategy):
Expand All @@ -40,13 +64,11 @@ def __init__(self, user_id, kind, data):
raise AutomationError('authentication_required')
self.app = current_app._get_current_object()
self.service = self.app.extensions['credentials']
connection = deepcopy({key: value for key, value in data.items() if key in {
connection = _connection_target(kind, data)
connection = deepcopy({key: value for key, value in connection.items() if key in {
'host', 'port', 'username', 'auth_type', 'key_id', 'jump_host_id', 'domain', 'share',
'socks_proxy_id', 'remote_dns', 'kind',
}})
if kind in {'ssh', 'jump'}:
connection.setdefault('auth_type', 'password')
connection.setdefault('port', 22)
connection['jump_host_id'] = _saved_route(data)
self.target_token=data.get('credential_target_token')
self.target_args=(user_id,auth.id,data['credential_id'],connection,self.target_token)
self.args = (user_id, auth.id, kind, data['credential_id'], connection)
Expand Down Expand Up @@ -91,11 +113,7 @@ def connection_password(user_id, kind, data):
raise AutomationError('authentication_required')
if not isinstance(reference, str) or not reference or data.get('password'):
raise AutomationError('invalid_request')
connection = dict(data)
if kind in {'ssh', 'jump'}:
connection.setdefault('auth_type', 'password')
connection.setdefault('port', 22)
connection['jump_host_id'] = _saved_route(data)
connection = _connection_target(kind, data)
if data.get('credential_target_token'):
return current_app.extensions['credentials'].target_password(user_id,auth.id,reference,connection,data['credential_target_token'])
return current_app.extensions['credentials'].connection_password(
Expand Down
Loading
Loading