diff --git a/app/src/api/controller.js b/app/src/api/controller.js index 101db4b5..912d7902 100644 --- a/app/src/api/controller.js +++ b/app/src/api/controller.js @@ -107,6 +107,23 @@ export async function disableBluetooth() { return controllerFetch('/bluetooth/disable', {method: 'POST'}); } +// --- Samba Endpoints --- + +export async function getSambaStatus() { + return controllerFetch('/samba/status'); +} + +export async function addSambaShare(name, path, readOnly = true, comment = '') { + return controllerFetch('/samba/shares', { + method: 'POST', + body: JSON.stringify({name, path, read_only: readOnly, comment}), + }); +} + +export async function removeSambaShare(shareName) { + return controllerFetch(`/samba/shares/${encodeURIComponent(shareName)}`, {method: 'DELETE'}); +} + export async function shutdownSystem() { return controllerFetch('/shutdown', {method: 'POST'}); } diff --git a/app/src/components/admin/ControllerPage.js b/app/src/components/admin/ControllerPage.js index 671e9351..4873f351 100644 --- a/app/src/components/admin/ControllerPage.js +++ b/app/src/components/admin/ControllerPage.js @@ -1,20 +1,23 @@ import React from 'react'; import {Checkbox, Container, Dropdown, Form, Icon, Input} from "semantic-ui-react"; import {Button, Confirm, Header, Loader, Modal, Segment, Table} from "../Theme"; -import {APIButton, BluetoothToggle, HandPointMessage, HotspotToggle, InfoMessage, ThrottleToggle, Toggle,} from "../Common"; -import {useDockerized} from "../../hooks/customHooks"; +import {APIButton, BluetoothToggle, DirectorySearch, HandPointMessage, HotspotToggle, InfoMessage, ThrottleToggle, Toggle,} from "../Common"; +import {useDockerized, useMediaDirectory} from "../../hooks/customHooks"; import {Media} from "../../contexts/contexts"; import { addFstabEntry, + addSambaShare, disableService, enableService, getDisks, getFstabEntries, + getSambaStatus, getServiceLogs, getServices, getSmartStatus, mountDisk, removeFstabEntry, + removeSambaShare, restartService, restartServices, startService, @@ -1053,6 +1056,215 @@ function DiskSection() { } +function SambaSection() { + const [status, setStatus] = React.useState(null); + const [loading, setLoading] = React.useState(true); + const [actionLoading, setActionLoading] = React.useState(false); + const [addOpen, setAddOpen] = React.useState(false); + const [shareAll, setShareAll] = React.useState(true); + const [newName, setNewName] = React.useState(''); + const [newPath, setNewPath] = React.useState(''); + const [newReadOnly, setNewReadOnly] = React.useState(true); + const [newComment, setNewComment] = React.useState(''); + const [removeConfirmName, setRemoveConfirmName] = React.useState(null); + const dockerized = useDockerized(); + const mediaDirectory = useMediaDirectory(); + + const fetchStatus = async () => { + try { + const result = await getSambaStatus(); + setStatus(result); + } catch (e) { + console.error('Failed to fetch Samba status:', e); + } finally { + setLoading(false); + } + }; + + React.useEffect(() => { + fetchStatus(); + const interval = setInterval(fetchStatus, 10000); + return () => clearInterval(interval); + }, []); + + const handleAddShare = async () => { + if (!newName.trim()) return; + setActionLoading(true); + const absolutePath = shareAll + ? mediaDirectory + : `${mediaDirectory}/${newPath.trim()}`; + try { + await addSambaShare(newName.trim(), absolutePath, newReadOnly, newComment.trim()); + toast({type: 'success', title: 'Share Added', description: `Added share "${newName}"`, time: 3000}); + setAddOpen(false); + setShareAll(true); + setNewName(''); + setNewPath(''); + setNewReadOnly(true); + setNewComment(''); + await fetchStatus(); + } catch (e) { + toast({type: 'error', title: 'Add Share Failed', description: e.message, time: 5000}); + } finally { + setActionLoading(false); + } + }; + + const handleRemoveShare = async (name) => { + setActionLoading(true); + try { + await removeSambaShare(name); + toast({type: 'success', title: 'Share Removed', description: `Removed share "${name}"`, time: 3000}); + await fetchStatus(); + } catch (e) { + toast({type: 'error', title: 'Remove Share Failed', description: e.message, time: 5000}); + } finally { + setActionLoading(false); + setRemoveConfirmName(null); + } + }; + + if (loading) { + return ; + } + + if (!status || !status.available) { + if (dockerized) return null; + return ( + +
+ + Network Shares (Samba) +
+ Samba is not available on this system. +
+ ); + } + + const shares = status.shares || []; + + return ( + +
+ + Network Shares (Samba) +
+ +

Configure directories to share over the local network. Start the smbd service to activate sharing.

+ + {shares.length > 0 && ( + + + + Name + Path + Read Only + Comment + Actions + + + + {shares.map(share => ( + + {share.name} + {share.path} + {share.read_only ? 'Yes' : 'No'} + {share.comment} + +
+ )} + + setRemoveConfirmName(null)} + onConfirm={() => handleRemoveShare(removeConfirmName)} + /> + +
+
+ + { setAddOpen(false); setShareAll(true); }} size='small'> + Add Samba Share + +
+ + + setNewName(value)} + /> + + + setShareAll(checked)} + /> + + {!shareAll && ( + + + + + )} + + setNewReadOnly(checked)} + /> + + + + setNewComment(value)} + /> + +
+
+ + + ), + DirectorySearch: ({onSelect, value}) => ( + onSelect && onSelect(e.target.value)}/> + ), }; }); diff --git a/controller/api/admin.py b/controller/api/admin.py index c30b419c..83ba5b53 100644 --- a/controller/api/admin.py +++ b/controller/api/admin.py @@ -10,6 +10,10 @@ from controller.api.schemas import ( HotspotActionResponse, HotspotStatusResponse, RestartServicesResponse, + SambaShareAddRequest, + SambaShareRemoveResponse, + SambaShareResponse, + SambaStatusResponse, ServiceRestartResult, SystemActionResponse, ThrottleActionResponse, @@ -35,6 +39,11 @@ from controller.lib.admin import ( shutdown_system, ) from controller.lib.config import is_docker_mode +from controller.lib.samba import ( + add_share, + get_samba_status_dict, + remove_share, +) router = APIRouter(tags=["admin"]) @@ -91,6 +100,32 @@ async def bluetooth_disable() -> BluetoothActionResponse: return BluetoothActionResponse(**result) +# --- Samba --- + +@router.get("/api/samba/status", response_model=SambaStatusResponse) +async def samba_status() -> SambaStatusResponse: + """Get Samba sharing status.""" + return SambaStatusResponse(**get_samba_status_dict()) + + +@router.post("/api/samba/shares", response_model=SambaShareResponse) +async def samba_add_share(request: SambaShareAddRequest) -> SambaShareResponse: + """Add a Samba share.""" + result = add_share(request.name, request.path, request.read_only, request.comment or "") + if not result.get("success"): + raise HTTPException(status_code=400, detail=result.get("error", "Failed")) + return SambaShareResponse(**result) + + +@router.delete("/api/samba/shares/{share_name}", response_model=SambaShareRemoveResponse) +async def samba_remove_share(share_name: str) -> SambaShareRemoveResponse: + """Remove a Samba share.""" + result = remove_share(share_name) + if not result.get("success"): + raise HTTPException(status_code=404, detail=result.get("error", "Failed")) + return SambaShareRemoveResponse(**result) + + # --- Throttle --- @router.get("/api/throttle/status", response_model=ThrottleStatusResponse) diff --git a/controller/api/schemas.py b/controller/api/schemas.py index 6b3eede2..7d500258 100644 --- a/controller/api/schemas.py +++ b/controller/api/schemas.py @@ -185,6 +185,52 @@ class BluetoothActionResponse(BaseModel): error: Optional[str] = Field(default=None, description="Error message if failed") +# ============================================================================ +# Admin - Samba +# ============================================================================ + +class SambaShareInfo(BaseModel): + """Information about a single Samba share.""" + + name: str = Field(description="Share name") + path: str = Field(description="Filesystem path being shared") + read_only: bool = Field(description="Whether share is read-only") + comment: Optional[str] = Field(default="", description="Share description") + + +class SambaStatusResponse(BaseModel): + """Response model for /api/samba/status endpoint.""" + + enabled: bool = Field(description="Whether Samba is currently running") + available: bool = Field(description="Whether Samba functionality is available") + reason: Optional[str] = Field(default=None, description="Reason if unavailable") + shares: list[SambaShareInfo] = Field(default_factory=list, description="Configured shares") + + +class SambaShareAddRequest(BaseModel): + """Request model for adding a Samba share.""" + + name: str = Field(description="Share name (alphanumeric, hyphens, underscores)") + path: str = Field(description="Filesystem path to share") + read_only: bool = Field(default=True, description="Whether share is read-only") + comment: Optional[str] = Field(default="", description="Share description") + + +class SambaShareResponse(BaseModel): + """Response model for share add/update actions.""" + + success: bool = Field(description="Whether the action succeeded") + share: Optional[SambaShareInfo] = Field(default=None, description="The share") + error: Optional[str] = Field(default=None, description="Error message if failed") + + +class SambaShareRemoveResponse(BaseModel): + """Response model for removing a Samba share.""" + + success: bool = Field(description="Whether the share was removed") + error: Optional[str] = Field(default=None, description="Error message if failed") + + # ============================================================================ # Admin - Throttle # ============================================================================ diff --git a/controller/defaults.py b/controller/defaults.py index a4e9d6df..88c993a6 100644 --- a/controller/defaults.py +++ b/controller/defaults.py @@ -109,6 +109,23 @@ DEFAULT_CONFIG = { "use_https": False, "description": "Web proxy", }, + # Samba file sharing services + { + "name": "smbd", + "systemd_name": "smbd", + "port": 445, + "viewable": False, + "use_https": False, + "description": "Samba file sharing", + }, + { + "name": "nmbd", + "systemd_name": "nmbd", + "port": 137, + "viewable": False, + "use_https": False, + "description": "Samba NetBIOS name service", + }, # Upgrade service - only shown when running { "name": "wrolpi-upgrade", @@ -130,4 +147,9 @@ DEFAULT_CONFIG = { "throttle": { "default_governor": "ondemand", }, + + "samba": { + "shares": [], + # Each share: {"name": str, "path": str, "read_only": bool, "comment": str} + }, } diff --git a/controller/lib/samba.py b/controller/lib/samba.py new file mode 100644 index 00000000..45a201b1 --- /dev/null +++ b/controller/lib/samba.py @@ -0,0 +1,217 @@ +""" +Samba share management for WROLPi Controller. + +Manages smb.conf generation, smbd/nmbd service control, and share CRUD. +""" + +import enum +import logging +import platform +import re +import subprocess +from pathlib import Path + +from controller.lib.config import ( + get_config_value, + get_media_directory, + is_docker_mode, + save_config, + update_config, +) + +logger = logging.getLogger(__name__) + +SMB_CONF_PATH = Path("/etc/samba/smb.conf") + +# Share name must be alphanumeric with hyphens, underscores, or spaces. +SHARE_NAME_RE = re.compile(r'^[\w\s-]+$') + + +class SambaStatus(enum.Enum): + running = enum.auto() + stopped = enum.auto() + unavailable = enum.auto() + unknown = enum.auto() + + +def get_samba_status() -> SambaStatus: + """Get Samba service status.""" + if is_docker_mode(): + return SambaStatus.unavailable + + try: + result = subprocess.run( + ["systemctl", "is-active", "smbd"], + capture_output=True, text=True, timeout=5, + ) + active = result.stdout.strip() + if active == "active": + return SambaStatus.running + elif active in ("inactive", "dead"): + return SambaStatus.stopped + return SambaStatus.unknown + except FileNotFoundError: + return SambaStatus.unavailable + except (subprocess.TimeoutExpired, subprocess.SubprocessError): + return SambaStatus.unknown + + +def get_samba_status_dict() -> dict: + """Get Samba status as dict for API responses.""" + status = get_samba_status() + shares = get_config_value("samba.shares", []) + + reason = None + if status == SambaStatus.unavailable: + reason = "Samba not available (Docker mode or not installed)" + elif status == SambaStatus.unknown: + reason = "Could not determine Samba status" + + return { + "enabled": status == SambaStatus.running, + "available": status != SambaStatus.unavailable, + "reason": reason, + "shares": shares, + } + + +def _generate_smb_conf() -> str: + """Generate smb.conf content from config.""" + shares = get_config_value("samba.shares", []) + media_dir = str(get_media_directory()) + + hostname = platform.node() or "WROLPi" + + lines = [ + "[global]", + " workgroup = WORKGROUP", + f" server string = {hostname}", + " security = user", + " map to guest = Bad User", + " guest account = nobody", + " log file = /var/log/samba/log.%m", + " max log size = 1000", + " logging = file", + " server role = standalone server", + "", + ] + + for share in shares: + name = share.get("name", "share") + path = share.get("path", media_dir) + read_only = "yes" if share.get("read_only", True) else "no" + comment = share.get("comment", "") + + lines.extend([ + f"[{name}]", + f" comment = {comment}", + f" path = {path}", + " browseable = yes", + f" read only = {read_only}", + " guest ok = yes", + " create mask = 0644", + " directory mask = 0755", + "", + ]) + + return "\n".join(lines) + + +def _write_smb_conf() -> dict: + """Write smb.conf and reload Samba config.""" + try: + content = _generate_smb_conf() + SMB_CONF_PATH.write_text(content) + + # Reload config without full restart if Samba is running. + if get_samba_status() == SambaStatus.running: + subprocess.run( + ["smbcontrol", "all", "reload-config"], + capture_output=True, text=True, timeout=10, + ) + return {"success": True} + except PermissionError: + return {"success": False, "error": "Permission denied writing smb.conf"} + except (subprocess.SubprocessError, OSError) as e: + return {"success": False, "error": str(e)} + + +def _validate_share_path(path: str) -> str | None: + """Validate that a share path is under the media directory. Returns error message or None.""" + media_dir = get_media_directory() + try: + share_path = Path(path).resolve() + except (ValueError, OSError): + return f"Invalid path: {path}" + if not str(share_path).startswith(str(media_dir.resolve())): + return f"Share path must be under {media_dir}" + if not share_path.is_dir(): + return f"Path does not exist: {path}" + return None + + +def add_share(name: str, path: str, read_only: bool = True, comment: str = "") -> dict: + """Add a Samba share.""" + if is_docker_mode(): + return {"success": False, "error": "Not available in Docker mode"} + + if not SHARE_NAME_RE.match(name): + return {"success": False, "error": "Share name must be alphanumeric (hyphens/underscores/spaces allowed)"} + + path_error = _validate_share_path(path) + if path_error: + return {"success": False, "error": path_error} + + shares = get_config_value("samba.shares", []) + + # Check for duplicate name. + for s in shares: + if s["name"].lower() == name.lower(): + return {"success": False, "error": f"Share '{name}' already exists"} + + share_path = str(Path(path).resolve()) + new_share = {"name": name, "path": share_path, "read_only": read_only, "comment": comment} + shares.append(new_share) + update_config("samba.shares", shares) + save_config() + + # Write smb.conf so it's ready when user starts smbd. + _write_smb_conf() + + return {"success": True, "share": new_share} + + +def remove_share(name: str) -> dict: + """Remove a Samba share by name.""" + if is_docker_mode(): + return {"success": False, "error": "Not available in Docker mode"} + + shares = get_config_value("samba.shares", []) + new_shares = [s for s in shares if s["name"].lower() != name.lower()] + + if len(new_shares) == len(shares): + return {"success": False, "error": f"Share '{name}' not found"} + + update_config("samba.shares", new_shares) + save_config() + _write_smb_conf() + + return {"success": True} + + +def apply_samba_from_config(): + """Apply Samba config on startup. Called from lifespan. + + Writes smb.conf whenever shares are configured so the config is ready + when the user starts smbd from the services table. + """ + if is_docker_mode(): + return + + shares = get_config_value("samba.shares", []) + if shares: + result = _write_smb_conf() + if result["success"]: + logger.info("Applied Samba config with %d shares", len(shares)) + else: + logger.warning("Failed to apply Samba config: %s", result.get("error")) diff --git a/controller/main.py b/controller/main.py index a5d6e37e..41077450 100644 --- a/controller/main.py +++ b/controller/main.py @@ -77,6 +77,10 @@ async def lifespan(app: FastAPI): # Apply timezone from wrolpi.yaml to the system. from controller.lib.admin import apply_timezone_from_config apply_timezone_from_config() + + # Apply Samba config if enabled. + from controller.lib.samba import apply_samba_from_config + apply_samba_from_config() else: # Drive may be mounted but controller.yaml may not exist yet (e.g., the system # was set up before the Controller was introduced). Create the marker file. @@ -94,6 +98,9 @@ async def lifespan(app: FastAPI): from controller.lib.admin import apply_timezone_from_config apply_timezone_from_config() + + from controller.lib.samba import apply_samba_from_config + apply_samba_from_config() else: logger.info("Primary drive not mounted - using default configuration") diff --git a/controller/templates/index.html b/controller/templates/index.html index f8a39178..211f8e74 100644 --- a/controller/templates/index.html +++ b/controller/templates/index.html @@ -773,7 +773,7 @@ {% if docker_mode %}
- Running in Docker mode. Disk mounting, hotspot, Bluetooth, and shutdown are not available. + Running in Docker mode. Disk mounting, hotspot, Bluetooth, Samba, and shutdown are not available.
{% endif %} diff --git a/controller/test/test_defaults.py b/controller/test/test_defaults.py index c7321418..1411549f 100644 --- a/controller/test/test_defaults.py +++ b/controller/test/test_defaults.py @@ -57,7 +57,7 @@ class TestManagedServicesConfig: def test_managed_services_count(self): """Should have expected number of managed services.""" services = DEFAULT_CONFIG["managed_services"] - assert len(services) == 12 + assert len(services) == 14 @pytest.mark.parametrize("name,expected", [ ("wrolpi-api", diff --git a/controller/test/test_samba.py b/controller/test/test_samba.py new file mode 100644 index 00000000..8bd796af --- /dev/null +++ b/controller/test/test_samba.py @@ -0,0 +1,204 @@ +""" +Unit tests for controller.lib.samba module. +""" + +import subprocess +from unittest import mock + +from controller.lib.samba import ( + SambaStatus, + _generate_smb_conf, + add_share, + apply_samba_from_config, + get_samba_status, + get_samba_status_dict, + remove_share, +) + + +class TestGetSambaStatus: + """Tests for get_samba_status function.""" + + def test_returns_unavailable_in_docker_mode(self): + with mock.patch("controller.lib.samba.is_docker_mode", return_value=True): + assert get_samba_status() == SambaStatus.unavailable + + def test_returns_running_when_active(self): + mock_result = mock.Mock() + mock_result.stdout = "active\n" + with mock.patch("controller.lib.samba.is_docker_mode", return_value=False): + with mock.patch("subprocess.run", return_value=mock_result): + assert get_samba_status() == SambaStatus.running + + def test_returns_stopped_when_inactive(self): + mock_result = mock.Mock() + mock_result.stdout = "inactive\n" + with mock.patch("controller.lib.samba.is_docker_mode", return_value=False): + with mock.patch("subprocess.run", return_value=mock_result): + assert get_samba_status() == SambaStatus.stopped + + def test_returns_unavailable_when_systemctl_not_found(self): + with mock.patch("controller.lib.samba.is_docker_mode", return_value=False): + with mock.patch("subprocess.run", side_effect=FileNotFoundError()): + assert get_samba_status() == SambaStatus.unavailable + + def test_returns_unknown_on_timeout(self): + with mock.patch("controller.lib.samba.is_docker_mode", return_value=False): + with mock.patch("subprocess.run", side_effect=subprocess.TimeoutExpired("cmd", 5)): + assert get_samba_status() == SambaStatus.unknown + + +class TestGetSambaStatusDict: + """Tests for get_samba_status_dict function.""" + + def test_returns_expected_fields(self): + with mock.patch("controller.lib.samba.is_docker_mode", return_value=True): + result = get_samba_status_dict() + assert "enabled" in result + assert "available" in result + assert "shares" in result + + def test_unavailable_in_docker(self): + with mock.patch("controller.lib.samba.is_docker_mode", return_value=True): + result = get_samba_status_dict() + assert result["available"] is False + assert result["reason"] is not None + + +class TestGenerateSmbConf: + """Tests for _generate_smb_conf function.""" + + def test_generates_global_section(self, reset_runtime_config): + conf = _generate_smb_conf() + assert "[global]" in conf + assert "workgroup = WORKGROUP" in conf + assert "map to guest = Bad User" in conf + + def test_generates_share_sections(self, reset_runtime_config): + from controller.lib.config import update_config + update_config("samba.shares", [ + {"name": "TestShare", "path": "/media/wrolpi/test", "read_only": True, "comment": "Test"}, + ]) + conf = _generate_smb_conf() + assert "[TestShare]" in conf + assert "path = /media/wrolpi/test" in conf + assert "read only = yes" in conf + assert "guest ok = yes" in conf + + def test_read_write_share(self, reset_runtime_config): + from controller.lib.config import update_config + update_config("samba.shares", [ + {"name": "Writable", "path": "/media/wrolpi", "read_only": False, "comment": ""}, + ]) + conf = _generate_smb_conf() + assert "read only = no" in conf + + def test_empty_shares_produces_global_only(self, reset_runtime_config): + conf = _generate_smb_conf() + assert "[global]" in conf + # No share sections. + lines = conf.split("\n") + sections = [l for l in lines if l.startswith("[") and l != "[global]"] + assert len(sections) == 0 + + +class TestAddShare: + """Tests for add_share function.""" + + def test_fails_in_docker_mode(self): + with mock.patch("controller.lib.samba.is_docker_mode", return_value=True): + result = add_share("test", "/media/wrolpi") + assert result["success"] is False + + def test_rejects_invalid_name(self, reset_runtime_config): + with mock.patch("controller.lib.samba.is_docker_mode", return_value=False): + result = add_share("test/bad", "/media/wrolpi") + assert result["success"] is False + assert "alphanumeric" in result["error"] + + def test_rejects_path_outside_media_dir(self, reset_runtime_config, test_directory): + with mock.patch("controller.lib.samba.is_docker_mode", return_value=False): + with mock.patch("controller.lib.samba.get_media_directory", return_value=test_directory): + result = add_share("test", "/tmp") + assert result["success"] is False + assert "must be under" in result["error"] + + def test_rejects_nonexistent_path(self, reset_runtime_config, test_directory): + with mock.patch("controller.lib.samba.is_docker_mode", return_value=False): + with mock.patch("controller.lib.samba.get_media_directory", return_value=test_directory): + result = add_share("test", str(test_directory / "nonexistent")) + assert result["success"] is False + assert "does not exist" in result["error"] + + def test_rejects_duplicate_name(self, reset_runtime_config, test_directory): + from controller.lib.config import update_config + update_config("samba.shares", [ + {"name": "existing", "path": str(test_directory), "read_only": True, "comment": ""}, + ]) + with mock.patch("controller.lib.samba.is_docker_mode", return_value=False): + with mock.patch("controller.lib.samba.get_media_directory", return_value=test_directory): + with mock.patch("controller.lib.samba.save_config"): + with mock.patch("controller.lib.samba._write_smb_conf", return_value={"success": True}): + result = add_share("Existing", str(test_directory)) + assert result["success"] is False + assert "already exists" in result["error"] + + def test_adds_share_successfully(self, reset_runtime_config, test_directory): + with mock.patch("controller.lib.samba.is_docker_mode", return_value=False): + with mock.patch("controller.lib.samba.get_media_directory", return_value=test_directory): + with mock.patch("controller.lib.samba.save_config"): + with mock.patch("controller.lib.samba._write_smb_conf", return_value={"success": True}): + result = add_share("MyShare", str(test_directory), read_only=False, comment="My files") + assert result["success"] is True + assert result["share"]["name"] == "MyShare" + assert result["share"]["read_only"] is False + + +class TestRemoveShare: + """Tests for remove_share function.""" + + def test_removes_share(self, reset_runtime_config): + from controller.lib.config import update_config + update_config("samba.shares", [ + {"name": "test", "path": "/media/wrolpi", "read_only": True, "comment": ""}, + ]) + with mock.patch("controller.lib.samba.is_docker_mode", return_value=False): + with mock.patch("controller.lib.samba.save_config"): + with mock.patch("controller.lib.samba._write_smb_conf", return_value={"success": True}): + result = remove_share("test") + assert result["success"] is True + + from controller.lib.config import get_config_value + assert len(get_config_value("samba.shares", [])) == 0 + + def test_returns_error_for_missing_share(self, reset_runtime_config): + with mock.patch("controller.lib.samba.is_docker_mode", return_value=False): + result = remove_share("nonexistent") + assert result["success"] is False + assert "not found" in result["error"] + + +class TestApplySambaFromConfig: + """Tests for apply_samba_from_config function.""" + + def test_skips_in_docker_mode(self): + with mock.patch("controller.lib.samba.is_docker_mode", return_value=True): + with mock.patch("controller.lib.samba._write_smb_conf") as mock_write: + apply_samba_from_config() + mock_write.assert_not_called() + + def test_writes_config_when_shares_exist(self, reset_runtime_config): + from controller.lib.config import update_config + update_config("samba.shares", [ + {"name": "test", "path": "/media/wrolpi", "read_only": True, "comment": ""}, + ]) + with mock.patch("controller.lib.samba.is_docker_mode", return_value=False): + with mock.patch("controller.lib.samba._write_smb_conf", return_value={"success": True}) as mock_write: + apply_samba_from_config() + mock_write.assert_called_once() + + def test_skips_when_no_shares(self, reset_runtime_config): + with mock.patch("controller.lib.samba.is_docker_mode", return_value=False): + with mock.patch("controller.lib.samba._write_smb_conf") as mock_write: + apply_samba_from_config() + mock_write.assert_not_called() diff --git a/controller/test/test_samba_api.py b/controller/test/test_samba_api.py new file mode 100644 index 00000000..27023c4c --- /dev/null +++ b/controller/test/test_samba_api.py @@ -0,0 +1,55 @@ +""" +Integration tests for Controller Samba API endpoints. +""" + + +class TestSambaStatusEndpoint: + """Tests for /api/samba/status endpoint.""" + + def test_returns_200(self, test_client): + response = test_client.get("/api/samba/status") + assert response.status_code == 200 + + def test_returns_expected_fields(self, test_client): + response = test_client.get("/api/samba/status") + data = response.json() + assert "enabled" in data + assert "available" in data + assert "shares" in data + + def test_shows_unavailable_in_docker(self, test_client_docker_mode): + response = test_client_docker_mode.get("/api/samba/status") + data = response.json() + assert data["available"] is False + + +class TestSambaShareAddEndpoint: + """Tests for POST /api/samba/shares endpoint.""" + + def test_rejects_invalid_name(self, test_client): + response = test_client.post("/api/samba/shares", json={ + "name": "bad/name", + "path": "/media/wrolpi", + }) + assert response.status_code == 400 + + def test_fails_in_docker(self, test_client_docker_mode): + response = test_client_docker_mode.post("/api/samba/shares", json={ + "name": "test", + "path": "/media/wrolpi", + }) + assert response.status_code == 400 + + +class TestSambaShareRemoveEndpoint: + """Tests for DELETE /api/samba/shares/{name} endpoint.""" + + def test_returns_404_for_missing_share(self, test_client): + response = test_client.delete("/api/samba/shares/nonexistent") + assert response.status_code == 404 + + def test_fails_in_docker(self, test_client_docker_mode): + response = test_client_docker_mode.delete("/api/samba/shares/test") + assert response.status_code == 404 + + diff --git a/debian-live-config/config/package-lists/wrolpi.list.chroot b/debian-live-config/config/package-lists/wrolpi.list.chroot index 064193e7..89c5fbcc 100644 --- a/debian-live-config/config/package-lists/wrolpi.list.chroot +++ b/debian-live-config/config/package-lists/wrolpi.list.chroot @@ -88,6 +88,7 @@ python3-pip python3-venv renderd rsync +samba smartmontools software-properties-common sudo diff --git a/pi-gen/stage2/04-wrolpi/01-packages-nr b/pi-gen/stage2/04-wrolpi/01-packages-nr index 765212e1..5007e6f1 100644 --- a/pi-gen/stage2/04-wrolpi/01-packages-nr +++ b/pi-gen/stage2/04-wrolpi/01-packages-nr @@ -87,6 +87,7 @@ python3-pip python3-venv renderd rsync +samba smartmontools software-properties-common sysstat diff --git a/scripts/upgrade.sh b/scripts/upgrade.sh index d9bbcc2a..5cbe1bd3 100755 --- a/scripts/upgrade.sh +++ b/scripts/upgrade.sh @@ -114,5 +114,12 @@ if ! command -v caddy &>/dev/null; then apt-get install -y caddy fi +# Install Samba if not already installed. +if ! command -v smbd &>/dev/null; then + echo "Installing Samba..." + apt-get update + apt-get install -y samba +fi + # Install any configs, restart services. /opt/wrolpi/repair.sh