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 != nil && s.stackMgr.SambaRunning() // „Csatlakozás a megosztáshoz" (v0.151.0, S-2): the page has always shown the configured NAME and // never an address, so a customer whose network does not resolve the name had nothing to fall // back on and guessed — which is how this diagnosis started, with the Proxmox HOST's IP typed // into Finder (DIAG-sharing-2026-07-20.md). Derived FRESH on every render and cached nowhere: // the guest holds this address by DHCP, so a stored copy is a copy that eventually misdirects // people (S-5). "" simply omits the line — an address-less page beats a wrong address. if smb.Enabled { data["SMBDirectAddress"] = s.sambaLANAddress() } 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 } // sambaLANAddress resolves the connect-address seam. Never cached at this level either — the seam // exists so tests can supply an address without docker, not so anyone can memoize one. func (s *Server) sambaLANAddress() string { if s.sambaAddrFn != nil { return s.sambaAddrFn() } if s.stackMgr == nil { return "" } return s.stackMgr.SambaLANAddress() } // guestGateway resolves the guest's default gateway for the Hálózat card (R-66) — the sibling of // sambaLANAddress with the identical contract: live per render, never stored, "" = the row shows // „—". The read goes through the samba-container netns door (stacks/guestnet.go) because the // controller's OWN /proc/net/route answers for the docker bridge (172.x) — the S-2 wrong answer. func (s *Server) guestGateway() string { if s.guestGatewayFn != nil { return s.guestGatewayFn() } if s.stackMgr == nil { return "" } return s.stackMgr.GuestGateway() } // guestNetSnapshot resolves the Debug dump's network section (R-66); same seam shape. func (s *Server) guestNetSnapshot() stacks.GuestNetSnapshot { if s.guestNetFn != nil { return s.guestNetFn() } if s.stackMgr == nil { return stacks.GuestNetSnapshot{Errors: map[string]string{ "interfaces": "stack manager unavailable", "route": "stack manager unavailable", "dns": "stack manager unavailable", }} } return s.stackMgr.GuestNetSnapshot() } 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). It carries TWO independent // channels in one envelope, and keeping them apart is the whole point of this handler: // // phase — the ensure JOB. The client treats a terminal `running` as an EDGE ("the bring-up I was // watching just succeeded") and reloads once to repaint the server-rendered badge. // running — the service LEVEL, straight from the liveness probe. True whenever the container is up, // with or without a job, and it is what makes a page loaded after the job finished (or // after a controller restart, when the in-memory job is gone) still show the truth. // // v0.147.0 coerced `idle` → `running` here so that liveness could never be contradicted by a missing // job. That duty belongs to — and was already discharged by — the `running` field beside it; on the // phase channel the same value reads as a fresh terminal edge, so the client reloaded on the FIRST // poll of every steady-state page load and the page looped at ~1.2s forever // (felhom.eu/documentation/audits/DIAG-sharing-2026-07-20.md, S-1). The coercion is gone: no job, // no edge. See consumeIfRunning for the other half — a REAL bring-up must be reported exactly once. func (s *Server) sharingStatusHandler(w http.ResponseWriter, r *http.Request) { phase := sambaPhaseIdle errMsg := "" if job := s.sambaEnsure.consumeIfRunning(); job != nil { phase, errMsg = job.Phase, job.Error } running := s.stackMgr != nil && s.stackMgr.SambaRunning() 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 // /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"}`) } }