b409f5eee2
Live validation caught it: the 'new folder' flow passed the storage root through sharingResolvePath, which (correctly) refuses the drive root as a share target — so share creation silently failed. sharingResolveStorageRoot accepts EXACTLY a registered live root (strictly tighter) and is used only as the new-folder parent. Regression test asserts both halves.
421 lines
14 KiB
Go
421 lines
14 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
|
|
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
|
|
}
|
|
if err := s.stackMgr.ReconcileSamba(); err != nil {
|
|
s.logger.Printf("[WARN] [sharing] reconcile failed: %v", err)
|
|
}
|
|
sharingRedirect(w, r, "Beállítás mentve.")
|
|
}
|
|
|
|
// 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
|
|
}
|
|
if err := s.stackMgr.ReconcileSamba(); err != nil {
|
|
s.logger.Printf("[WARN] [sharing] reconcile after password failed: %v", err)
|
|
}
|
|
sharingRedirect(w, r, "Megosztási jelszó beállítva.")
|
|
}
|
|
|
|
// 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"}`)
|
|
}
|
|
}
|