Files
felhom-controller/controller/internal/web/sharing_handlers.go
T
admin b5d78d1e0f v0.147.0 — feedback slice 1: pressing a button says something
The systemic complaint, twice in one evening: you press a button and nothing
happens. No progress, no ETA, no named result. Three worst offenders, fixed on
the two patterns already here (deploy 3-step panel, storage-init status poll).
No new framework — that is a ROADMAP item; three targeted cards ship tonight.

4a — a verification restore names its result. The flash said the app had been
restored "to a verification folder on the drive"; which folder, on which drive,
was invisible, so the customer could not go and look at what they had just asked
for. Full path now. The restore page gained a listing of existing verification
copies (app, size, date, path) — nothing anywhere showed these, so they piled up
and the only way to find them was SSH — each with a double-confirmed delete.

That delete is the only one this release adds, so it names a STACK, never a
path: the Manager resolves the name inside a backups/offsite-restore root it
computed itself and refuses anything landing outside. Red-proofed — neutralise
the name guard and stack:"" resolves to the offsite-restore ROOT and takes every
copy with it. Refusals are asserted as non-effects.

4b — Megosztás enable shows what it is waiting for. Enabling ran ReconcileSamba
synchronously inside the POST handler; on a golden without felhom-samba baked
that is compose pulling ~100MB, i.e. minutes of an apparently-hung form post
followed by "Beállítás mentve." whether or not anything came up. Detached +
polled now, distinguishing "képfájl letöltése" from "indítás" — decided BEFORE
the work starts, since afterwards the image is always present. Success is
probed, not inferred (compose up -d exits 0 on a crash-loop). The password form
starts the same job: with UserSet false reconcile deploys nothing, so on a fresh
box that is where the pull actually happens.

4c — "Távoli mentés most" streams real progress. restic was already reporting
bytes and percent; the runner seam used CombinedOutput() and discarded them. The
manual run now passes --json and scans stdout line-by-line: total bytes, percent,
current app. Manual only — the nightly stays silent, pinned by a test that fails
if it ever passes --json. The poll now arms unconditionally, closing a race the
manual trigger always ran: the redirect rendered before the goroutine wrote
LastStatus=running, so the poll never armed and the page sat static during the
very run just started. Red-proofed twice.

Also closes the golden/controller infra-image drift at the source: infra.Images()
derives from the existing pins and --print-infra-images exposes it, so the golden
bake can stop carrying its own copy. That copy had already drifted — felhom-samba
was never added, so the golden baked 3 of 4, which is why enabling Megosztás
pulled at runtime in the first place.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-19 09:30:30 +02:00

468 lines
16 KiB
Go

