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
33 changes: 22 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,9 +230,15 @@ session from the WebSSH interface terminates its remote tmux session.

### Commands After Connecting

The connection dialog lets you select one optional, named **command set**. Use
**Create new** directly beside the selector, or open **Commands** and switch to
the **Command Sets** tab to manage all sets. The builder can search the complete
The connection dialog offers four explicit choices under **Run after
connecting**: nothing, one reusable **Command Set**, one saved **Command**, or
one-off **Free text**. Only the active mode is sent to the server. An exact
preview shows what will run before the connection is started. A selected
Command can use its saved parameters, an override, or an intentionally empty
override without modifying the library entry.

Open **Commands** to manage both reusable commands and sets. The **Command
Sets** tab is first and opens by default. The builder can search the complete
command library by name, command text, parameters, description, or category and
filter the results by operating system.

Expand All @@ -247,19 +253,24 @@ Steps can be reordered by drag and drop or by the accessible up/down buttons.
<img src="assets/command-sets.gif" alt="Command workflow showing full-text search, user commands, sudo-enabled command sets, multiline steps, ordering, and profile assignment" width="1100">
</p>

New command sets enable **Run commands with sudo** by default. When enabled,
WebSSH prefixes each non-empty resolved command line unless it already starts
with `sudo`; blank and comment-only lines remain unchanged. Existing command sets from an earlier version and sets produced by legacy conversion keep sudo disabled, so upgrading or converting does not change what runs.
**Run commands with sudo** is opt-in for new command sets. When enabled, WebSSH
prefixes each non-empty resolved command line unless it already starts with
`sudo`; blank and comment-only lines remain unchanged. Existing command sets
from an earlier version and sets produced by legacy conversion keep their saved
sudo setting, so upgrading or converting does not change what runs.

WebSSH does not store or answer a sudo password. If the remote account requires
one, its normal prompt appears in the terminal. The added prefixes count toward
the existing maximum 4096 characters for the resolved command text.

Profiles store only the selected command-set ID. Editing a set or one of its
referenced library commands therefore updates every profile that uses it. A set
cannot be deleted while a profile references it, and a user-created library
command cannot be deleted while a set references it. The UI reports the
profiles or sets that must be changed first.
Profiles are managed independently from connecting under the account menu.
They can be created, inspected, updated, or deleted without opening an SSH
session. A profile stores the selected post-connect mode and only its relevant
reference or free text; credentials are never stored. Editing a referenced set
or library command therefore updates every profile that uses its saved
definition. A set cannot be deleted while a profile references it, and a
user-created library command cannot be deleted while a set or profile
references it. The UI reports the profiles or sets that must be changed first.

After a new SSH connection succeeds, WebSSH resolves the latest referenced
commands on the server, validates the combined text (maximum 4096 characters),
Expand Down
8 changes: 7 additions & 1 deletion app/command_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,13 @@ def delete_user_command(user_id, command_id):
if error:
return False, error, []
if usages:
noun = 'command set' if len(usages) == 1 else 'command sets'
usage_types = {usage.get('type') for usage in usages}
if usage_types == {'command_set'}:
noun = 'command set' if len(usages) == 1 else 'command sets'
elif usage_types == {'profile'}:
noun = 'profile' if len(usages) == 1 else 'profiles'
else:
noun = 'reference' if len(usages) == 1 else 'references'
return False, f'Command is used by {len(usages)} {noun}', usages

with storage_lock(f'commands:{user_id}'):
Expand Down
58 changes: 50 additions & 8 deletions app/command_set_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -402,13 +402,8 @@ def duplicate_command_set(user_id, command_set_id):
return (copy.deepcopy(duplicate), None) if saved else (None, error)


def resolve_command_set(user_id, command_set_id):
command_set, error = get_command_set(user_id, command_set_id)
if error:
return None, error
commands, error = _command_index(user_id)
if error:
return None, error
def _resolve_loaded_command_set(command_set, commands):
"""Resolve one already-loaded set using an already-built command index."""
_steps, resolved_parts, error = _normalize_steps(
command_set.get('steps'), commands
)
Expand All @@ -432,6 +427,37 @@ def resolve_command_set(user_id, command_set_id):
return resolved, None


def resolve_command_set(user_id, command_set_id):
command_set, error = get_command_set(user_id, command_set_id)
if error:
return None, error
commands, error = _command_index(user_id)
if error:
return None, error
return _resolve_loaded_command_set(command_set, commands)


def load_command_sets_with_resolution(user_id):
"""Load sets with exact previews without repeating command-library reads."""
command_sets, error = load_command_sets(user_id)
if error:
return None, error
commands, error = _command_index(user_id)
if error:
return None, error

enriched = []
for command_set in command_sets:
item = copy.deepcopy(command_set)
resolved, resolution_error = _resolve_loaded_command_set(item, commands)
if resolution_error:
item['resolution_error'] = resolution_error
else:
item['resolved_command'] = resolved
enriched.append(item)
return enriched, None


