+
+ { setAddOpen(false); setShareAll(true); }} size='small'>
+ Add Samba Share
+
+
+
+ setNewName(value)}
+ />
+
+
+ setShareAll(checked)}
+ />
+
+ {!shareAll && (
+
+
+
+
+ )}
+
+ setNewReadOnly(checked)}
+ />
+
+
+
+ setNewComment(value)}
+ />
+
+
+
+
+
+
+
+
+
+ );
+}
+
+
function AdminControlsSection() {
const dockerized = useDockerized();
@@ -1089,6 +1301,7 @@ export function ControllerPage() {
+
diff --git a/app/src/components/admin/ControllerPage.test.js b/app/src/components/admin/ControllerPage.test.js
index 920f2e12..5de2faa5 100644
--- a/app/src/components/admin/ControllerPage.test.js
+++ b/app/src/components/admin/ControllerPage.test.js
@@ -34,12 +34,16 @@ jest.mock('../../api/controller', () => ({
getMounts: jest.fn().mockResolvedValue([]),
getSmartStatus: jest.fn().mockResolvedValue({}),
restartServices: jest.fn().mockResolvedValue({success: true}),
+ getSambaStatus: jest.fn().mockResolvedValue({enabled: false, available: true, shares: []}),
+ addSambaShare: jest.fn().mockResolvedValue({success: true}),
+ removeSambaShare: jest.fn().mockResolvedValue({success: true}),
}));
-// Mock useDockerized hook
+// Mock hooks
jest.mock('../../hooks/customHooks', () => ({
useDockerized: jest.fn().mockReturnValue(false),
+ useMediaDirectory: jest.fn().mockReturnValue('/media/wrolpi'),
useBluetooth: jest.fn().mockReturnValue({on: false, setBluetooth: jest.fn()}),
useHotspot: jest.fn().mockReturnValue({on: true, setHotspot: jest.fn()}),
useThrottle: jest.fn().mockReturnValue({on: false, setThrottle: jest.fn()}),
@@ -66,6 +70,9 @@ jest.mock('../Common', () => {
APIButton: ({children, onClick, disabled}) => (
),
+ 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