From 2b799fb035c82d162dd6e4b22f8345d14a6c6b4a Mon Sep 17 00:00:00 2001 From: Christos Miniotis Date: Mon, 4 May 2026 22:26:09 +0300 Subject: [PATCH 1/3] Add managed server hostname setting --- docs/network_file_sharing.md | 7 + docs/setup.md | 5 +- docs/uninstall.md | 5 + ...027-cloud-backup-setup-service-refactor.md | 19 ++ notes/0028-server-identity-hostname.md | 27 ++ .../adapters/server_identity_commands.py | 36 +++ simple_safer_server/app_factory.py | 8 + simple_safer_server/routes/server_identity.py | 50 ++++ simple_safer_server/routes/setup_wizard.py | 169 +++++------- simple_safer_server/services/container.py | 2 + .../services/server_identity.py | 256 ++++++++++++++++++ static/css/styles.css | 76 ++++++ templates/network_file_sharing.html | 134 ++++++++- templates/setup.html | 7 +- tests/test_server_identity.py | 159 +++++++++++ tests/test_server_identity_routes.py | 98 +++++++ tests/test_setup_wizard.py | 176 +++++++++++- tests/test_uninstall.py | 55 ++++ uninstall.sh | 53 ++++ 19 files changed, 1222 insertions(+), 120 deletions(-) create mode 100644 notes/0027-cloud-backup-setup-service-refactor.md create mode 100644 notes/0028-server-identity-hostname.md create mode 100644 simple_safer_server/adapters/server_identity_commands.py create mode 100644 simple_safer_server/routes/server_identity.py create mode 100644 simple_safer_server/services/server_identity.py create mode 100644 tests/test_server_identity.py create mode 100644 tests/test_server_identity_routes.py diff --git a/docs/network_file_sharing.md b/docs/network_file_sharing.md index 6aabef9..84c063a 100644 --- a/docs/network_file_sharing.md +++ b/docs/network_file_sharing.md @@ -1,6 +1,8 @@ # Network File Sharing The Network File Sharing page manages Samba shares and Samba service status. +It also includes the server name because that name is used when finding the +server on the network and in alert emails. SimpleSaferServer now distinguishes between two kinds of Samba shares: @@ -16,9 +18,14 @@ That ownership split matters because SimpleSaferServer only edits shares that it - **Edit Share**: updates a SimpleSaferServer-managed share - **Delete Share**: removes a SimpleSaferServer-managed share - **Unmanaged share warning**: appears when SimpleSaferServer detects other non-system Samba shares in `smb.conf` +- **Server Name**: changes the OS hostname, SimpleSaferServer's stored server name, + and the local hostname entry used by the server itself - **Restart Services**: restarts `smbd` and `nmbd` Restarting Samba disconnects anyone who is currently connected to a share, so active file copies or other SMB activity will drop. +Changing the server name also restarts Samba discovery/services so the new name is +advertised without rebooting. Connected file-sharing clients may need to reconnect. + If unmanaged shares are detected, the page shows a small warning button with the count. That button opens a modal that: diff --git a/docs/setup.md b/docs/setup.md index 4083a26..363972b 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -16,7 +16,10 @@ That split is important because the safety checks are different. ## Step 1: Create Admin Account - Enter the admin username. -- Enter the server name. +- Enter the server name. This is the name you use to find the server on your + network, and it appears in alert emails. +- Server names may contain letters, numbers, and hyphens. They cannot contain + spaces or start/end with a hyphen. - Enter and confirm the password. - On success, the wizard logs the user in and moves to the next step. - That first account becomes the initial administrator for the web UI. diff --git a/docs/uninstall.md b/docs/uninstall.md index ad0babc..2d76581 100644 --- a/docs/uninstall.md +++ b/docs/uninstall.md @@ -28,4 +28,9 @@ leaves unmanaged or legacy untagged Samba share blocks in `/etc/samba/smb.conf` If SimpleSaferServer configured Ubuntu Pro Livepatch, uninstall warns that Ubuntu Pro and Livepatch state are retained. Review that host-level subscription/security state manually after uninstall. +If SimpleSaferServer changed the server hostname, uninstall warns with the original, +last SimpleSaferServer-applied, and current hostnames when available. The uninstaller +does not automatically change the hostname or `/etc/hosts` back because those are +host-level identity settings that may still be in use after the app is removed. + This process is irreversible. Back up anything important before uninstalling. diff --git a/notes/0027-cloud-backup-setup-service-refactor.md b/notes/0027-cloud-backup-setup-service-refactor.md new file mode 100644 index 0000000..15ce7f1 --- /dev/null +++ b/notes/0027-cloud-backup-setup-service-refactor.md @@ -0,0 +1,19 @@ +# Cloud Backup Setup Service Refactor + +## Context + +The cloud backup management page already uses `CloudBackupService`, but the setup wizard still +implemented MEGA password obscuring, temporary rclone config generation, folder listing, folder +creation, and credential persistence inside the route module. + +## Decision + +Keep the existing setup wizard endpoint URLs and request field names as onboarding compatibility +wrappers. Delegate the actual cloud-backup behavior to `CloudBackupService` so onboarding and +post-setup management use the same rclone and configuration rules. + +## Follow-up Checks + +- Setup wizard tests should verify the legacy setup payloads are mapped to the shared service. +- Cloud backup service tests remain the behavior source for rclone command shape, timeouts, + credential write ordering, and advanced rclone persistence. diff --git a/notes/0028-server-identity-hostname.md b/notes/0028-server-identity-hostname.md new file mode 100644 index 0000000..85ba405 --- /dev/null +++ b/notes/0028-server-identity-hostname.md @@ -0,0 +1,27 @@ +# Server Identity Hostname + +## Context + +The setup wizard collected `system.server_name`, but that value was only stored in +SimpleSaferServer config and used by alert email scripts. Operators expected the +same name to also be the server name they connect to on the network. + +## Decision + +Server-name changes now go through a shared server-identity service. The service +validates one simple hostname format, updates `/etc/hosts`, applies the OS +hostname, persists SimpleSaferServer config, and restarts Samba discovery/services +when changed after setup. + +The original hostname is recorded once so uninstall can warn operators that the +host-level name was changed by SimpleSaferServer. Uninstall intentionally leaves +the current hostname and `/etc/hosts` in place because they are host identity, not +app-owned files. + +## Follow-up Checks + +- Setup and File Sharing should keep using the shared validation and helper text. +- If hostname policy changes, update the Python service, setup JavaScript, and + File Sharing JavaScript together. +- `uninstall.sh` should continue warning about managed hostname metadata before + deleting `/etc/SimpleSaferServer`. diff --git a/simple_safer_server/adapters/server_identity_commands.py b/simple_safer_server/adapters/server_identity_commands.py new file mode 100644 index 0000000..364abf7 --- /dev/null +++ b/simple_safer_server/adapters/server_identity_commands.py @@ -0,0 +1,36 @@ +from typing import Optional + +from simple_safer_server.adapters.command_runner import CommandRunner + +SERVER_IDENTITY_TIMEOUT_SECONDS = 30 + + +class ServerIdentityCommandAdapter: + """Wrap host identity commands behind one injectable boundary.""" + + def __init__(self, command_runner: Optional[CommandRunner] = None) -> None: + self._command_runner = command_runner or CommandRunner() + + def current_hostname(self) -> str: + result = self._command_runner.run( + ["hostname"], + capture_output=True, + text=True, + check=True, + timeout=SERVER_IDENTITY_TIMEOUT_SECONDS, + ) + return result.stdout.strip() + + def set_hostname(self, hostname: str) -> None: + self._command_runner.run( + ["hostnamectl", "set-hostname", hostname], + check=True, + timeout=SERVER_IDENTITY_TIMEOUT_SECONDS, + ) + + def restart_unit(self, unit_name: str) -> None: + self._command_runner.run( + ["systemctl", "restart", unit_name], + check=True, + timeout=SERVER_IDENTITY_TIMEOUT_SECONDS, + ) diff --git a/simple_safer_server/app_factory.py b/simple_safer_server/app_factory.py index 85ef278..e568e78 100644 --- a/simple_safer_server/app_factory.py +++ b/simple_safer_server/app_factory.py @@ -23,6 +23,7 @@ from simple_safer_server.routes.cloud_backup import cloud_backup as cloud_backup_routes from simple_safer_server.routes.ddns import ddns as ddns_routes from simple_safer_server.routes.drive_health import drive_health as drive_health_routes +from simple_safer_server.routes.server_identity import server_identity as server_identity_routes from simple_safer_server.routes.setup_wizard import setup from simple_safer_server.routes.smb import smb as smb_routes from simple_safer_server.routes.storage import storage as storage_routes @@ -36,6 +37,7 @@ from simple_safer_server.services.ddns_service import DdnsService from simple_safer_server.services.drive_health import DriveHealthSummaryService from simple_safer_server.services.runtime import get_fake_state, get_flask_secret_key, get_runtime +from simple_safer_server.services.server_identity import ServerIdentityService from simple_safer_server.services.smb_manager import SMB_DOCS_URL, SMBManager from simple_safer_server.services.storage_service import StorageService from simple_safer_server.services.system_updates import SystemUpdatesManager @@ -136,6 +138,10 @@ def create_app() -> Tuple[Flask, SocketIO]: config_manager=config_manager, system_utils=system_utils, ) + server_identity_service = ServerIdentityService( + config_manager=config_manager, + runtime=runtime, + ) storage_service = StorageService( runtime=runtime, fake_state=fake_state, @@ -156,6 +162,7 @@ def create_app() -> Tuple[Flask, SocketIO]: ddns_service=ddns_service, cloud_backup_service=cloud_backup_service, alerts_service=alerts_service, + server_identity_service=server_identity_service, storage_service=storage_service, drive_health_summary_service=drive_health_summary_service, ) @@ -166,6 +173,7 @@ def create_app() -> Tuple[Flask, SocketIO]: app.register_blueprint(cloud_backup_routes) app.register_blueprint(system_updates_routes) app.register_blueprint(alerts_routes) + app.register_blueprint(server_identity_routes) app.register_blueprint(smb_routes) app.register_blueprint(users_routes) app.register_blueprint(storage_routes) diff --git a/simple_safer_server/routes/server_identity.py b/simple_safer_server/routes/server_identity.py new file mode 100644 index 0000000..5c0a08f --- /dev/null +++ b/simple_safer_server/routes/server_identity.py @@ -0,0 +1,50 @@ +from typing import Any + +from flask import Blueprint, current_app + +from simple_safer_server.services.server_identity import ServerIdentityError +from simple_safer_server.services.user_manager import api_admin_required +from simple_safer_server.web.api import json_data, json_problem, json_request_data +from simple_safer_server.web.problems import OperationProblem, ValidationProblem + +server_identity = Blueprint("server_identity_routes", __name__) + + +def _get_services() -> Any: + """Return app-level services registered during Flask startup.""" + return current_app.extensions["simple_safer_server"] + + +@server_identity.route("/api/server_identity", methods=["GET"]) +@api_admin_required +def api_get_server_identity(): + try: + return json_data(_get_services().server_identity_service.current_identity()) + except Exception: + current_app.logger.exception("Failed to read server identity") + return json_problem(OperationProblem("Failed to read server name.")) + + +@server_identity.route("/api/server_identity", methods=["PUT"]) +@api_admin_required +def api_update_server_identity(): + try: + data = json_request_data() + result = _get_services().server_identity_service.update_server_name( + data.get("server_name"), + restart_samba=True, + ) + message = "Server name updated." + if result.warning: + message = f"{message} {result.warning}" + return json_data(result, message=message) + except ServerIdentityError as exc: + return json_problem(ValidationProblem(str(exc), slug="server-identity-validation-error")) + except Exception: + current_app.logger.exception("Failed to update server identity") + return json_problem( + OperationProblem( + "Failed to update server name.", + slug="server-identity-operation-failed", + ) + ) diff --git a/simple_safer_server/routes/setup_wizard.py b/simple_safer_server/routes/setup_wizard.py index 06aa114..27a4848 100644 --- a/simple_safer_server/routes/setup_wizard.py +++ b/simple_safer_server/routes/setup_wizard.py @@ -4,9 +4,8 @@ import time from datetime import datetime from functools import wraps -from tempfile import NamedTemporaryFile -from flask import Blueprint, redirect, render_template, session +from flask import Blueprint, current_app, redirect, render_template, session from simple_safer_server.adapters.command_runner import CalledProcessError, SubprocessError from simple_safer_server.adapters.setup_commands import SetupCommandAdapter @@ -31,6 +30,11 @@ ScheduleTimeError, normalize_ui_schedule_time, ) +from simple_safer_server.services.server_identity import ( + SERVER_NAME_HELP_TEXT, + ServerIdentityError, + ServerIdentityService, +) from simple_safer_server.services.smb_manager import SMBManager from simple_safer_server.services.system_utils import SystemUtils from simple_safer_server.services.user_manager import UserManager @@ -50,6 +54,7 @@ system_utils = SystemUtils(runtime=runtime) user_manager = UserManager(runtime=runtime) smb_manager = SMBManager(runtime=runtime) +server_identity_service = ServerIdentityService(config_manager=config_manager, runtime=runtime) setup_command_adapter = SetupCommandAdapter() logger = logging.getLogger(__name__) @@ -68,6 +73,19 @@ def _operation_problem(message, **extra): return json_problem(OperationProblem(message, slug='setup-operation-failed', extra=extra)) +def _cloud_backup_service(): + """Return the shared cloud-backup service registered by the app factory.""" + return current_app.extensions["simple_safer_server"].cloud_backup_service + + +def _server_identity_service(): + """Return the shared server-identity service, with test fallback support.""" + services = current_app.extensions.get("simple_safer_server") + if services is not None and hasattr(services, "server_identity_service"): + return services.server_identity_service + return server_identity_service + + def _valid_tcp_port(value): text = str(value or '').strip() if not text.isdigit(): @@ -243,16 +261,16 @@ def setup_page(): if missing_fields: logger.info(f"Setup incomplete, missing fields: {missing_fields}") - return render_template('setup.html') + return render_template('setup.html', server_name_help_text=SERVER_NAME_HELP_TEXT) # Do NOT mark setup as complete here. Only do so in /api/setup/complete. - return render_template('setup.html') + return render_template('setup.html', server_name_help_text=SERVER_NAME_HELP_TEXT) except ApiProblem: raise except Exception as e: logger.error(f"Error checking setup status: {e}") - return render_template('setup.html') + return render_template('setup.html', server_name_help_text=SERVER_NAME_HELP_TEXT) @setup.route('/api/setup/user', methods=['POST']) @@ -581,7 +599,7 @@ def mount_drive(): @setup.route('/api/setup/rclone', methods=['POST']) @setup_api_access_required def setup_rclone(): - """Set up rclone configuration""" + """Set up advanced rclone configuration through the shared backup service.""" try: data = json_request_data() config = data.get('config') @@ -590,12 +608,15 @@ def setup_rclone(): if not config or not remote_name: return _validation_problem('Config and remote name are required') - # Store rclone config - if not system_utils.setup_rclone(config): - return _operation_problem('Failed to set up rclone') - - # Save to config - config_manager.set_value('backup', 'rclone_dir', remote_name) + # Setup keeps its historical field names; the cloud-backup service owns + # rclone persistence and mode flags for every cloud configuration flow. + _cloud_backup_service().save_config( + { + 'cloud_mode': 'advanced', + 'rclone_config': config, + 'remote_name': remote_name, + } + ) return json_data() except ApiProblem: @@ -860,8 +881,10 @@ def setup_system_info(): 'Username must match the admin account created during setup' ) - config_manager.set_value('system', 'server_name', server_name) + _server_identity_service().update_server_name(server_name, restart_samba=False) return json_data() + except ServerIdentityError as e: + return _validation_problem(str(e)) except ApiProblem: raise except Exception as e: @@ -872,39 +895,17 @@ def setup_system_info(): @setup.route('/api/setup/mega/connect', methods=['POST']) @setup_api_access_required def mega_connect(): - """Authenticate with MEGA and list folders using rclone.""" + """Authenticate with MEGA and return root folders using the shared service.""" try: data = json_request_data() email = data.get('email') password = data.get('password') if not email or not password: return _validation_problem('Email and password are required.') - # Obscure password using rclone - obscured_pw = setup_command_adapter.obscure_rclone_password(password) - # Build temp rclone config - config_text = f""" -[mega] -type = mega -user = {email} -pass = {obscured_pw} -""" - with NamedTemporaryFile( - delete=False, mode="w", prefix="rclone-", suffix=".conf" - ) as config_file: - config_file.write(config_text) - config_path = config_file.name - try: - # List folders at root - lsjson = setup_command_adapter.rclone_lsjson("mega:/", config_path) - if lsjson.returncode != 0: - return _validation_problem('Failed to list MEGA folders. Check credentials.') - import json as pyjson - - items = pyjson.loads(lsjson.stdout) - folders = [item['Name'] for item in items if item['IsDir']] - return json_data({'folders': folders}) - finally: - os.remove(config_path) + folder_list = _cloud_backup_service().list_mega_folders( + {'email': email, 'password': password, 'path': '/'} + ) + return json_data({'folders': folder_list.folders}) except ApiProblem: raise except Exception as e: @@ -915,7 +916,7 @@ def mega_connect(): @setup.route('/api/setup/mega/list_folders', methods=['POST']) @setup_api_access_required def mega_list_folders(): - """List folders at a given MEGA path using rclone.""" + """List folders at a given MEGA path using the shared service.""" try: data = json_request_data() path = data.get('path', '/') @@ -923,35 +924,10 @@ def mega_list_folders(): password = data.get('password') if not email or not password: return _validation_problem('Email and password are required.') - obscured_pw = setup_command_adapter.obscure_rclone_password(password) - config_text = f""" -[mega] -type = mega -user = {email} -pass = {obscured_pw} -""" - from tempfile import NamedTemporaryFile - - with NamedTemporaryFile( - delete=False, mode="w", prefix="rclone-", suffix=".conf" - ) as config_file: - config_file.write(config_text) - config_path = config_file.name - try: - lsjson = setup_command_adapter.rclone_lsjson(f"mega:{path}", config_path) - if lsjson.returncode != 0: - return _validation_problem( - 'Failed to list MEGA folders. Check credentials or path.' - ) - import json as pyjson - - items = pyjson.loads(lsjson.stdout) - folders = [item['Name'] for item in items if item['IsDir']] - # Compute parent path - parent = '/'.join(path.rstrip('/').split('/')[:-1]) or '/' - return json_data({'folders': folders, 'path': path, 'parent': parent}) - finally: - os.remove(config_path) + folder_list = _cloud_backup_service().list_mega_folders( + {'email': email, 'password': password, 'path': path} + ) + return json_data(folder_list) except ApiProblem: raise except Exception as e: @@ -962,7 +938,7 @@ def mega_list_folders(): @setup.route('/api/setup/mega/create_folder', methods=['POST']) @setup_api_access_required def mega_create_folder_picker(): - """Create a new folder at a given MEGA path using rclone.""" + """Create a new MEGA folder using the shared service.""" try: data = json_request_data() folder_name = data.get('folder_name') @@ -971,29 +947,10 @@ def mega_create_folder_picker(): password = data.get('password') if not folder_name or not email or not password: return _validation_problem('Folder name, email, and password are required.') - obscured_pw = setup_command_adapter.obscure_rclone_password(password) - config_text = f""" -[mega] -type = mega -user = {email} -pass = {obscured_pw} -""" - from tempfile import NamedTemporaryFile - - with NamedTemporaryFile( - delete=False, mode="w", prefix="rclone-", suffix=".conf" - ) as config_file: - config_file.write(config_text) - config_path = config_file.name - try: - # Create folder at mega:{path}/{folder_name} - full_path = f"{path.rstrip('/')}/{folder_name}" if path != '/' else f"/{folder_name}" - mkdir = setup_command_adapter.rclone_mkdir(f"mega:{full_path}", config_path) - if mkdir.returncode != 0: - return _validation_problem('Failed to create folder on MEGA.') - return json_data() - finally: - os.remove(config_path) + _cloud_backup_service().create_mega_folder( + {'email': email, 'password': password, 'path': path, 'folder_name': folder_name} + ) + return json_data() except ApiProblem: raise except Exception as e: @@ -1004,7 +961,7 @@ def mega_create_folder_picker(): @setup.route('/api/setup/mega/save', methods=['POST']) @setup_api_access_required def mega_save(): - """Save MEGA config (obscured password, selected folder) securely and write rclone config.""" + """Save MEGA credentials and selected folder through the shared backup service.""" try: data = json_request_data() email = data.get('email') @@ -1012,18 +969,16 @@ def mega_save(): folder = data.get('folder') if not email or not password or not folder: return _validation_problem('Email, password, and folder are required.') - obscured_pw = setup_command_adapter.obscure_rclone_password(password) - # Write rclone config for MEGA - mega_rclone_config = f"""[mega]\ntype = mega\nuser = {email}\npass = {obscured_pw}\n""" - if not system_utils.setup_rclone(mega_rclone_config): - return _operation_problem('Failed to write rclone config for MEGA') - # Persist after rclone is written so the UI does not advertise a cloud - # target that the backup scripts cannot actually use. - config_manager.set_value('backup', 'cloud_mode', 'mega') - config_manager.set_value('backup', 'mega_email', email) - config_manager.set_value('backup', 'mega_pass', obscured_pw) - config_manager.set_value('backup', 'mega_folder', folder) - config_manager.set_value('backup', 'rclone_dir', f"mega:{folder}") + # Setup keeps its concise field names; the service keeps write ordering + # consistent with post-onboarding cloud-backup management. + _cloud_backup_service().save_config( + { + 'cloud_mode': 'mega', + 'mega_email': email, + 'mega_password': password, + 'mega_folder': folder, + } + ) return json_data() except ApiProblem: raise diff --git a/simple_safer_server/services/container.py b/simple_safer_server/services/container.py index f3f8b7a..760b27f 100644 --- a/simple_safer_server/services/container.py +++ b/simple_safer_server/services/container.py @@ -6,6 +6,7 @@ from simple_safer_server.services.cloud_backup_service import CloudBackupService from simple_safer_server.services.ddns_service import DdnsService from simple_safer_server.services.drive_health import DriveHealthSummaryService +from simple_safer_server.services.server_identity import ServerIdentityService from simple_safer_server.services.storage_service import StorageService from simple_safer_server.services.task_service import TaskService @@ -26,5 +27,6 @@ class AppServices: ddns_service: DdnsService cloud_backup_service: CloudBackupService alerts_service: AlertsService + server_identity_service: ServerIdentityService storage_service: StorageService drive_health_summary_service: DriveHealthSummaryService diff --git a/simple_safer_server/services/server_identity.py b/simple_safer_server/services/server_identity.py new file mode 100644 index 0000000..4bb9538 --- /dev/null +++ b/simple_safer_server/services/server_identity.py @@ -0,0 +1,256 @@ +import logging +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any, List, Optional, Tuple + +from simple_safer_server.adapters.server_identity_commands import ( + ServerIdentityCommandAdapter, +) +from simple_safer_server.services.file_persistence import atomic_write_text +from simple_safer_server.services.runtime import get_runtime + +LOGGER = logging.getLogger(__name__) + +SERVER_NAME_HELP_TEXT = ( + "This is the name you'll use to find this server on your network, " + "and it appears in alert emails." +) +SERVER_NAME_VALIDATION_MESSAGE = ( + "Server name may only contain letters, numbers, and hyphens, and cannot " + "start or end with a hyphen." +) +MAX_HOSTNAME_LENGTH = 63 +LOCAL_HOSTS_ADDRESSES = {"127.0.0.1", "127.0.1.1"} +SERVER_NAME_PATTERN = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$") + + +class ServerIdentityError(ValueError): + """Raised when a server-name change cannot be applied safely.""" + + +@dataclass(frozen=True) +class ServerIdentity: + server_name: str + hostname: str + help_text: str = SERVER_NAME_HELP_TEXT + + def as_dict(self) -> Any: + return { + "server_name": self.server_name, + "hostname": self.hostname, + "help_text": self.help_text, + } + + +@dataclass(frozen=True) +class ServerIdentityUpdateResult: + server_name: str + hostname: str + warning: str = "" + + def as_dict(self) -> Any: + payload = { + "server_name": self.server_name, + "hostname": self.hostname, + } + if self.warning: + payload["warning"] = self.warning + return payload + + +def normalize_server_name(value: Any) -> str: + """Normalize the UI server name into a single-label Linux hostname.""" + if not isinstance(value, str): + raise ServerIdentityError(SERVER_NAME_VALIDATION_MESSAGE) + normalized = value.strip().lower() + if not normalized: + raise ServerIdentityError("Server name is required.") + if len(normalized) > MAX_HOSTNAME_LENGTH: + raise ServerIdentityError(f"Server name must be {MAX_HOSTNAME_LENGTH} characters or fewer.") + if not SERVER_NAME_PATTERN.match(normalized): + raise ServerIdentityError(SERVER_NAME_VALIDATION_MESSAGE) + return normalized + + +def _split_hosts_line(line: str) -> Tuple[str, str]: + if "#" not in line: + return line.rstrip("\n"), "" + body, comment = line.rstrip("\n").split("#", 1) + return body.rstrip(), "#" + comment + + +def _join_hosts_line(address: str, names: List[str], comment: str) -> str: + body = address + if names: + body = "{}\t{}".format(address, " ".join(names)) + if comment: + body = f"{body} {comment}" + return f"{body.rstrip()}\n" + + +def update_hosts_content(content: str, old_hostname: str, new_hostname: str) -> str: + """Return `/etc/hosts` content with the local hostname entry updated. + + Only loopback hostname entries are touched. Unrelated addresses, aliases, + comments, and custom host mappings are preserved because `/etc/hosts` is + often hand-edited on home servers. + """ + old_hostname = old_hostname.strip().lower() + new_hostname = new_hostname.strip().lower() + output = [] + updated_local_name = False + saw_new_name = False + + for line in content.splitlines(True): + body, comment = _split_hosts_line(line) + tokens = body.split() + if not tokens: + output.append(line) + continue + + address = tokens[0] + names = tokens[1:] + if address not in LOCAL_HOSTS_ADDRESSES: + output.append(line) + continue + + lowered_names = [name.lower() for name in names] + if new_hostname in lowered_names: + saw_new_name = True + + should_replace = old_hostname and old_hostname in lowered_names + should_fill_127_0_1_1 = address == "127.0.1.1" and not names + if should_replace or should_fill_127_0_1_1: + replacement_names = [] + inserted = False + for name in names: + if name.lower() == old_hostname: + if not inserted: + replacement_names.append(new_hostname) + inserted = True + continue + if name.lower() == new_hostname: + inserted = True + replacement_names.append(name) + if not inserted: + replacement_names.insert(0, new_hostname) + output.append(_join_hosts_line(address, replacement_names, comment)) + updated_local_name = True + saw_new_name = True + continue + + output.append(line) + + if not updated_local_name and not saw_new_name: + output.append(f"127.0.1.1\t{new_hostname}\n") + + return "".join(output) + + +class ServerIdentityService: + """Coordinates server-name config, OS hostname, hosts, and Samba discovery.""" + + def __init__( + self, + config_manager: Any, + runtime: Optional[Any] = None, + command_adapter: Optional[Any] = None, + hosts_path: Optional[Path] = None, + ) -> None: + self.config_manager = config_manager + self.runtime = runtime or get_runtime() + self.command_adapter = command_adapter or ServerIdentityCommandAdapter() + self.hosts_path = hosts_path or Path("/etc/hosts") + + def current_identity(self) -> ServerIdentity: + configured_name = self.config_manager.get_value("system", "server_name", "") + hostname = configured_name + if not self.runtime.is_fake: + try: + hostname = self.command_adapter.current_hostname() + except Exception: + LOGGER.exception("Failed to read current hostname") + return ServerIdentity(server_name=configured_name or hostname, hostname=hostname) + + def update_server_name( + self, value: Any, *, restart_samba: bool = True + ) -> ServerIdentityUpdateResult: + new_hostname = normalize_server_name(value) + current_hostname = self._current_hostname_for_update() + previous_hosts_content = None + + if not self.runtime.is_fake: + previous_hosts_content = self._update_hosts_file(current_hostname, new_hostname) + try: + self.command_adapter.set_hostname(new_hostname) + except Exception as exc: + self._restore_hosts_file(previous_hosts_content) + raise ServerIdentityError("Could not update the system hostname.") from exc + + self._persist_hostname_metadata(current_hostname, new_hostname) + + warning = "" + if restart_samba: + warning = self._restart_samba_services() + + return ServerIdentityUpdateResult( + server_name=new_hostname, + hostname=new_hostname, + warning=warning, + ) + + def _current_hostname_for_update(self) -> str: + if self.runtime.is_fake: + return self.config_manager.get_value( + "system", "applied_hostname", "" + ) or self.config_manager.get_value("system", "server_name", "") + return self.command_adapter.current_hostname() + + def _update_hosts_file(self, old_hostname: str, new_hostname: str) -> str: + try: + content = self.hosts_path.read_text(encoding="utf-8") + except FileNotFoundError: + content = "" + updated = update_hosts_content(content, old_hostname, new_hostname) + if updated != content: + backup_path = self.hosts_path.with_name(f"{self.hosts_path.name}.SimpleSaferServer.bak") + if content and not backup_path.exists(): + atomic_write_text(backup_path, content, mode=0o644) + atomic_write_text(self.hosts_path, updated, mode=0o644) + return content + + def _restore_hosts_file(self, content: Optional[str]) -> None: + if content is None: + return + try: + atomic_write_text(self.hosts_path, content, mode=0o644) + except Exception: + LOGGER.exception("Failed to restore %s after hostname update failure", self.hosts_path) + + def _persist_hostname_metadata(self, original_hostname: str, new_hostname: str) -> None: + if ( + str(self.config_manager.get_value("system", "hostname_managed", "false")).lower() + != "true" + ): + self.config_manager.set_value("system", "original_hostname", original_hostname) + self.config_manager.set_value("system", "server_name", new_hostname) + self.config_manager.set_value("system", "hostname_managed", "true") + self.config_manager.set_value("system", "applied_hostname", new_hostname) + + def _restart_samba_services(self) -> str: + if self.runtime.is_fake: + return "" + failed_units = [] + for unit_name in ("smbd", "nmbd"): + try: + self.command_adapter.restart_unit(unit_name) + except Exception: + LOGGER.exception("Failed to restart %s after hostname change", unit_name) + failed_units.append(unit_name) + if failed_units: + return ( + "Server name was updated, but file sharing services did not restart cleanly. " + "Restart Samba manually if the old name still appears on the network." + ) + return "" diff --git a/static/css/styles.css b/static/css/styles.css index 4a20a9b..ba25784 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -610,6 +610,82 @@ body.fake-mode-active .toast-stack { } .btn-ghost:hover { background: var(--bg-hover); color: var(--text-primary); } +/* Keep this identity strip compact; it sits between status controls and the shares table. */ +.server-identity-section { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-4); + min-height: 44px; + padding: var(--sp-2) var(--sp-3); + background: hsla(220, 16%, 12%, 0.58); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + backdrop-filter: blur(18px); + -webkit-backdrop-filter: blur(18px); +} + +.server-identity-main { + display: flex; + align-items: center; + gap: var(--sp-3); + min-width: 0; +} + +.server-identity-mark { + width: 28px; + height: 28px; + flex: 0 0 28px; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--text-muted); + background: var(--bg-overlay); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); +} + +.server-identity-copy { + display: flex; + align-items: baseline; + gap: var(--sp-2); + min-width: 0; +} + +.server-identity-title { + color: var(--text-muted); + font-size: var(--text-xs); + font-weight: var(--weight-medium); + white-space: nowrap; +} + +.server-identity-value { + color: var(--text-primary); + font-size: var(--text-sm); + font-weight: var(--weight-semi); + max-width: min(30vw, 24rem); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +@media (max-width: 640px) { + .server-identity-section { + gap: var(--sp-3); + padding-right: var(--sp-2); + } + + .server-identity-copy { + align-items: flex-start; + flex-direction: column; + gap: 0; + } + + .server-identity-value { + max-width: 100%; + } +} + .btn-outline-primary { background: transparent; color: var(--accent-text); diff --git a/templates/network_file_sharing.html b/templates/network_file_sharing.html index 31725b5..698722e 100644 --- a/templates/network_file_sharing.html +++ b/templates/network_file_sharing.html @@ -7,9 +7,9 @@ {% endblock %} {% block content %} - -
-
+ +
+
Checking… Checking system status…
@@ -29,10 +29,9 @@
-
-
-

Service Details

-
+
+
+
Checking… SMB Daemon (smbd) @@ -51,6 +50,21 @@

+
+ +
+ Server Name + Loading… +
+
+ + +
@@ -92,6 +106,39 @@

SMB Shares

+ + +