def get_command_usage(user_id, command_id):
command_sets, error = load_command_sets(user_id)
if error:
Expand All @@ -441,7 +467,23 @@ def get_command_usage(user_id, command_id):
steps = command_set.get('steps', []) if isinstance(command_set, dict) else []
if any(step.get('type') == 'library' and step.get('command_id') == command_id
for step in steps if isinstance(step, dict)):
usages.append({'id': command_set.get('id'), 'name': command_set.get('name', '')})
usages.append({
'id': command_set.get('id'),
'name': command_set.get('name', ''),
'type': 'command_set',
})

profiles, error = _load_profile_references(user_id)
if error:
return None, error
for profile in profiles:
if (isinstance(profile, dict)
and profile.get('command_id') == command_id):
usages.append({
'id': profile.get('id'),
'name': profile.get('name', ''),
'type': 'profile',
})
return usages, None


Expand Down
166 changes: 166 additions & 0 deletions app/post_connect_manager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
"""Canonical validation and resolution for post-connect command modes."""

from . import command_manager, command_set_manager
from .startup_commands import normalize_startup_commands


VALID_MODES = {'none', 'free_text', 'command', 'command_set'}


def infer_mode(payload):
"""Return the explicit mode or infer one for legacy profile data."""
payload = payload if isinstance(payload, dict) else {}
explicit = payload.get('startup_mode')
if explicit is not None:
return explicit
if payload.get('command_set_id'):
return 'command_set'
if payload.get('command_id'):
return 'command'
if payload.get('startup_commands'):
return 'free_text'
return 'none'


def _command_index(user_id):
"""Return commands visible to one user, keyed by their stable IDs."""
try:
commands = command_manager.get_all_commands(user_id)
except (OSError, ValueError, TypeError):
return None, 'Command library is unreadable'

index = {}
for command in commands:
if not isinstance(command, dict):
continue
command_id = command.get('id')
command_text = command.get('command')
if isinstance(command_id, str) and isinstance(command_text, str):
index[command_id] = command
return index, None


def _get_owned_command(user_id, command_id):
if not isinstance(command_id, str) or not command_id:
return None, 'Command not found'
commands, error = _command_index(user_id)
if error:
return None, error
command = commands.get(command_id)
return (command, None) if command else (None, 'Command not found')


def _resolve_command(command, parameters_override=None, override_present=False):
parameters = parameters_override if override_present else command.get('parameters', '')
if parameters is None:
parameters = command.get('parameters', '')
if not isinstance(parameters, str):
return None, 'Command parameters must be a string'
if '\x00' in parameters:
return None, 'Commands cannot contain NUL bytes'

command_text = command['command']
resolved = command_text + (f' {parameters}' if parameters else '')
return normalize_startup_commands(resolved)


def _has_conflicting_references(mode, payload):
command_id = payload.get('command_id')
command_set_id = payload.get('command_set_id')
if mode == 'command':
return bool(command_set_id)
if mode == 'command_set':
return bool(command_id)
if mode == 'free_text':
return bool(command_id or command_set_id)
return False


def validate_configuration(user_id, payload):
"""Validate one mode and return only the fields safe to persist."""
if not isinstance(payload, dict):
return None, 'Invalid post-connect command configuration'

mode = infer_mode(payload)
if mode not in VALID_MODES:
return None, 'Invalid post-connect command mode'
if _has_conflicting_references(mode, payload):
return None, 'Conflicting post-connect command configuration'

if mode == 'none':
return {'startup_mode': 'none'}, None

if mode == 'free_text':
normalized, error = normalize_startup_commands(
payload.get('startup_commands', '')
)
if error:
return None, error
return {
'startup_mode': mode,
'startup_commands': normalized,
}, None

if mode == 'command':
command, error = _get_owned_command(user_id, payload.get('command_id'))
if error:
return None, error
override_present = 'parameters_override' in payload
override = payload.get('parameters_override')
if override is not None and not isinstance(override, str):
return None, 'Command parameters must be a string'
_resolved, error = _resolve_command(
command, override, override_present=override_present
)
if error:
return None, error
result = {
'startup_mode': mode,
'command_id': command['id'],
}
if override_present and override is not None:
result['parameters_override'] = override
return result, None

command_set, error = command_set_manager.get_command_set(
user_id, payload.get('command_set_id')
)
if error:
return None, error
return {
'startup_mode': mode,
'command_set_id': command_set['id'],
}, None


def resolve_configuration(user_id, payload):
"""Resolve validated configuration to the exact terminal command string."""
if not isinstance(payload, dict):
return None, 'Invalid post-connect command configuration'
mode = infer_mode(payload)
if mode not in VALID_MODES:
return None, 'Invalid post-connect command mode'
if _has_conflicting_references(mode, payload):
return None, 'Conflicting post-connect command configuration'

if mode == 'none':
return '', None
if mode == 'free_text':
return normalize_startup_commands(payload.get('startup_commands', ''))
if mode == 'command_set':
return command_set_manager.resolve_command_set(
user_id, payload.get('command_set_id')
)

command, error = _get_owned_command(user_id, payload.get('command_id'))
if error:
return None, error
override_present = 'parameters_override' in payload
override = payload.get('parameters_override')
if override is not None and not isinstance(override, str):
return None, 'Command parameters must be a string'
return _resolve_command(
command,
override,
override_present=override_present,
)
Loading
Loading