package web
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// „Megosztás" — LAN network sharing (R-7 slice 1). The customer enables sharing, sets one household
// SMB password, and exports folders. Every customer-supplied path goes through sharingResolvePath,
// which is load-bearing security, not defence in depth.
// errNotShareable is the single refusal surfaced for every deny-listed / out-of-root path. It is
// deliberately uniform: a distinct message per reason would let the picker act as a filesystem oracle.
var errNotShareable = errors.New("Ez a mappa nem osztható meg.")
// pathWithin reports whether path IS root or lives under it. Segment-wise and separator-agnostic, so
// neither a "/mnt/drive-evil" sibling nor a non-POSIX separator can pass as containment.
func pathWithin(path, root string) bool {
p := filepath.ToSlash(filepath.Clean(path))
r := filepath.ToSlash(filepath.Clean(root))
return p == r || strings.HasPrefix(p, r+"/")
}
// sharingOwningRoot returns the registered, non-decommissioned storage root that contains path.
func (s *Server) sharingOwningRoot(path string) (string, bool) {
for _, sp := range s.settings.GetStoragePaths() {
if sp.Decommissioned {
continue
}
if pathWithin(path, sp.Path) {
return sp.Path, true
}
}
return "", false
}
// sharingResolvePath is THE security gate for this feature. A path is accepted only when it
// (1) is absolute, (2) resolves — SYMLINKS INCLUDED — into a registered live storage root, (3) is not
// inside a deny-listed system subtree (appdata/, backups/, the drive root itself, felhom-data), and
// (4) is a real directory. EvalSymlinks runs BEFORE the containment assert so a symlink planted inside
// a root cannot point outside it.
func (s *Server) sharingResolvePath(raw string) (string, error) {
if strings.TrimSpace(raw) == "" {
return "", errNotShareable
}
clean := filepath.Clean(raw)
if !filepath.IsAbs(clean) {
return "", errNotShareable
}
resolved, err := filepath.EvalSymlinks(clean)
if err != nil {
return "", errNotShareable
}
root, ok := s.sharingOwningRoot(resolved)
if !ok {
return "", errNotShareable
}
// A whole drive is never shareable (exact match — the subtree rule below must NOT include the
// root, or every legitimate share under it would be refused).
if filepath.Clean(resolved) == filepath.Clean(root) {
return "", errNotShareable
}
for _, denied := range stacks.SharingDeniedRoots(root) {
if pathWithin(resolved, denied) {
return "", errNotShareable
}
}
fi, err := os.Stat(resolved)
if err != nil || !fi.IsDir() {
return "", errNotShareable
}
return resolved, nil
}
// sharingResolveStorageRoot validates a storage ROOT chosen for the "new folder" flow.
//
// This is deliberately NOT sharingResolvePath: that one validates a SHARE TARGET and therefore
// refuses the drive root itself (a whole drive is never shareable). Here the root is not the share —
// the new folder is created UNDER it — so the accept condition is "is EXACTLY a registered, live
// storage root", which is strictly tighter than the share-target guard.
func (s *Server) sharingResolveStorageRoot(raw string) (string, error) {
if strings.TrimSpace(raw) == "" {
return "", errNotShareable
}
clean := filepath.Clean(raw)
if !filepath.IsAbs(clean) {
return "", errNotShareable
}
resolved, err := filepath.EvalSymlinks(clean)
if err != nil {
return "", errNotShareable
}
for _, sp := range s.settings.GetStoragePaths() {
if sp.Decommissioned || sp.Disconnected {
continue
}
spResolved, serr := filepath.EvalSymlinks(sp.Path)
if serr != nil {
spResolved = filepath.Clean(sp.Path)
}
if filepath.Clean(resolved) == filepath.Clean(spResolved) {
return resolved, nil
}
}
return "", errNotShareable
}
// sharingPageData assembles the „Megosztás" page state.
func (s *Server) sharingPageData() map[string]interface{} {
data := s.settingsBaseData("sharing", "Hálózati megosztás")
smb := s.settings.GetSMBSettings()
data["SMBEnabled"] = smb.Enabled
data["SMBServerName"] = smb.EffectiveServerName()
data["SMBUserSet"] = smb.UserSet
data["SMBRunning"] = s.stackMgr.SambaRunning()
type shareRow struct {
Name string
Path string
ReadOnly bool
Offsite bool
Available bool
}
var rows []shareRow
for _, sh := range s.settings.GetSMBShares() {
fi, err := os.Stat(sh.Path)
rows = append(rows, shareRow{
Name: sh.Name, Path: sh.Path, ReadOnly: sh.ReadOnly, Offsite: sh.Offsite,
Available: err == nil && fi.IsDir(),
})
}
data["SMBShares"] = rows
// Storage roots offered for the "new folder on a drive" flow.
var roots []map[string]string
for _, sp := range s.settings.GetStoragePaths() {
if sp.Decommissioned || sp.Disconnected {
continue
}
label := sp.Label
if label == "" {
label = filepath.Base(sp.Path)
}
roots = append(roots, map[string]string{"Path": sp.Path, "Label": label})
}
data["StorageRoots"] = roots
// R-7b: per-tier backup truth. Until R-7b the „Felhőmentés" toggle promised a protection the
// engines did not deliver; these two lines are what makes the promise checkable by the customer
// rather than taken on faith. Amber ONLY on deviation — a green tier says nothing at all beyond
// its timestamp, so the page stays quiet when everything is fine.
if s.backupMgr != nil {
if cd := s.backupMgr.SharesTier2Status(); cd != nil {
data["SharesTier2Status"] = cd.LastStatus
data["SharesTier2LastRun"] = cd.LastRun
data["SharesTier2Warning"] = cd.LastWarning
data["SharesTier2Error"] = cd.LastError
data["SharesTier2Dest"] = cd.DestinationPath
}
if lastRun, status, count, ok := s.backupMgr.SharesOffsiteStatus(); ok {
data["SharesOffsiteStatus"] = status
data["SharesOffsiteLastRun"] = lastRun
data["SharesOffsiteCount"] = count
}
data["SharesRestoreReady"] = s.backupMgr.SharesScratchReady()
}
return data
}
func (s *Server) sharingPageHandler(w http.ResponseWriter, r *http.Request) {
data := s.sharingPageData()
if f := strings.TrimSpace(r.URL.Query().Get("flash")); f != "" {
data["Flash"] = f
}
s.executeTemplate(w, r, "sharing", data)
}
// sharingRedirect returns to the page with a Hungarian flash.
func sharingRedirect(w http.ResponseWriter, r *http.Request, flash string) {
http.Redirect(w, r, "/sharing?flash="+urlQueryEscape(flash), http.StatusSeeOther)
}
func urlQueryEscape(s string) string {
return strings.NewReplacer(" ", "+", "&", "%26", "?", "%3F", "#", "%23").Replace(s)
}
// sharingEnableHandler toggles the feature and updates the server name (POST /sharing/enable).
func (s *Server) sharingEnableHandler(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
enable := r.FormValue("enabled") == "on" || r.FormValue("enabled") == "true"
name := strings.TrimSpace(r.FormValue("server_name"))
if name != "" {
if err := settings.ValidateSMBServerName(name); err != nil {
sharingRedirect(w, r, err.Error())
return
}
if err := s.settings.SetSMBServerName(name); err != nil {
sharingRedirect(w, r, "A mentés nem sikerült.")
return
}
}
if err := s.settings.SetSMBEnabled(enable); err != nil {
sharingRedirect(w, r, "A mentés nem sikerült.")
return
}
if !enable {
if err := s.stackMgr.DisableSamba(); err != nil {
s.logger.Printf("[WARN] [sharing] disable failed: %v", err)
}
sharingRedirect(w, r, "A hálózati megosztás kikapcsolva. A mappák és a fájlok megmaradtak.")
return
}
// v0.147.0 (4b): the bring-up runs DETACHED and the page polls it. Synchronously it was a form
// post that hung for minutes on a first-enable image pull and then flashed „Beállítás mentve."
// regardless of whether anything actually came up.
if !s.startSambaEnsure() {
sharingRedirect(w, r, "A megosztási szolgáltatás előkészítése már folyamatban van.")
return
}
sharingRedirect(w, r, "Beállítás mentve. A megosztási szolgáltatás előkészítése folyamatban…")
}
// sharingStatusHandler is the 4b poll target (GET /sharing/status). Reports the ensure job's phase
// plus the live container state, so a page loaded AFTER the job finished (or after a restart, when
// the in-memory job is gone) still shows the truth.
func (s *Server) sharingStatusHandler(w http.ResponseWriter, r *http.Request) {
phase := sambaPhaseIdle
errMsg := ""
if job := s.sambaEnsure.snapshot(); job != nil {
phase, errMsg = job.Phase, job.Error
}
running := s.stackMgr != nil && s.stackMgr.SambaRunning()
// A stale `idle`/`running` job must never contradict reality: liveness wins on a fresh page.
if phase == sambaPhaseIdle && running {
phase = sambaPhaseRunning
}
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{
"phase": phase, "error": errMsg, "running": running,
})
}
// sharingPasswordHandler sets the household SMB password (POST /sharing/password).
// SECRET: the password is read from the form and handed straight to the stacks layer, which puts it
// on smbpasswd's stdin. It is never logged and never persisted.
func (s *Server) sharingPasswordHandler(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
pw := r.FormValue("smb_password")
pw2 := r.FormValue("smb_password_confirm")
if len(pw) < 8 {
sharingRedirect(w, r, "A jelszónak legalább 8 karakter hosszúnak kell lennie.")
return
}
if pw != pw2 {
sharingRedirect(w, r, "A két jelszó nem egyezik.")
return
}
if err := s.stackMgr.SetSMBPassword(pw); err != nil {
s.logger.Printf("[ERROR] [sharing] password apply failed: %v", err)
sharingRedirect(w, r, "A jelszó beállítása nem sikerült.")
return
}
// Setting the password is the moment the stack ACTUALLY first comes up: with UserSet false,
// reconcile deliberately deploys nothing, so on a fresh box this — not the enable toggle — is
// where the image pull happens. Same detached job, same card.
if !s.startSambaEnsure() {
sharingRedirect(w, r, "Megosztási jelszó beállítva. Az előkészítés már folyamatban van.")
return
}
sharingRedirect(w, r, "Megosztási jelszó beállítva. A megosztási szolgáltatás előkészítése folyamatban…")
}
// sharingShareCreateHandler creates a share (POST /sharing/shares). Either a NEW folder under
// <storage>/shares/ or an EXISTING folder picked in the browser — both go through the same guard.
func (s *Server) sharingShareCreateHandler(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
name := strings.TrimSpace(r.FormValue("name"))
mode := r.FormValue("mode") // "new" | "existing"
readOnly := r.FormValue("read_only") == "on"
if err := settings.ValidateSMBShareName(name); err != nil {
sharingRedirect(w, r, err.Error())
return
}
var target string
switch mode {
case "new":
root, err := s.sharingResolveStorageRoot(r.FormValue("storage_root"))
if err != nil {
sharingRedirect(w, r, errNotShareable.Error())
return
}
dir := filepath.Join(root, "shares", name)
// The name is already NetBIOS-validated (no slash/dot), but assert containment anyway —
// a join that escaped its root must never reach MkdirAll.
if !pathWithin(dir, root) {
sharingRedirect(w, r, errNotShareable.Error())
return
}
if err := os.MkdirAll(dir, 0o775); err != nil {
s.logger.Printf("[ERROR] [sharing] mkdir failed: %v", err)
sharingRedirect(w, r, "A mappa létrehozása nem sikerült.")
return
}
// uid:gid 1000 so apps and both backup tiers see the same ownership as SMB writes.
if err := os.Chown(dir, 1000, 1000); err != nil {
s.logger.Printf("[WARN] [sharing] chown 1000:1000 failed: %v", err)
}
target = dir
case "existing":
resolved, err := s.sharingResolvePath(r.FormValue("path"))
if err != nil {
sharingRedirect(w, r, errNotShareable.Error())
return
}
target = resolved
default:
sharingRedirect(w, r, errNotShareable.Error())
return
}
share := settings.SMBShare{
Name: name,
Path: target,
ReadOnly: readOnly,
Offsite: true, // [R4] new shares default to mandatory (offsite + tier-2)
CreatedAt: time.Now().UTC().Format(time.RFC3339),
}
if err := s.settings.AddSMBShare(share); err != nil {
sharingRedirect(w, r, err.Error())
return
}
if err := s.stackMgr.ReconcileSamba(); err != nil {
s.logger.Printf("[WARN] [sharing] reconcile after share create failed: %v", err)
}
sharingRedirect(w, r, "A megosztás létrehozva.")
}
// sharingShareDeleteHandler removes a share (POST /sharing/shares/delete). CONFIG ONLY — by
// construction there is no filesystem removal anywhere in this feature.
func (s *Server) sharingShareDeleteHandler(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
name := strings.TrimSpace(r.FormValue("name"))
if err := s.settings.RemoveSMBShare(name); err != nil {
sharingRedirect(w, r, err.Error())
return
}
if err := s.stackMgr.ReconcileSamba(); err != nil {
s.logger.Printf("[WARN] [sharing] reconcile after share delete failed: %v", err)
}
sharingRedirect(w, r, "A megosztás törölve — a mappa és a fájlok megmaradtak.")
}
// sharingShareOffsiteHandler flips a share's „Felhőmentés" toggle (POST /sharing/shares/offsite).
func (s *Server) sharingShareOffsiteHandler(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
name := strings.TrimSpace(r.FormValue("name"))
on := r.FormValue("offsite") == "on" || r.FormValue("offsite") == "true"
if err := s.settings.SetSMBShareOffsite(name, on); err != nil {
sharingRedirect(w, r, err.Error())
return
}
sharingRedirect(w, r, "Beállítás mentve.")
}
// ServeSharingAPI dispatches the /api/sharing/* XHR endpoints. Registered on the mux in main.go
// behind RequireAuth+CsrfProtect (the /api/ subtree is claimed there, NOT in the web ServeHTTP
// switch — a case added there would be shadowed by the apiRouter catch-all and 401).
func (s *Server) ServeSharingAPI(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/sharing/browse" && r.Method == http.MethodGet:
s.sharingBrowseHandler(w, r)
default:
http.NotFound(w, r)
}
}
// sharingBrowseHandler is the folder picker (GET /api/sharing/browse?path=). With no path it lists
// the registered live storage roots; otherwise the sub-DIRECTORIES of a guard-approved path, sorted.
// Deny-listed children are omitted so the picker never offers an unshareable folder.
func (s *Server) sharingBrowseHandler(w http.ResponseWriter, r *http.Request) {
type entry struct {
Name string `json:"name"`
Path string `json:"path"`
}
resp := struct {
Path string `json:"path"`
Parent string `json:"parent,omitempty"`
Entries []entry `json:"entries"`
Error string `json:"error,omitempty"`
}{Entries: []entry{}}
raw := r.URL.Query().Get("path")
if strings.TrimSpace(raw) == "" {
for _, sp := range s.settings.GetStoragePaths() {
if sp.Decommissioned || sp.Disconnected {
continue
}
label := sp.Label
if label == "" {
label = filepath.Base(sp.Path)
}
resp.Entries = append(resp.Entries, entry{Name: label, Path: sp.Path})
}
sort.Slice(resp.Entries, func(i, j int) bool { return resp.Entries[i].Name < resp.Entries[j].Name })
writeSharingJSON(w, http.StatusOK, resp)
return
}
dir, err := s.sharingResolvePath(raw)
if err != nil {
resp.Error = errNotShareable.Error()
writeSharingJSON(w, http.StatusBadRequest, resp)
return
}
resp.Path = dir
if root, ok := s.sharingOwningRoot(dir); ok && dir != root {
resp.Parent = filepath.Dir(dir)
}
items, rerr := os.ReadDir(dir)
if rerr != nil {
resp.Error = "A mappa nem érhető el."
writeSharingJSON(w, http.StatusBadRequest, resp)
return
}
root, _ := s.sharingOwningRoot(dir)
denied := stacks.SharingDeniedRoots(root)
for _, it := range items {
if !it.IsDir() || strings.HasPrefix(it.Name(), ".") {
continue
}
child := filepath.Join(dir, it.Name())
skip := false
for _, d := range denied {
if pathWithin(child, d) {
skip = true
break
}
}
if skip {
continue
}
resp.Entries = append(resp.Entries, entry{Name: it.Name(), Path: child})
}
sort.Slice(resp.Entries, func(i, j int) bool { return resp.Entries[i].Name < resp.Entries[j].Name })
writeSharingJSON(w, http.StatusOK, resp)
}
func writeSharingJSON(w http.ResponseWriter, code int, v interface{}) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
if err := json.NewEncoder(w).Encode(v); err != nil {
fmt.Fprintf(w, `{"error":"encode"}`)
}
}