mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
Samba is installed and can be configured in the Controller.
This commit is contained in:
parent
0ceac364ec
commit
ce0cdd3a2e
15 changed files with 837 additions and 5 deletions
|
|
@ -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'});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <Segment><Loader active inline='centered'/></Segment>;
|
||||
}
|
||||
|
||||
if (!status || !status.available) {
|
||||
if (dockerized) return null;
|
||||
return (
|
||||
<Segment>
|
||||
<Header as='h3'>
|
||||
<Icon name='folder open'/>
|
||||
Network Shares (Samba)
|
||||
</Header>
|
||||
<Message info>Samba is not available on this system.</Message>
|
||||
</Segment>
|
||||
);
|
||||
}
|
||||
|
||||
const shares = status.shares || [];
|
||||
|
||||
return (
|
||||
<Segment>
|
||||
<Header as='h3'>
|
||||
<Icon name='folder open'/>
|
||||
Network Shares (Samba)
|
||||
</Header>
|
||||
|
||||
<p>Configure directories to share over the local network. Start the smbd service to activate sharing.</p>
|
||||
|
||||
{shares.length > 0 && (
|
||||
<Table celled style={{marginTop: '1em'}}>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell>Name</Table.HeaderCell>
|
||||
<Table.HeaderCell>Path</Table.HeaderCell>
|
||||
<Table.HeaderCell>Read Only</Table.HeaderCell>
|
||||
<Table.HeaderCell>Comment</Table.HeaderCell>
|
||||
<Table.HeaderCell>Actions</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{shares.map(share => (
|
||||
<Table.Row key={share.name}>
|
||||
<Table.Cell>{share.name}</Table.Cell>
|
||||
<Table.Cell><code>{share.path}</code></Table.Cell>
|
||||
<Table.Cell>{share.read_only ? 'Yes' : 'No'}</Table.Cell>
|
||||
<Table.Cell>{share.comment}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Button
|
||||
size='tiny'
|
||||
color='red'
|
||||
icon='trash'
|
||||
disabled={actionLoading}
|
||||
onClick={() => setRemoveConfirmName(share.name)}
|
||||
/>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
<Confirm
|
||||
open={removeConfirmName !== null}
|
||||
header='Remove Share'
|
||||
content={`Remove the share "${removeConfirmName}"?`}
|
||||
confirmButton='Remove'
|
||||
onCancel={() => setRemoveConfirmName(null)}
|
||||
onConfirm={() => handleRemoveShare(removeConfirmName)}
|
||||
/>
|
||||
|
||||
<div style={{marginTop: '1em'}}>
|
||||
<Button
|
||||
icon='plus'
|
||||
content='Add Share'
|
||||
onClick={() => setAddOpen(true)}
|
||||
disabled={actionLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Modal open={addOpen} onClose={() => { setAddOpen(false); setShareAll(true); }} size='small'>
|
||||
<Modal.Header>Add Samba Share</Modal.Header>
|
||||
<Modal.Content>
|
||||
<Form>
|
||||
<Form.Field>
|
||||
<label>Share Name</label>
|
||||
<Input
|
||||
placeholder='e.g. Documents'
|
||||
value={newName}
|
||||
onChange={(e, {value}) => setNewName(value)}
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<Checkbox
|
||||
label='Share all files'
|
||||
checked={shareAll}
|
||||
onChange={(e, {checked}) => setShareAll(checked)}
|
||||
/>
|
||||
</Form.Field>
|
||||
{!shareAll && (
|
||||
<Form.Field>
|
||||
<label>Path</label>
|
||||
<DirectorySearch
|
||||
onSelect={setNewPath}
|
||||
value={newPath}
|
||||
/>
|
||||
</Form.Field>
|
||||
)}
|
||||
<Form.Field>
|
||||
<Checkbox
|
||||
label='Read Only'
|
||||
checked={newReadOnly}
|
||||
onChange={(e, {checked}) => setNewReadOnly(checked)}
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<label>Comment (optional)</label>
|
||||
<Input
|
||||
placeholder='Description of this share'
|
||||
value={newComment}
|
||||
onChange={(e, {value}) => setNewComment(value)}
|
||||
/>
|
||||
</Form.Field>
|
||||
</Form>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<Button onClick={() => setAddOpen(false)}>Cancel</Button>
|
||||
<Button
|
||||
color='green'
|
||||
icon='plus'
|
||||
content='Add Share'
|
||||
disabled={!newName.trim() || actionLoading}
|
||||
loading={actionLoading}
|
||||
onClick={handleAddShare}
|
||||
/>
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
</Segment>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function AdminControlsSection() {
|
||||
const dockerized = useDockerized();
|
||||
|
||||
|
|
@ -1089,6 +1301,7 @@ export function ControllerPage() {
|
|||
<Container fluid>
|
||||
<AdminControlsSection/>
|
||||
<ServicesSection/>
|
||||
<SambaSection/>
|
||||
<DiskSection/>
|
||||
<ControllerLink/>
|
||||
</Container>
|
||||
|
|
|
|||
|
|
@ -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}) => (
|
||||
<button onClick={onClick} disabled={disabled}>{children}</button>
|
||||
),
|
||||
DirectorySearch: ({onSelect, value}) => (
|
||||
<input data-testid="directory-search" value={value || ''} onChange={e => onSelect && onSelect(e.target.value)}/>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ============================================================================
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
},
|
||||
}
|
||||
|
|
|
|||
217
controller/lib/samba.py
Normal file
217
controller/lib/samba.py
Normal file
|
|
@ -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"))
|
||||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -773,7 +773,7 @@
|
|||
<body>
|
||||
{% if docker_mode %}
|
||||
<div class="warning-banner">
|
||||
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.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
204
controller/test/test_samba.py
Normal file
204
controller/test/test_samba.py
Normal file
|
|
@ -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()
|
||||
55
controller/test/test_samba_api.py
Normal file
55
controller/test/test_samba_api.py
Normal file
|
|
@ -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
|
||||
|
||||
|
||||
|
|
@ -88,6 +88,7 @@ python3-pip
|
|||
python3-venv
|
||||
renderd
|
||||
rsync
|
||||
samba
|
||||
smartmontools
|
||||
software-properties-common
|
||||
sudo
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ python3-pip
|
|||
python3-venv
|
||||
renderd
|
||||
rsync
|
||||
samba
|
||||
smartmontools
|
||||
software-properties-common
|
||||
sysstat
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue