Compare commits
2 Commits
3db9126121
...
a65dcff85a
| Author | SHA1 | Date | |
|---|---|---|---|
| a65dcff85a | |||
| bb8737a81f |
@@ -57,6 +57,14 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
// Hidden re-exec mode (NAS verify-before-commit): the web server re-execs THIS binary as
|
||||||
|
// `felhom-controller --netprobe <dir>` with uid/gid-1000 credentials to prove a media app can
|
||||||
|
// write through a freshly-verified network share (SPIKE-nas-verify Q2). Handled before flag
|
||||||
|
// parsing; the exit code is the probe verdict (see web/netprobe.go).
|
||||||
|
if len(os.Args) == 3 && os.Args[1] == "--netprobe" {
|
||||||
|
os.Exit(web.NetProbeChild(os.Args[2]))
|
||||||
|
}
|
||||||
|
|
||||||
configPath := flag.String("config", "/opt/docker/felhom-controller/controller.yaml", "Path to configuration file")
|
configPath := flag.String("config", "/opt/docker/felhom-controller/controller.yaml", "Path to configuration file")
|
||||||
showVersion := flag.Bool("version", false, "Show version and exit")
|
showVersion := flag.Bool("version", false, "Show version and exit")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|||||||
@@ -652,7 +652,9 @@ type AddNetStorageRequest struct {
|
|||||||
Password string `json:"password,omitempty"` // SMB secret — pass-through, never persisted
|
Password string `json:"password,omitempty"` // SMB secret — pass-through, never persisted
|
||||||
}
|
}
|
||||||
|
|
||||||
// NetStorageAddResult mirrors the agent's add response (the in-guest path the media app's data dir points at).
|
// NetStorageAddResult mirrors the agent's add response (the in-guest path the media app's data dir
|
||||||
|
// points at). Since agent v0.81.0 (verify-before-commit) a successful add means "units installed,
|
||||||
|
// verify STARTED" — Verify/JobID carry the detached verify job the caller polls via NetVerifyStatus.
|
||||||
type NetStorageAddResult struct {
|
type NetStorageAddResult struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Protocol string `json:"protocol"`
|
Protocol string `json:"protocol"`
|
||||||
@@ -660,18 +662,67 @@ type NetStorageAddResult struct {
|
|||||||
GuestPath string `json:"guest_path"`
|
GuestPath string `json:"guest_path"`
|
||||||
HostUID int `json:"host_uid"`
|
HostUID int `json:"host_uid"`
|
||||||
HostGID int `json:"host_gid"`
|
HostGID int `json:"host_gid"`
|
||||||
|
Verify string `json:"verify"` // "started" on a verify-before-commit agent (v0.81.0+)
|
||||||
|
JobID string `json:"job_id"`
|
||||||
|
Code string `json:"code"` // set on a categorized SYNC refusal (e.g. "unreachable", "busy")
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddNetStorage mounts a NAS share host-side (the agent automounts it; it propagates into this guest via
|
// NetAddRefusedError is the agent's CATEGORIZED sync refusal of a netstorage add (the 2 s TCP
|
||||||
// the shared bind). Returns the in-guest path the media app's data dir is pointed at.
|
// pre-probe "unreachable", or "busy" single-flight). Code is the verify-category vocabulary the UI
|
||||||
|
// maps to Hungarian; nothing was installed agent-side.
|
||||||
|
type NetAddRefusedError struct {
|
||||||
|
Code string
|
||||||
|
Msg string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *NetAddRefusedError) Error() string {
|
||||||
|
return "agentapi: netstorage add refused (" + e.Code + "): " + e.Msg
|
||||||
|
}
|
||||||
|
|
||||||
|
// NetVerifyStatus mirrors the agent's GET /netstorage/verify-status: the single verify slot.
|
||||||
|
// Phase "none" is a REAL signal — after an agent restart the in-memory job is gone; the caller
|
||||||
|
// treats none-after-install as verify-lost and rolls the add back (Scenario F).
|
||||||
|
type NetVerifyStatus struct {
|
||||||
|
Phase string `json:"phase"` // none | running | done | failed
|
||||||
|
Name string `json:"name"`
|
||||||
|
Where string `json:"where"`
|
||||||
|
Protocol string `json:"protocol"`
|
||||||
|
Code string `json:"code"` // failure category (the agent's classifier vocabulary)
|
||||||
|
Detail string `json:"detail"` // operator hint + raw journal fragment
|
||||||
|
JobID string `json:"job_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddNetStorage installs a NAS share host-side and starts the agent's detached verify job (agent
|
||||||
|
// v0.81.0 verify-before-commit). A categorized sync refusal returns *NetAddRefusedError carrying
|
||||||
|
// the category code (the result body also carries it); other failures are plain errors.
|
||||||
func (c *Client) AddNetStorage(ctx context.Context, req AddNetStorageRequest) (NetStorageAddResult, error) {
|
func (c *Client) AddNetStorage(ctx context.Context, req AddNetStorageRequest) (NetStorageAddResult, error) {
|
||||||
var out NetStorageAddResult
|
var out NetStorageAddResult
|
||||||
body, err := c.post(ctx, "/netstorage/add", req)
|
env, status, err := c.postWithStatus(ctx, "/netstorage/add", req)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
if len(env.Data) > 0 {
|
||||||
|
_ = json.Unmarshal(env.Data, &out) // best-effort; the refusal body carries {code}
|
||||||
|
}
|
||||||
|
if rerr := refusalError("/netstorage/add", status, env); rerr != nil {
|
||||||
|
if out.Code != "" {
|
||||||
|
return out, &NetAddRefusedError{Code: out.Code, Msg: truncateErr(env.Error, 300)}
|
||||||
|
}
|
||||||
|
return out, rerr
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NetVerifyStatus polls the agent's verify slot (short GET — fits the client's global 15 s timeout;
|
||||||
|
// the LONG wait lives in the caller's poll loop, never in one HTTP call).
|
||||||
|
func (c *Client) NetVerifyStatus(ctx context.Context) (NetVerifyStatus, error) {
|
||||||
|
var out NetVerifyStatus
|
||||||
|
body, err := c.get(ctx, "/netstorage/verify-status")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(body, &out); err != nil {
|
if err := json.Unmarshal(body, &out); err != nil {
|
||||||
return out, fmt.Errorf("agentapi: decode /netstorage/add: %w", err)
|
return out, fmt.Errorf("agentapi: decode /netstorage/verify-status: %w", err)
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// In-guest uid-1000 write probe (NAS verify-before-commit, SPIKE-nas-verify Q2/Q3). The agent's
|
||||||
|
// verify proves the MOUNT is real; this probe proves a uid/gid-1000 app can actually WRITE through
|
||||||
|
// it — the squash trap (Q3 row c: an export that mounts fine but denies every write). The probe is
|
||||||
|
// a RE-EXEC of this binary (`felhom-controller --netprobe <dir>`) dropped to uid/gid 1000 via
|
||||||
|
// SysProcAttr.Credential (spike Q2: SETUID/SETGID caps present in the container) — no shell, no
|
||||||
|
// in-process setuid (which would poison the whole Go runtime's credentials).
|
||||||
|
|
||||||
|
// Probe child exit codes (the parent maps them to verify categories).
|
||||||
|
const (
|
||||||
|
netProbeExitOK = 0 // write + readback + delete all fine
|
||||||
|
netProbeExitNoWrite = 2 // create/write failed → not_writable (the squash trap)
|
||||||
|
netProbeExitMismatch = 3 // readback failed or differed → probe_io
|
||||||
|
netProbeExitCleanup = 4 // wrote fine but delete failed → OK + warn (never a failure)
|
||||||
|
)
|
||||||
|
|
||||||
|
// netProbeReadBack is the child's readback seam (package var — the child is a re-exec'd process in
|
||||||
|
// production, so a struct seam can't reach it; tests override in-process).
|
||||||
|
var netProbeReadBack = os.ReadFile
|
||||||
|
|
||||||
|
// NetProbeChild is the --netprobe body, run AS uid/gid 1000 by the re-exec parent: create a
|
||||||
|
// dot-file with a random name + nonce in dir, read it back, compare, remove. Pure file logic —
|
||||||
|
// unit-tested directly in t.TempDir(). Exposed for cmd/controller's hidden mode.
|
||||||
|
func NetProbeChild(dir string) int {
|
||||||
|
name := filepath.Join(dir, ".felhom-proba-"+randHexToken(8))
|
||||||
|
nonce := randHexToken(32)
|
||||||
|
if err := os.WriteFile(name, []byte(nonce), 0o644); err != nil {
|
||||||
|
return netProbeExitNoWrite
|
||||||
|
}
|
||||||
|
back, err := netProbeReadBack(name)
|
||||||
|
if err != nil || string(back) != nonce {
|
||||||
|
_ = os.Remove(name) // best-effort — the verdict is already mismatch
|
||||||
|
return netProbeExitMismatch
|
||||||
|
}
|
||||||
|
if err := os.Remove(name); err != nil {
|
||||||
|
return netProbeExitCleanup
|
||||||
|
}
|
||||||
|
return netProbeExitOK
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeOutcome is the parent-side verdict of one probe run.
|
||||||
|
type probeOutcome struct {
|
||||||
|
OK bool
|
||||||
|
Category string // failure category (not_writable | probe_io) when !OK
|
||||||
|
Detail string
|
||||||
|
Warn string // set on OK when cleanup failed (§8: a failed delete is a WARN, not a failure)
|
||||||
|
}
|
||||||
|
|
||||||
|
// netProbeVerdict maps the child's exit code to the outcome (pure — unit-tested).
|
||||||
|
func netProbeVerdict(exitCode int, output string) probeOutcome {
|
||||||
|
switch exitCode {
|
||||||
|
case netProbeExitOK:
|
||||||
|
return probeOutcome{OK: true}
|
||||||
|
case netProbeExitCleanup:
|
||||||
|
return probeOutcome{OK: true, Warn: "a próbafájl törlése nem sikerült a megosztáson"}
|
||||||
|
case netProbeExitNoWrite:
|
||||||
|
return probeOutcome{OK: false, Category: "not_writable", Detail: "uid-1000 write probe: create/write refused | " + output}
|
||||||
|
case netProbeExitMismatch:
|
||||||
|
return probeOutcome{OK: false, Category: "probe_io", Detail: "uid-1000 write probe: readback failed or differed | " + output}
|
||||||
|
default:
|
||||||
|
return probeOutcome{OK: false, Category: "probe_io", Detail: "uid-1000 write probe: unexpected exit | " + output}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// randHexToken returns n random bytes hex-encoded (2n chars); crypto/rand, panics never — a rand
|
||||||
|
// failure degrades to a constant (the probe still functions, names just stop being random).
|
||||||
|
func randHexToken(n int) string {
|
||||||
|
b := make([]byte, n)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "felhom-static"
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// netProbeTimeout bounds one probe run (LAN write+readback is sub-second; a wedged share must not
|
||||||
|
// hold the orchestrator — the NFS soft/retry=0 options error out well inside this).
|
||||||
|
const netProbeTimeout = 30 * time.Second
|
||||||
|
|
||||||
|
// runNetProbe re-execs this binary as `felhom-controller --netprobe <dir>` with uid/gid 1000
|
||||||
|
// credentials (supplementary groups CLEARED — the probe must see exactly what a media app sees)
|
||||||
|
// and maps the exit code to a verdict. This is the production netProbeFn seam value.
|
||||||
|
func runNetProbe(ctx context.Context, dir string) probeOutcome {
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return probeOutcome{OK: false, Category: "probe_io", Detail: "probe re-exec: executable path: " + err.Error()}
|
||||||
|
}
|
||||||
|
pctx, cancel := context.WithTimeout(ctx, netProbeTimeout)
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(pctx, exe, "--netprobe", dir)
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||||
|
Credential: &syscall.Credential{Uid: 1000, Gid: 1000, Groups: []uint32{}},
|
||||||
|
}
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
output := strings.TrimSpace(string(out))
|
||||||
|
if err != nil {
|
||||||
|
if ee, ok := err.(*exec.ExitError); ok {
|
||||||
|
return netProbeVerdict(ee.ExitCode(), output)
|
||||||
|
}
|
||||||
|
// Spawn-level failure (EPERM on setuid = capability dropped somewhere — spike Q2 proved the
|
||||||
|
// default container HAS the caps, so this is a real config regression worth the raw detail).
|
||||||
|
return probeOutcome{OK: false, Category: "probe_io", Detail: "probe spawn failed: " + err.Error() + " | " + output}
|
||||||
|
}
|
||||||
|
return netProbeVerdict(0, output)
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
//go:build !linux
|
||||||
|
|
||||||
|
package web
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// runNetProbe is Linux-only (SysProcAttr.Credential). Off-linux (dev/test hosts) the seam must be
|
||||||
|
// injected; reaching this stub is a wiring error, reported as a failed probe — never a false PASS.
|
||||||
|
func runNetProbe(_ context.Context, _ string) probeOutcome {
|
||||||
|
return probeOutcome{OK: false, Category: "probe_io", Detail: "uid-1000 probe unavailable on this platform"}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -22,6 +23,8 @@ import (
|
|||||||
const defaultMediaUID = 1000
|
const defaultMediaUID = 1000
|
||||||
|
|
||||||
// networkStorageItem is the UI row: the registered descriptor + live per-share health from the agent.
|
// networkStorageItem is the UI row: the registered descriptor + live per-share health from the agent.
|
||||||
|
// Orphan marks an agent-configured share with NO registry entry (a crash-window leftover — Scenario
|
||||||
|
// F's visible closure): the row renders with ONLY the remove action.
|
||||||
type networkStorageItem struct {
|
type networkStorageItem struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
@@ -33,10 +36,12 @@ type networkStorageItem struct {
|
|||||||
Reachable bool `json:"reachable"`
|
Reachable bool `json:"reachable"`
|
||||||
Mounted bool `json:"mounted"`
|
Mounted bool `json:"mounted"`
|
||||||
Configured bool `json:"configured"`
|
Configured bool `json:"configured"`
|
||||||
|
Orphan bool `json:"orphan"` // agent-configured but NOT registered — remove is the only action
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleNetStorageAdd proxies POST /api/storage/netstorage/add → agent /netstorage/add, then registers a
|
// handleNetStorageAdd starts the verify-before-commit orchestration: sync input validation (a bad
|
||||||
// Kind=network StoragePath (no password persisted).
|
// request changes nothing), then the detached job (agent add → verify poll → uid-1000 probe →
|
||||||
|
// register LAST). The response is {started:true}; the UI polls /api/storage/netstorage/add/status.
|
||||||
func (s *Server) handleNetStorageAdd(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleNetStorageAdd(w http.ResponseWriter, r *http.Request) {
|
||||||
var req struct {
|
var req struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -81,46 +86,42 @@ func (s *Server) handleNetStorageAdd(w http.ResponseWriter, r *http.Request) {
|
|||||||
gid = defaultMediaUID
|
gid = defaultMediaUID
|
||||||
}
|
}
|
||||||
|
|
||||||
agent, err := s.agentClient()
|
agent, err := s.netAgentForAdd()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
|
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
res, err := agent.AddNetStorage(r.Context(), agentapi.AddNetStorageRequest{
|
|
||||||
Name: name, Protocol: proto, Server: server, Export: export,
|
|
||||||
MappedUID: uid, MappedGID: gid, IdleTimeoutSec: req.IdleTimeoutSec,
|
|
||||||
Username: req.Username, Password: req.Password,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
s.logger.Printf("[ERROR] [web] netstorage add %q via agent failed: %v", name, err)
|
|
||||||
writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
label := strings.TrimSpace(req.Label)
|
label := strings.TrimSpace(req.Label)
|
||||||
if label == "" {
|
if label == "" {
|
||||||
label = "Hálózati tárhely: " + name
|
label = "Hálózati tárhely: " + name
|
||||||
}
|
}
|
||||||
// Register the Kind=network path. NO password is persisted — only the non-secret descriptors.
|
// The password rides INSIDE the request straight to the agent (0600 creds file) — it is never
|
||||||
sp := settings.StoragePath{
|
// persisted controller-side and never appears in the job status.
|
||||||
Path: res.GuestPath,
|
started := s.startNetAdd(agent, agentapi.AddNetStorageRequest{
|
||||||
Label: label,
|
Name: name, Protocol: proto, Server: server, Export: export,
|
||||||
Schedulable: true,
|
MappedUID: uid, MappedGID: gid, IdleTimeoutSec: req.IdleTimeoutSec,
|
||||||
AddedAt: time.Now().UTC().Format(time.RFC3339),
|
Username: req.Username, Password: req.Password,
|
||||||
Kind: settings.StorageKindNetwork,
|
}, label)
|
||||||
Protocol: proto,
|
if !started {
|
||||||
Server: server,
|
writeDiskJSON(w, http.StatusConflict, false, "már folyamatban van egy csatlakoztatás", nil)
|
||||||
Export: export,
|
|
||||||
MappedUID: uid,
|
|
||||||
MappedGID: gid,
|
|
||||||
}
|
|
||||||
if err := s.settings.AddStoragePath(sp); err != nil {
|
|
||||||
s.logger.Printf("[ERROR] [web] netstorage register %q failed: %v", name, err)
|
|
||||||
writeDiskJSON(w, http.StatusInternalServerError, false, "regisztráció sikertelen", nil)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.logger.Printf("[INFO] [web] network storage added: %s (%s %s:%s) → %s", name, proto, server, export, res.GuestPath)
|
s.logger.Printf("[INFO] [web] network storage add started: %s (%s %s:%s)", name, proto, server, export)
|
||||||
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"registered": true, "name": name, "path": res.GuestPath})
|
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"started": true, "name": name})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleNetStorageAddStatus reports the last / in-flight add job (deep copy; "none" when never ran).
|
||||||
|
func (s *Server) handleNetStorageAddStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
job := s.netAdd.snapshot()
|
||||||
|
if job == nil {
|
||||||
|
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"phase": "none"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{
|
||||||
|
"phase": job.Phase, "name": job.Name, "category": job.Category,
|
||||||
|
"message": job.Message, "detail": trimNetDetail(job.Detail), "warn": job.Warn,
|
||||||
|
"path": job.Path, "started_at": job.StartedAt, "updated_at": job.UpdatedAt,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// networkStorageItems returns the registered network shares merged with the agent's live per-share
|
// networkStorageItems returns the registered network shares merged with the agent's live per-share
|
||||||
@@ -128,23 +129,23 @@ func (s *Server) handleNetStorageAdd(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Shared by the JSON list handler and the settings page render.
|
// Shared by the JSON list handler and the settings page render.
|
||||||
func (s *Server) networkStorageItems(ctx context.Context) []networkStorageItem {
|
func (s *Server) networkStorageItems(ctx context.Context) []networkStorageItem {
|
||||||
live := map[string]agentapi.NetworkMountStatus{}
|
live := map[string]agentapi.NetworkMountStatus{}
|
||||||
if agent, err := s.agentClient(); err == nil {
|
lctx, cancel := context.WithTimeout(ctx, 5*time.Second) // never let a slow agent hang the page
|
||||||
lctx, cancel := context.WithTimeout(ctx, 5*time.Second) // never let a slow agent hang the page
|
defer cancel()
|
||||||
defer cancel()
|
if mounts, lerr := s.listNetStorage(lctx); lerr == nil {
|
||||||
if mounts, lerr := agent.ListNetStorage(lctx); lerr == nil {
|
for _, m := range mounts {
|
||||||
for _, m := range mounts {
|
live[m.Name] = m
|
||||||
live[m.Name] = m
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
s.logger.Printf("[WARN] [web] netstorage live health unavailable: %v", lerr)
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
s.logger.Printf("[WARN] [web] netstorage live health unavailable: %v", lerr)
|
||||||
}
|
}
|
||||||
items := make([]networkStorageItem, 0)
|
items := make([]networkStorageItem, 0)
|
||||||
|
registered := map[string]bool{}
|
||||||
for _, sp := range s.settings.GetStoragePaths() {
|
for _, sp := range s.settings.GetStoragePaths() {
|
||||||
if !sp.IsNetwork() {
|
if !sp.IsNetwork() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
name := pathBase(sp.Path)
|
name := pathBase(sp.Path)
|
||||||
|
registered[name] = true
|
||||||
it := networkStorageItem{
|
it := networkStorageItem{
|
||||||
Name: name, Label: sp.Label, Protocol: sp.Protocol,
|
Name: name, Label: sp.Label, Protocol: sp.Protocol,
|
||||||
Server: sp.Server, Export: sp.Export, Path: sp.Path, Health: "unknown",
|
Server: sp.Server, Export: sp.Export, Path: sp.Path, Health: "unknown",
|
||||||
@@ -157,6 +158,26 @@ func (s *Server) networkStorageItems(ctx context.Context) []networkStorageItem {
|
|||||||
}
|
}
|
||||||
items = append(items, it)
|
items = append(items, it)
|
||||||
}
|
}
|
||||||
|
// Orphans (Scenario F's visible closure): an agent-configured share with NO registry entry is a
|
||||||
|
// crash-window leftover (e.g. controller died between agent-add and register). Surface it with
|
||||||
|
// ONLY the remove action — re-adding the same name is the repair path (EnsureNetworkMount is
|
||||||
|
// idempotent, verify runs again).
|
||||||
|
var orphans []string
|
||||||
|
for name := range live {
|
||||||
|
if !registered[name] {
|
||||||
|
orphans = append(orphans, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(orphans)
|
||||||
|
for _, name := range orphans {
|
||||||
|
m := live[name]
|
||||||
|
items = append(items, networkStorageItem{
|
||||||
|
Name: name, Label: "Árva megosztás: " + name, Protocol: m.Protocol,
|
||||||
|
Server: m.Server, Export: m.Export, Path: m.Where,
|
||||||
|
Health: m.Health, Reachable: m.Reachable, Mounted: m.Mounted,
|
||||||
|
Configured: m.Configured, Orphan: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,6 +219,18 @@ func (s *Server) handleNetStorageRemove(w http.ResponseWriter, r *http.Request)
|
|||||||
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"removed": true, "name": name})
|
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"removed": true, "name": name})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// listNetStorage resolves the agent's live share list (test seam first, then the shared client).
|
||||||
|
func (s *Server) listNetStorage(ctx context.Context) ([]agentapi.NetworkMountStatus, error) {
|
||||||
|
if s.netListFn != nil {
|
||||||
|
return s.netListFn(ctx)
|
||||||
|
}
|
||||||
|
agent, err := s.agentClient()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return agent.ListNetStorage(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
// pathBase returns the last path segment (the share name) of a /mnt/felhom-drives/<name> path.
|
// pathBase returns the last path segment (the share name) of a /mnt/felhom-drives/<name> path.
|
||||||
func pathBase(p string) string {
|
func pathBase(p string) string {
|
||||||
p = strings.TrimRight(p, "/")
|
p = strings.TrimRight(p, "/")
|
||||||
|
|||||||
@@ -0,0 +1,323 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NAS add orchestration (verify-before-commit). POST /api/storage/netstorage/add no longer
|
||||||
|
// registers blind: it validates sync, then a DETACHED job (migrate.go shape — sync-validate-then-
|
||||||
|
// goroutine, single-flight, deep-copied status) drives:
|
||||||
|
//
|
||||||
|
// agent_add → the agent installs units + starts ITS detached verify (auto-rollback on fail)
|
||||||
|
// verifying → poll the agent's verify slot every 2 s; "none" after install = agent restarted
|
||||||
|
// mid-verify ⇒ WE roll back (Scenario F)
|
||||||
|
// probing → in-guest uid-1000 write probe (the squash trap — a mountable-but-unwritable
|
||||||
|
// export must never register)
|
||||||
|
// registering→ AddStoragePath — the LAST step, so the worst crash outcome is an agent-side
|
||||||
|
// orphan (surfaced by the list), never a registered-but-broken path
|
||||||
|
// done | failed
|
||||||
|
//
|
||||||
|
// The job runs on context.Background() with an overall deadline — a closed browser tab can never
|
||||||
|
// abort a half-done add mid-rollback. Rollback (agent RemoveNetStorage) is idempotent; a
|
||||||
|
// double-remove after the agent's own auto-rollback is harmless.
|
||||||
|
|
||||||
|
// netAgent is the agent surface the orchestrator needs (seam — tests inject a fake; production is
|
||||||
|
// the shared *agentapi.Client).
|
||||||
|
type netAgent interface {
|
||||||
|
AddNetStorage(ctx context.Context, req agentapi.AddNetStorageRequest) (agentapi.NetStorageAddResult, error)
|
||||||
|
NetVerifyStatus(ctx context.Context) (agentapi.NetVerifyStatus, error)
|
||||||
|
RemoveNetStorage(ctx context.Context, name string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// netAddJob is the poll-visible orchestration state (GET /api/storage/netstorage/add/status).
|
||||||
|
type netAddJob struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Phase string `json:"phase"` // agent_add | verifying | probing | registering | done | failed
|
||||||
|
Category string `json:"category,omitempty"` // failure category (agent classifier vocabulary + not_writable/probe_io)
|
||||||
|
Message string `json:"message,omitempty"` // the category's Hungarian customer message (§3.2)
|
||||||
|
Detail string `json:"detail,omitempty"` // raw English/journal detail (the collapsible)
|
||||||
|
Warn string `json:"warn,omitempty"` // non-fatal note on a successful add (e.g. probe cleanup)
|
||||||
|
Path string `json:"path,omitempty"` // the registered in-guest path (done only)
|
||||||
|
StartedAt time.Time `json:"started_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
netAddPhaseAgentAdd = "agent_add"
|
||||||
|
netAddPhaseVerifying = "verifying"
|
||||||
|
netAddPhaseProbing = "probing"
|
||||||
|
netAddPhaseRegistering = "registering"
|
||||||
|
netAddPhaseDone = "done"
|
||||||
|
netAddPhaseFailed = "failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// netAddDeadline bounds the WHOLE orchestration (§8: detached ctx + ~150 s). The dominant term
|
||||||
|
// is the agent verify's own 95 s worst case (black-holed-but-routed server).
|
||||||
|
netAddDeadline = 150 * time.Second
|
||||||
|
// netVerifyPollEvery is the agent verify-slot poll cadence.
|
||||||
|
netVerifyPollEvery = 2 * time.Second
|
||||||
|
// netRollbackBudget bounds a rollback call on a possibly-expired job context.
|
||||||
|
netRollbackBudget = 30 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// netAddState is the single-flight slot (migrate.go's acquire/release + deep-copy status shape).
|
||||||
|
type netAddState struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
running bool
|
||||||
|
cur *netAddJob
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *netAddState) acquire(job *netAddJob) bool {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if s.running {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
s.running = true
|
||||||
|
cp := *job
|
||||||
|
s.cur = &cp
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *netAddState) release() {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.running = false
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *netAddState) set(job *netAddJob) {
|
||||||
|
s.mu.Lock()
|
||||||
|
cp := *job
|
||||||
|
s.cur = &cp
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// snapshot returns a copy of the last / in-flight job (nil = never ran).
|
||||||
|
func (s *netAddState) snapshot() *netAddJob {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if s.cur == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cp := *s.cur
|
||||||
|
return &cp
|
||||||
|
}
|
||||||
|
|
||||||
|
// netAgentForAdd resolves the orchestrator's agent surface (test seam first, then the shared client).
|
||||||
|
func (s *Server) netAgentForAdd() (netAgent, error) {
|
||||||
|
if s.netAgentFn != nil {
|
||||||
|
return s.netAgentFn()
|
||||||
|
}
|
||||||
|
c, err := s.agentClient()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// netProbe runs the in-guest uid-1000 write probe (test seam first, then the re-exec runner).
|
||||||
|
func (s *Server) netProbe(ctx context.Context, dir string) probeOutcome {
|
||||||
|
if s.netProbeFn != nil {
|
||||||
|
return s.netProbeFn(ctx, dir)
|
||||||
|
}
|
||||||
|
return runNetProbe(ctx, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// startNetAdd claims the single-flight slot and launches the detached orchestration. false = an add
|
||||||
|
// is already in flight (Scenario G).
|
||||||
|
func (s *Server) startNetAdd(agent netAgent, req agentapi.AddNetStorageRequest, label string) bool {
|
||||||
|
job := &netAddJob{
|
||||||
|
Name: req.Name,
|
||||||
|
Phase: netAddPhaseAgentAdd,
|
||||||
|
StartedAt: time.Now().UTC(),
|
||||||
|
UpdatedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
if !s.netAdd.acquire(job) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
go s.runNetAdd(agent, req, label, job)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// runNetAdd drives the phase machine on a DETACHED context (a closed tab can't abort a rollback).
|
||||||
|
func (s *Server) runNetAdd(agent netAgent, req agentapi.AddNetStorageRequest, label string, job *netAddJob) {
|
||||||
|
defer s.netAdd.release()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), netAddDeadline)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
setPhase := func(p string) {
|
||||||
|
job.Phase = p
|
||||||
|
job.UpdatedAt = time.Now().UTC()
|
||||||
|
s.netAdd.set(job)
|
||||||
|
}
|
||||||
|
fail := func(category, detail string) {
|
||||||
|
job.Phase = netAddPhaseFailed
|
||||||
|
job.Category = category
|
||||||
|
job.Message = netAddMessage(category, req.Server, req.MappedUID)
|
||||||
|
job.Detail = detail
|
||||||
|
job.UpdatedAt = time.Now().UTC()
|
||||||
|
s.netAdd.set(job)
|
||||||
|
s.logger.Printf("[WARN] [web] netstorage add %q failed: category=%s detail=%s", req.Name, category, detail)
|
||||||
|
}
|
||||||
|
rollback := func(why string) {
|
||||||
|
rctx, rcancel := context.WithTimeout(context.Background(), netRollbackBudget)
|
||||||
|
defer rcancel()
|
||||||
|
if err := agent.RemoveNetStorage(rctx, req.Name); err != nil {
|
||||||
|
// Best-effort by design: the agent may have auto-rolled-back already (double-remove is
|
||||||
|
// harmless) — but log it, a REAL leftover shows up as an orphan row in the list.
|
||||||
|
s.logger.Printf("[WARN] [web] netstorage add %q rollback (%s): remove: %v", req.Name, why, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 1 — agent add (install units + start the agent-side verify).
|
||||||
|
res, err := agent.AddNetStorage(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
var refused *agentapi.NetAddRefusedError
|
||||||
|
if errors.As(err, &refused) {
|
||||||
|
fail(refused.Code, refused.Msg) // categorized sync refusal — NOTHING was installed
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fail("agent_error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2 — poll the agent's verify slot. On a pre-verify agent (no job started) skip straight
|
||||||
|
// to the probe: the mount-trigger check then happens implicitly through the probe's write.
|
||||||
|
if res.Verify == "started" {
|
||||||
|
setPhase(netAddPhaseVerifying)
|
||||||
|
verdict, verr := s.pollAgentVerify(ctx, agent, res.JobID)
|
||||||
|
switch {
|
||||||
|
case verr != nil:
|
||||||
|
rollback("verify poll failed")
|
||||||
|
fail("agent_error", "verify status unavailable: "+verr.Error())
|
||||||
|
return
|
||||||
|
case verdict.Phase == "failed":
|
||||||
|
// The agent has ALREADY auto-rolled-back (units + creds) — no controller rollback needed.
|
||||||
|
fail(verdict.Code, verdict.Detail)
|
||||||
|
return
|
||||||
|
case verdict.Phase == "done":
|
||||||
|
// verified — proceed
|
||||||
|
default:
|
||||||
|
// "none" (agent restarted mid-verify — Scenario F) or an alien job id: the verify is
|
||||||
|
// LOST, the units may linger — roll back and fail loud.
|
||||||
|
rollback("verify lost (agent restart?)")
|
||||||
|
fail("mount_failed", fmt.Sprintf("agent verify job lost after install (phase=%q) — rolled back", verdict.Phase))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 3 — the in-guest uid-1000 write probe (the squash trap).
|
||||||
|
setPhase(netAddPhaseProbing)
|
||||||
|
outcome := s.netProbe(ctx, res.GuestPath)
|
||||||
|
if !outcome.OK {
|
||||||
|
rollback("probe failed")
|
||||||
|
fail(outcome.Category, outcome.Detail)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 4 — register. THE LAST STEP (worst crash outcome = an agent-side orphan, never a
|
||||||
|
// registered-but-broken path).
|
||||||
|
setPhase(netAddPhaseRegistering)
|
||||||
|
sp := settings.StoragePath{
|
||||||
|
Path: res.GuestPath,
|
||||||
|
Label: label,
|
||||||
|
Schedulable: true,
|
||||||
|
AddedAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
Kind: settings.StorageKindNetwork,
|
||||||
|
Protocol: req.Protocol,
|
||||||
|
Server: req.Server,
|
||||||
|
Export: req.Export,
|
||||||
|
MappedUID: req.MappedUID,
|
||||||
|
MappedGID: req.MappedGID,
|
||||||
|
}
|
||||||
|
if err := s.settings.AddStoragePath(sp); err != nil {
|
||||||
|
rollback("register failed")
|
||||||
|
fail("register_failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
job.Phase = netAddPhaseDone
|
||||||
|
job.Path = res.GuestPath
|
||||||
|
job.Warn = outcome.Warn
|
||||||
|
job.UpdatedAt = time.Now().UTC()
|
||||||
|
s.netAdd.set(job)
|
||||||
|
s.logger.Printf("[INFO] [web] network storage added + verified: %s (%s %s:%s) → %s (warn=%q)",
|
||||||
|
req.Name, req.Protocol, req.Server, req.Export, res.GuestPath, outcome.Warn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pollAgentVerify polls the agent's verify slot until it leaves `running` (or ctx expires).
|
||||||
|
// Transient poll errors are tolerated up to a small budget — the agent may be busy mounting.
|
||||||
|
func (s *Server) pollAgentVerify(ctx context.Context, agent netAgent, jobID string) (agentapi.NetVerifyStatus, error) {
|
||||||
|
var last agentapi.NetVerifyStatus
|
||||||
|
consecutiveErrs := 0
|
||||||
|
t := time.NewTicker(netVerifyPollEvery)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
st, err := agent.NetVerifyStatus(ctx)
|
||||||
|
if err != nil {
|
||||||
|
consecutiveErrs++
|
||||||
|
if consecutiveErrs >= 5 {
|
||||||
|
return last, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
consecutiveErrs = 0
|
||||||
|
last = st
|
||||||
|
if st.Phase != "running" {
|
||||||
|
// A different job id in the slot means OUR job is gone (restart + a newer add) —
|
||||||
|
// report it as the lost-verify shape, not a false done/failed.
|
||||||
|
if st.Phase != "none" && jobID != "" && st.JobID != jobID {
|
||||||
|
last.Phase = "none"
|
||||||
|
}
|
||||||
|
return last, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return last, ctx.Err()
|
||||||
|
case <-t.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// netAddMessage maps a failure category to the EXACT Hungarian customer message (§3.2). server and
|
||||||
|
// mappedUID parameterize the unreachable/not_writable texts (host id = mapped uid + 100000).
|
||||||
|
func netAddMessage(category, server string, mappedUID int) string {
|
||||||
|
switch category {
|
||||||
|
case "unreachable":
|
||||||
|
return "A szerver nem érhető el (" + server + "). Ellenőrizze az IP-címet, és hogy a NAS be van-e kapcsolva."
|
||||||
|
case "nfs_export":
|
||||||
|
return "A megosztás nem található, vagy a NAS nem engedélyezi ennek a gépnek a hozzáférését. Ellenőrizze az export útvonalát, és hogy a NAS engedélyezi-e a Felhom gép IP-címét."
|
||||||
|
case "smb_auth":
|
||||||
|
return "Hibás SMB felhasználónév vagy jelszó."
|
||||||
|
case "smb_share":
|
||||||
|
return "A megadott SMB-megosztás nem található a szerveren. Ellenőrizze a megosztás nevét."
|
||||||
|
case "timeout":
|
||||||
|
return "Időtúllépés a csatolás közben — a szerver elérhető a hálózaton, de a megosztás nem csatolható. Ellenőrizze a NAS NFS/SMB szolgáltatását."
|
||||||
|
case "not_writable":
|
||||||
|
return "A megosztás csatolható, de az alkalmazások nem tudnak rá írni. NFS esetén kapcsolja be a NAS-on a „minden felhasználó leképezése” (map all users / all squash) beállítást a megosztáson — vagy állítsa a fájlok tulajdonosát a(z) " + fmt.Sprint(mappedUID+100000) + " azonosítóra. SMB esetén ellenőrizze, hogy a felhasználónak írási joga van a megosztáson."
|
||||||
|
case "probe_io":
|
||||||
|
return "Írási hiba a megosztáson (az adat nem olvasható vissza hibátlanul). Ellenőrizze a megosztást és a hálózatot."
|
||||||
|
case "busy":
|
||||||
|
return "Már folyamatban van egy csatlakoztatás. Várja meg, amíg befejeződik."
|
||||||
|
default: // mount_failed + agent_error + register_failed + any future agent code
|
||||||
|
return "A csatolás sikertelen. Részletek alább."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// trimNetDetail bounds the raw detail shown in the UI collapsible.
|
||||||
|
func trimNetDetail(d string) string {
|
||||||
|
d = strings.TrimSpace(d)
|
||||||
|
if len(d) > 800 {
|
||||||
|
return d[:800] + "…"
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Orchestration tests (verify-before-commit): everything runs through the netAgent / netProbeFn /
|
||||||
|
// netListFn seams — no docker, no TLS, no re-exec.
|
||||||
|
|
||||||
|
// fakeNetAgent is the netAgent seam fake: scripted add/verify results + call recording.
|
||||||
|
type fakeNetAgent struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
addRes agentapi.NetStorageAddResult
|
||||||
|
addErr error
|
||||||
|
verify agentapi.NetVerifyStatus
|
||||||
|
verifyErr error
|
||||||
|
addCalls int
|
||||||
|
removes []string
|
||||||
|
verifyPoll int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeNetAgent) AddNetStorage(_ context.Context, req agentapi.AddNetStorageRequest) (agentapi.NetStorageAddResult, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.addCalls++
|
||||||
|
return f.addRes, f.addErr
|
||||||
|
}
|
||||||
|
func (f *fakeNetAgent) NetVerifyStatus(_ context.Context) (agentapi.NetVerifyStatus, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.verifyPoll++
|
||||||
|
return f.verify, f.verifyErr
|
||||||
|
}
|
||||||
|
func (f *fakeNetAgent) RemoveNetStorage(_ context.Context, name string) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.removes = append(f.removes, name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (f *fakeNetAgent) removed() []string {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return append([]string(nil), f.removes...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// okAddRes is the standard successful agent add result (units installed, verify started).
|
||||||
|
func okAddRes(name string) agentapi.NetStorageAddResult {
|
||||||
|
return agentapi.NetStorageAddResult{
|
||||||
|
Name: name, Protocol: "nfs", Where: settings.NetworkMountRoot + "/" + name,
|
||||||
|
GuestPath: settings.NetworkMountRoot + "/" + name, HostUID: 101000, HostGID: 101000,
|
||||||
|
Verify: "started", JobID: "job-1",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// netAddReq builds the standard orchestrator input.
|
||||||
|
func netAddReq(name string) agentapi.AddNetStorageRequest {
|
||||||
|
return agentapi.AddNetStorageRequest{
|
||||||
|
Name: name, Protocol: "nfs", Server: "10.0.0.5", Export: "/srv/" + name,
|
||||||
|
MappedUID: 1000, MappedGID: 1000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitNetAdd polls the job slot until it reaches a terminal phase.
|
||||||
|
func waitNetAdd(t *testing.T, s *Server) *netAddJob {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if j := s.netAdd.snapshot(); j != nil && (j.Phase == netAddPhaseDone || j.Phase == netAddPhaseFailed) {
|
||||||
|
return j
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatalf("net add job did not finish (last: %+v)", s.netAdd.snapshot())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// networkPathCount counts registered Kind=network paths.
|
||||||
|
func networkPathCount(s *Server) int {
|
||||||
|
n := 0
|
||||||
|
for _, sp := range s.settings.GetStoragePaths() {
|
||||||
|
if sp.IsNetwork() {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- C1: happy path — register ONLY after probe-ok, exactly once ------------------------------------
|
||||||
|
// Companion red-proof: move AddStoragePath before the probe → registeredAtProbe becomes 1 → FAIL.
|
||||||
|
func TestNetAdd_HappyPath_RegisterOnlyAfterProbe(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
agent := &fakeNetAgent{addRes: okAddRes("media"), verify: agentapi.NetVerifyStatus{Phase: "done", JobID: "job-1"}}
|
||||||
|
registeredAtProbe := -1
|
||||||
|
s.netProbeFn = func(_ context.Context, dir string) probeOutcome {
|
||||||
|
registeredAtProbe = networkPathCount(s) // MUST be 0 — registration is the LAST step
|
||||||
|
if dir != settings.NetworkMountRoot+"/media" {
|
||||||
|
t.Errorf("probe dir = %q, want the guest path", dir)
|
||||||
|
}
|
||||||
|
return probeOutcome{OK: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.startNetAdd(agent, netAddReq("media"), "NAS media") {
|
||||||
|
t.Fatal("startNetAdd refused with a free slot")
|
||||||
|
}
|
||||||
|
job := waitNetAdd(t, s)
|
||||||
|
if job.Phase != netAddPhaseDone {
|
||||||
|
t.Fatalf("phase = %s (category=%s detail=%s), want done", job.Phase, job.Category, job.Detail)
|
||||||
|
}
|
||||||
|
if registeredAtProbe != 0 {
|
||||||
|
t.Errorf("AddStoragePath ran BEFORE the probe (count at probe = %d, want 0)", registeredAtProbe)
|
||||||
|
}
|
||||||
|
if got := networkPathCount(s); got != 1 {
|
||||||
|
t.Errorf("registered network paths = %d, want exactly 1", got)
|
||||||
|
}
|
||||||
|
if len(agent.removed()) != 0 {
|
||||||
|
t.Errorf("happy path must not roll back: removes=%v", agent.removed())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- C2: probe-fail ⇒ rollback + NOT registered + failed{not_writable} -------------------------------
|
||||||
|
// Companion red-proof: drop the rollback call in the probe-fail branch → the removes assertion fails.
|
||||||
|
func TestNetAdd_ProbeFail_RollsBackNotRegistered(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
agent := &fakeNetAgent{addRes: okAddRes("media"), verify: agentapi.NetVerifyStatus{Phase: "done", JobID: "job-1"}}
|
||||||
|
s.netProbeFn = func(context.Context, string) probeOutcome {
|
||||||
|
return probeOutcome{OK: false, Category: "not_writable", Detail: "uid-1000 write probe: create/write refused"}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.startNetAdd(agent, netAddReq("media"), "NAS media") {
|
||||||
|
t.Fatal("startNetAdd refused")
|
||||||
|
}
|
||||||
|
job := waitNetAdd(t, s)
|
||||||
|
if job.Phase != netAddPhaseFailed || job.Category != "not_writable" {
|
||||||
|
t.Fatalf("phase/category = %s/%s, want failed/not_writable", job.Phase, job.Category)
|
||||||
|
}
|
||||||
|
// The §3.2 Route-A message, with the computed host id (1000 + 100000).
|
||||||
|
if !strings.Contains(job.Message, "minden felhasználó leképezése") || !strings.Contains(job.Message, "101000") {
|
||||||
|
t.Errorf("not_writable message must carry the map-all-users guidance + host id 101000: %q", job.Message)
|
||||||
|
}
|
||||||
|
if got := agent.removed(); len(got) != 1 || got[0] != "media" {
|
||||||
|
t.Errorf("probe-fail must roll the agent install back: removes=%v", got)
|
||||||
|
}
|
||||||
|
if got := networkPathCount(s); got != 0 {
|
||||||
|
t.Errorf("a probe-failed share must NOT be registered (got %d paths)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- C3: agent verify failed{code} ⇒ mapped Hungarian message, NOT registered ------------------------
|
||||||
|
func TestNetAdd_AgentVerifyFailed_MappedMessage(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
agent := &fakeNetAgent{
|
||||||
|
addRes: okAddRes("media"),
|
||||||
|
verify: agentapi.NetVerifyStatus{Phase: "failed", Code: "nfs_export", Detail: "reason given by server: No such file or directory", JobID: "job-1"},
|
||||||
|
}
|
||||||
|
probeRan := false
|
||||||
|
s.netProbeFn = func(context.Context, string) probeOutcome { probeRan = true; return probeOutcome{OK: true} }
|
||||||
|
|
||||||
|
if !s.startNetAdd(agent, netAddReq("media"), "NAS media") {
|
||||||
|
t.Fatal("startNetAdd refused")
|
||||||
|
}
|
||||||
|
job := waitNetAdd(t, s)
|
||||||
|
if job.Phase != netAddPhaseFailed || job.Category != "nfs_export" {
|
||||||
|
t.Fatalf("phase/category = %s/%s, want failed/nfs_export", job.Phase, job.Category)
|
||||||
|
}
|
||||||
|
// The EXACT §3.2 merged message (NFSv4 cannot distinguish not-found from not-permitted).
|
||||||
|
want := "A megosztás nem található, vagy a NAS nem engedélyezi ennek a gépnek a hozzáférését. Ellenőrizze az export útvonalát, és hogy a NAS engedélyezi-e a Felhom gép IP-címét."
|
||||||
|
if job.Message != want {
|
||||||
|
t.Errorf("nfs_export message:\n got %q\nwant %q", job.Message, want)
|
||||||
|
}
|
||||||
|
if probeRan {
|
||||||
|
t.Error("the probe must not run after a failed agent verify")
|
||||||
|
}
|
||||||
|
if got := networkPathCount(s); got != 0 {
|
||||||
|
t.Errorf("a verify-failed share must NOT be registered (got %d)", got)
|
||||||
|
}
|
||||||
|
// The AGENT already auto-rolled-back — the controller must not double-remove on this branch.
|
||||||
|
if got := agent.removed(); len(got) != 0 {
|
||||||
|
t.Errorf("verify-failed is agent-rolled-back; controller removes=%v want none", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- C4: agent "no job" after install ⇒ rollback + failed (Scenario F) --------------------------------
|
||||||
|
// Companion red-proof: treat "none" as success (or skip the rollback) → assertions fail.
|
||||||
|
func TestNetAdd_VerifyLost_RollsBack(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
agent := &fakeNetAgent{
|
||||||
|
addRes: okAddRes("media"),
|
||||||
|
verify: agentapi.NetVerifyStatus{Phase: "none"}, // the agent restarted mid-verify
|
||||||
|
}
|
||||||
|
s.netProbeFn = func(context.Context, string) probeOutcome {
|
||||||
|
t.Error("probe must not run when the verify was lost")
|
||||||
|
return probeOutcome{OK: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.startNetAdd(agent, netAddReq("media"), "NAS media") {
|
||||||
|
t.Fatal("startNetAdd refused")
|
||||||
|
}
|
||||||
|
job := waitNetAdd(t, s)
|
||||||
|
if job.Phase != netAddPhaseFailed {
|
||||||
|
t.Fatalf("phase = %s, want failed", job.Phase)
|
||||||
|
}
|
||||||
|
if got := agent.removed(); len(got) != 1 || got[0] != "media" {
|
||||||
|
t.Errorf("verify-lost must roll back the install: removes=%v", got)
|
||||||
|
}
|
||||||
|
if got := networkPathCount(s); got != 0 {
|
||||||
|
t.Errorf("a verify-lost share must NOT be registered (got %d)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- C5: the --netprobe child body (pure file logic, t.TempDir) ---------------------------------------
|
||||||
|
func TestNetProbeChild(t *testing.T) {
|
||||||
|
t.Run("ok", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if got := NetProbeChild(dir); got != netProbeExitOK {
|
||||||
|
t.Fatalf("exit = %d, want 0", got)
|
||||||
|
}
|
||||||
|
entries, _ := os.ReadDir(dir)
|
||||||
|
if len(entries) != 0 {
|
||||||
|
t.Errorf("probe must clean up its file, left: %v", entries)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("unwritable dir → exit 2 → not_writable", func(t *testing.T) {
|
||||||
|
dir := filepath.Join(t.TempDir(), "nope") // nonexistent → create fails everywhere
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
|
dir = t.TempDir()
|
||||||
|
if err := os.Chmod(dir, 0o555); err != nil { // read-only — the squash-trap shape
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = os.Chmod(dir, 0o755) })
|
||||||
|
}
|
||||||
|
got := NetProbeChild(dir)
|
||||||
|
if got != netProbeExitNoWrite {
|
||||||
|
t.Fatalf("exit = %d, want %d", got, netProbeExitNoWrite)
|
||||||
|
}
|
||||||
|
if v := netProbeVerdict(got, ""); v.OK || v.Category != "not_writable" {
|
||||||
|
t.Errorf("verdict = %+v, want not_writable", v)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("nonce tamper → exit 3 → probe_io", func(t *testing.T) {
|
||||||
|
orig := netProbeReadBack
|
||||||
|
netProbeReadBack = func(path string) ([]byte, error) { return []byte("tampered"), nil }
|
||||||
|
t.Cleanup(func() { netProbeReadBack = orig })
|
||||||
|
got := NetProbeChild(t.TempDir())
|
||||||
|
if got != netProbeExitMismatch {
|
||||||
|
t.Fatalf("exit = %d, want %d", got, netProbeExitMismatch)
|
||||||
|
}
|
||||||
|
if v := netProbeVerdict(got, ""); v.OK || v.Category != "probe_io" {
|
||||||
|
t.Errorf("verdict = %+v, want probe_io", v)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("cleanup fail → exit 4 → OK with warn", func(t *testing.T) {
|
||||||
|
orig := netProbeReadBack
|
||||||
|
// Read back correctly but DELETE the file first — the child's own Remove then fails.
|
||||||
|
netProbeReadBack = func(path string) ([]byte, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_ = os.Remove(path)
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { netProbeReadBack = orig })
|
||||||
|
got := NetProbeChild(t.TempDir())
|
||||||
|
if got != netProbeExitCleanup {
|
||||||
|
t.Fatalf("exit = %d, want %d", got, netProbeExitCleanup)
|
||||||
|
}
|
||||||
|
v := netProbeVerdict(got, "")
|
||||||
|
if !v.OK || v.Warn == "" {
|
||||||
|
t.Errorf("cleanup-fail must be OK-with-warn (§8), got %+v", v)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- C6: single-flight (Scenario G) -------------------------------------------------------------------
|
||||||
|
// Companion red-proof: drop the acquire() running-check → the second add is accepted → FAIL.
|
||||||
|
func TestNetAdd_SingleFlight(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
release := make(chan struct{})
|
||||||
|
agent := &fakeNetAgent{addRes: okAddRes("m1"), verify: agentapi.NetVerifyStatus{Phase: "done", JobID: "job-1"}}
|
||||||
|
s.netAgentFn = func() (netAgent, error) { return agent, nil }
|
||||||
|
s.netProbeFn = func(context.Context, string) probeOutcome { <-release; return probeOutcome{OK: true} }
|
||||||
|
|
||||||
|
if !s.startNetAdd(agent, netAddReq("m1"), "NAS m1") {
|
||||||
|
t.Fatal("first add refused")
|
||||||
|
}
|
||||||
|
// Second add through the HTTP handler while the first is blocked in probing.
|
||||||
|
body := `{"name":"m2","protocol":"nfs","server":"10.0.0.6","export":"/srv/m2"}`
|
||||||
|
r := httptest.NewRequest(http.MethodPost, "/api/storage/netstorage/add", strings.NewReader(body))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleNetStorageAdd(w, r)
|
||||||
|
if w.Code != http.StatusConflict {
|
||||||
|
t.Fatalf("second add: got %d want 409 (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(w.Body.String(), "már folyamatban van egy csatlakoztatás") {
|
||||||
|
t.Errorf("409 must carry the Hungarian busy message: %s", w.Body.String())
|
||||||
|
}
|
||||||
|
close(release)
|
||||||
|
job := waitNetAdd(t, s)
|
||||||
|
if job.Phase != netAddPhaseDone || job.Name != "m1" {
|
||||||
|
t.Errorf("first job must finish unaffected: %+v", job)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- C7: orphan surfacing — live-but-unregistered share renders as a remove-only row ------------------
|
||||||
|
// Companion red-proof: a registry-only filter (drop the live-map sweep) loses the ghost row → FAIL.
|
||||||
|
func TestNetStorage_OrphanRow(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
addNetworkPath(t, s, "media") // registered + live
|
||||||
|
s.netListFn = func(context.Context) ([]agentapi.NetworkMountStatus, error) {
|
||||||
|
return []agentapi.NetworkMountStatus{
|
||||||
|
{Name: "media", Protocol: "nfs", Server: "10.0.0.5", Where: settings.NetworkMountRoot + "/media", Configured: true, Mounted: true, Reachable: true, Health: "ok"},
|
||||||
|
{Name: "ghost", Protocol: "nfs", Server: "10.0.0.5", Where: settings.NetworkMountRoot + "/ghost", Configured: true, Mounted: false, Reachable: true, Health: "idle"},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
items := s.networkStorageItems(context.Background())
|
||||||
|
if len(items) != 2 {
|
||||||
|
t.Fatalf("items = %d, want 2 (registered + orphan): %+v", len(items), items)
|
||||||
|
}
|
||||||
|
var ghost *networkStorageItem
|
||||||
|
for i := range items {
|
||||||
|
if items[i].Name == "ghost" {
|
||||||
|
ghost = &items[i]
|
||||||
|
} else if items[i].Orphan {
|
||||||
|
t.Errorf("registered share %q must not be an orphan", items[i].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ghost == nil {
|
||||||
|
t.Fatalf("live-but-unregistered share missing from the list: %+v", items)
|
||||||
|
}
|
||||||
|
if !ghost.Orphan || ghost.Label != "Árva megosztás: ghost" {
|
||||||
|
t.Errorf("ghost row must be Orphan with the árva label, got %+v", *ghost)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNetAdd_StatusEndpoint_NoJob: the poll endpoint's empty shape.
|
||||||
|
func TestNetAdd_StatusEndpoint_NoJob(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
r := httptest.NewRequest(http.MethodGet, "/api/storage/netstorage/add/status", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleNetStorageAddStatus(w, r)
|
||||||
|
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"phase":"none"`) {
|
||||||
|
t.Fatalf("empty status: %d %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,6 +68,13 @@ type Server struct {
|
|||||||
// escrowed (DELETE /escrow/stage-secret). nil → the default agentClient()-backed impl; tests inject.
|
// escrowed (DELETE /escrow/stage-secret). nil → the default agentClient()-backed impl; tests inject.
|
||||||
wipeStagedEscrowFn func(ctx context.Context) error
|
wipeStagedEscrowFn func(ctx context.Context) error
|
||||||
|
|
||||||
|
// NAS add orchestration (verify-before-commit): the single-flight job slot + the two seams.
|
||||||
|
// netAgentFn nil → the shared agentClient(); netProbeFn nil → runNetProbe (the uid-1000 re-exec).
|
||||||
|
netAdd netAddState
|
||||||
|
netAgentFn func() (netAgent, error)
|
||||||
|
netProbeFn func(ctx context.Context, dir string) probeOutcome
|
||||||
|
netListFn func(ctx context.Context) ([]agentapi.NetworkMountStatus, error)
|
||||||
|
|
||||||
// Asset syncer for Hub-managed assets (optional)
|
// Asset syncer for Hub-managed assets (optional)
|
||||||
assetsSyncer *assets.Syncer
|
assetsSyncer *assets.Syncer
|
||||||
|
|
||||||
|
|||||||
@@ -288,8 +288,11 @@ func (s *Server) ServeStorageAPI(w http.ResponseWriter, r *http.Request) {
|
|||||||
case r.URL.Path == "/api/storage/decommission" && r.Method == http.MethodPost:
|
case r.URL.Path == "/api/storage/decommission" && r.Method == http.MethodPost:
|
||||||
s.handleStorageDecommission(w, r)
|
s.handleStorageDecommission(w, r)
|
||||||
// NAS network storage (Part A2) — distinct from the drive lifecycle above (proxy to agent /netstorage/*).
|
// NAS network storage (Part A2) — distinct from the drive lifecycle above (proxy to agent /netstorage/*).
|
||||||
|
// Add is verify-before-commit: it starts a detached orchestration job the UI polls on add/status.
|
||||||
case r.URL.Path == "/api/storage/netstorage/add" && r.Method == http.MethodPost:
|
case r.URL.Path == "/api/storage/netstorage/add" && r.Method == http.MethodPost:
|
||||||
s.handleNetStorageAdd(w, r)
|
s.handleNetStorageAdd(w, r)
|
||||||
|
case r.URL.Path == "/api/storage/netstorage/add/status" && r.Method == http.MethodGet:
|
||||||
|
s.handleNetStorageAddStatus(w, r)
|
||||||
case r.URL.Path == "/api/storage/netstorage" && r.Method == http.MethodGet:
|
case r.URL.Path == "/api/storage/netstorage" && r.Method == http.MethodGet:
|
||||||
s.handleNetStorageList(w, r)
|
s.handleNetStorageList(w, r)
|
||||||
case r.URL.Path == "/api/storage/netstorage/remove" && r.Method == http.MethodPost:
|
case r.URL.Path == "/api/storage/netstorage/remove" && r.Method == http.MethodPost:
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// C8 — template smoke for the redesigned NAS page: it renders through the PRODUCTION template tree
|
||||||
|
// and uses the canonical form classes; the nonexistent classes (`form-row`/`form-input`) and the
|
||||||
|
// summary-styled-as-button hack — the root causes of the unstyled look this task fixed — must never
|
||||||
|
// come back. Companion red-proof: reintroduce `class="form-input"` on an input → FAIL.
|
||||||
|
func TestStorageNetworkTemplate_CanonicalClasses(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
s.loadTemplates()
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"Page": "storage-network", "Title": "Hálózati tárhely",
|
||||||
|
"NetworkStoragePaths": []networkStorageItem{
|
||||||
|
{Name: "media", Label: "NAS media", Protocol: "nfs", Server: "10.0.0.5", Export: "/srv/media", Path: "/mnt/felhom-drives/media", Health: "ok"},
|
||||||
|
{Name: "ghost", Label: "Árva megosztás: ghost", Protocol: "nfs", Server: "10.0.0.5", Export: "/srv/ghost", Path: "/mnt/felhom-drives/ghost", Health: "idle", Orphan: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := s.tmpl.ExecuteTemplate(&buf, "storage_network", data); err != nil {
|
||||||
|
t.Fatalf("render storage_network: %v", err)
|
||||||
|
}
|
||||||
|
html := buf.String()
|
||||||
|
|
||||||
|
for _, want := range []string{
|
||||||
|
`class="form-control"`, // the canonical input class
|
||||||
|
`class="form-group"`, // the canonical field wrapper
|
||||||
|
"SMB (Synology, QNAP", // protocol-honest ordering: SMB listed first
|
||||||
|
"NFS (TrueNAS, Linux szerver)", // no bare "ajánlott" claim
|
||||||
|
"Árva", // the orphan badge renders
|
||||||
|
"minden felhasználó leképezése", // the Route-A guidance block
|
||||||
|
"ns-hostid", // the live computed host-id span
|
||||||
|
"/api/storage/netstorage/add/status", // the poll-driven progress wiring
|
||||||
|
} {
|
||||||
|
if !strings.Contains(html, want) {
|
||||||
|
t.Errorf("rendered page missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, banned := range []string{
|
||||||
|
"form-row", // nonexistent class — the old unstyled-look root cause
|
||||||
|
"form-input", // nonexistent class
|
||||||
|
`<summary class="btn`, // the summary-styled-as-button hack
|
||||||
|
} {
|
||||||
|
if strings.Contains(html, banned) {
|
||||||
|
t.Errorf("rendered page still contains the banned pattern %q", banned)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The orphan row renders with ONLY the remove action (no attach/migrate verbs anywhere near it).
|
||||||
|
if !strings.Contains(html, "Eltávolítás") {
|
||||||
|
t.Error("remove action missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,112 +2,213 @@
|
|||||||
{{template "layout_start" .}}
|
{{template "layout_start" .}}
|
||||||
|
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h2>Tárhely — Hálózati tárhely (NAS)</h2>
|
<div style="display:flex;align-items:center;gap:.5rem">
|
||||||
|
<a href="/settings" class="btn btn-sm btn-outline">← Vissza</a>
|
||||||
|
<h2>Tárhely — Hálózati tárhely (NAS)</h2>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-card">
|
<div class="settings-card">
|
||||||
<!-- NAS network storage (Part A2) — a distinct class from the physical drives above. No
|
<!-- NAS network storage (Part A2) — a distinct class from the physical drives. No
|
||||||
leválasztás/leszerelés/áthelyezés/törlés; the only lifecycle action is Eltávolítás. -->
|
leválasztás/leszerelés/áthelyezés/törlés; the only lifecycle action is Eltávolítás. -->
|
||||||
<div class="storage-section">
|
<h3>NAS-megosztások</h3>
|
||||||
<h3 style="margin-bottom:.25rem">NAS-megosztások</h3>
|
<p class="settings-card-desc">
|
||||||
<p class="form-hint" style="margin-top:0">
|
Egy NAS-megosztás (NFS vagy SMB) csatlakoztatása nagy méretű médiatartalomhoz (film, fotó, zene).
|
||||||
Egy NAS-megosztás (NFS vagy SMB) csatlakoztatása nagy méretű médiatartalomhoz (film, fotó, zene).
|
A megosztás kiválasztható médiaalkalmazás adatkönyvtáraként. A hálózati tárhely nem fizikai
|
||||||
A megosztás kiválasztható médiaalkalmazás adatkönyvtáraként. A hálózati tárhely nem fizikai
|
meghajtó — nincs leszerelés/áthelyezés, csak eltávolítás.
|
||||||
meghajtó — nincs leszerelés/áthelyezés, csak eltávolítás.
|
</p>
|
||||||
</p>
|
{{if .NetworkStoragePaths}}
|
||||||
{{if .NetworkStoragePaths}}
|
<div class="storage-paths-list">
|
||||||
<div class="storage-paths-list">
|
{{range .NetworkStoragePaths}}
|
||||||
{{range .NetworkStoragePaths}}
|
<div class="storage-path-item{{if eq .Health "unreachable"}} storage-disconnected{{end}}">
|
||||||
<div class="storage-path-item{{if eq .Health "unreachable"}} storage-disconnected{{end}}">
|
<div class="storage-path-header">
|
||||||
<div class="storage-path-header">
|
<div class="storage-path-info">
|
||||||
<div class="storage-path-info">
|
<span class="storage-path-label">{{.Label}}</span>
|
||||||
<span class="storage-path-label">{{.Label}}</span>
|
<span class="storage-path-path mono">{{.Protocol}} · {{.Server}}:{{.Export}} → {{.Path}}</span>
|
||||||
<span class="storage-path-path mono">{{.Protocol}} · {{.Server}}:{{.Export}} → {{.Path}}</span>
|
{{if .Orphan}}<span class="form-hint">A megosztás be van állítva a gazdagépen, de nincs regisztrálva — egy megszakadt csatlakoztatás maradványa. Távolítsa el, vagy adja hozzá újra ugyanezzel a névvel.</span>{{end}}
|
||||||
</div>
|
|
||||||
<div class="storage-path-badges">
|
|
||||||
{{if eq .Health "ok"}}<span class="badge badge-ok" title="A megosztás elérhető és csatlakoztatva van">Elérhető</span>
|
|
||||||
{{else if eq .Health "idle"}}<span class="badge badge-neutral" title="Elérhető, jelenleg készenlétben (igény szerint csatlakozik)">Készenlét</span>
|
|
||||||
{{else if eq .Health "unreachable"}}<span class="badge badge-warn" title="A NAS jelenleg nem érhető el — az érintett alkalmazások átmenetileg nem olvasnak róla">Nem elérhető</span>
|
|
||||||
{{else}}<span class="badge badge-neutral" title="Az állapot jelenleg nem lekérdezhető">Ismeretlen</span>{{end}}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="storage-path-actions">
|
<div class="storage-path-badges">
|
||||||
<button class="btn btn-xs btn-danger-outline" onclick="netStorageRemove('{{.Name}}','{{.Label}}')">Eltávolítás</button>
|
{{if .Orphan}}<span class="badge badge-warn" title="Beállítva a gazdagépen, de nincs regisztrálva">Árva</span>
|
||||||
|
{{else if eq .Health "ok"}}<span class="badge badge-ok" title="A megosztás elérhető és csatlakoztatva van">Elérhető</span>
|
||||||
|
{{else if eq .Health "idle"}}<span class="badge badge-neutral" title="Elérhető, jelenleg készenlétben (igény szerint csatlakozik)">Készenlét</span>
|
||||||
|
{{else if eq .Health "unreachable"}}<span class="badge badge-warn" title="A NAS jelenleg nem érhető el — az érintett alkalmazások átmenetileg nem olvasnak róla">Nem elérhető</span>
|
||||||
|
{{else}}<span class="badge badge-neutral" title="Az állapot jelenleg nem lekérdezhető">Ismeretlen</span>{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
<div class="storage-path-actions">
|
||||||
|
<button class="btn btn-xs btn-danger-outline" onclick="netStorageRemove('{{.Name}}','{{.Label}}')">Eltávolítás</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{else}}
|
|
||||||
<p class="form-hint">Nincs hálózati tárhely beállítva.</p>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<details style="margin-top:.75rem">
|
|
||||||
<summary class="btn btn-xs btn-primary" style="cursor:pointer;display:inline-block">Hálózati tárhely hozzáadása</summary>
|
|
||||||
<div style="margin-top:.75rem;padding:1rem;border:1px solid var(--border,#ddd);border-radius:6px;max-width:520px">
|
|
||||||
<div class="form-row"><label>Név (azonosító)</label>
|
|
||||||
<input id="ns-name" type="text" placeholder="pl. media" class="form-input"></div>
|
|
||||||
<div class="form-row"><label>Protokoll</label>
|
|
||||||
<select id="ns-protocol" class="form-input" onchange="nsToggleSmb()">
|
|
||||||
<option value="nfs">NFS (ajánlott)</option>
|
|
||||||
<option value="smb">SMB / CIFS</option>
|
|
||||||
</select></div>
|
|
||||||
<div class="form-row"><label>Szerver (IP vagy hosztnév)</label>
|
|
||||||
<input id="ns-server" type="text" placeholder="pl. 192.168.0.10" class="form-input"></div>
|
|
||||||
<div class="form-row"><label id="ns-export-label">Megosztás (NFS export útvonal)</label>
|
|
||||||
<input id="ns-export" type="text" placeholder="pl. /volume1/media" class="form-input"></div>
|
|
||||||
<div class="form-row"><label>Alkalmazás felhasználói azonosító (uid)</label>
|
|
||||||
<input id="ns-uid" type="number" value="1000" class="form-input">
|
|
||||||
<span class="form-hint">A legtöbb médiaalkalmazás 1000-es uid-del fut.</span></div>
|
|
||||||
<div id="ns-smb-creds" style="display:none">
|
|
||||||
<div class="form-row"><label>SMB felhasználónév</label>
|
|
||||||
<input id="ns-username" type="text" autocomplete="off" class="form-input"></div>
|
|
||||||
<div class="form-row"><label>SMB jelszó</label>
|
|
||||||
<input id="ns-password" type="password" autocomplete="new-password" class="form-input">
|
|
||||||
<span class="form-hint">A jelszót a gazda ügynök 0600-as fájlba írja; a vezérlő nem tárolja.</span></div>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-sm btn-primary" style="margin-top:.5rem" onclick="netStorageAdd()">Csatlakoztatás</button>
|
|
||||||
<div id="ns-add-msg" class="form-hint" style="margin-top:.5rem"></div>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</div>
|
</div>
|
||||||
<script>
|
{{else}}
|
||||||
function nsToggleSmb(){
|
<p class="form-hint">Nincs hálózati tárhely beállítva.</p>
|
||||||
var smb = document.getElementById('ns-protocol').value === 'smb';
|
{{end}}
|
||||||
document.getElementById('ns-smb-creds').style.display = smb ? 'block' : 'none';
|
|
||||||
document.getElementById('ns-export-label').textContent = smb ? 'Megosztás (SMB megosztásnév)' : 'Megosztás (NFS export útvonal)';
|
|
||||||
document.getElementById('ns-export').placeholder = smb ? 'pl. media' : 'pl. /volume1/media';
|
|
||||||
}
|
|
||||||
function netStorageAdd(){
|
|
||||||
var msg = document.getElementById('ns-add-msg');
|
|
||||||
var body = {
|
|
||||||
name: (document.getElementById('ns-name').value||'').trim(),
|
|
||||||
protocol: document.getElementById('ns-protocol').value,
|
|
||||||
server: (document.getElementById('ns-server').value||'').trim(),
|
|
||||||
export: (document.getElementById('ns-export').value||'').trim(),
|
|
||||||
mapped_uid: parseInt(document.getElementById('ns-uid').value||'1000',10),
|
|
||||||
mapped_gid: parseInt(document.getElementById('ns-uid').value||'1000',10),
|
|
||||||
username: (document.getElementById('ns-username').value||''),
|
|
||||||
password: (document.getElementById('ns-password').value||'')
|
|
||||||
};
|
|
||||||
msg.textContent = 'Csatlakoztatás folyamatban…';
|
|
||||||
fetch('/api/storage/netstorage/add',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},csrfHeaders()),body:JSON.stringify(body)})
|
|
||||||
.then(function(r){return r.json();}).then(function(d){
|
|
||||||
if(d.ok){ msg.textContent='Sikeres'; setTimeout(function(){location.reload();},900); }
|
|
||||||
else { msg.textContent='Hiba: '+(d.error||'ismeretlen'); }
|
|
||||||
}).catch(function(e){ msg.textContent='Hiba: '+e; });
|
|
||||||
}
|
|
||||||
function netStorageRemove(name,label){
|
|
||||||
openDialog({title:'Hálózati tárhely eltávolítása', confirmLabel:'Eltávolítás',
|
|
||||||
message:'Biztosan eltávolítja a(z) '+label+' hálózati tárhelyet?\n\nA megosztás leválasztásra kerül; a NAS-on lévő adatok érintetlenek maradnak.',
|
|
||||||
onConfirm:function(){
|
|
||||||
fetch('/api/storage/netstorage/remove',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},csrfHeaders()),body:JSON.stringify({name:name})})
|
|
||||||
.then(function(r){return r.json();}).then(function(d){
|
|
||||||
if(d.ok){ location.reload(); } else { alert('Hiba: '+(d.error||'ismeretlen')); }
|
|
||||||
}).catch(function(e){ alert('Hiba: '+e); });
|
|
||||||
}});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-card">
|
||||||
|
<h3>Hálózati tárhely hozzáadása</h3>
|
||||||
|
<p class="settings-card-desc">A csatlakoztatás előtt a rendszer ellenőrzi a megosztást: valóban
|
||||||
|
csatolható-e, és tudnak-e írni rá az alkalmazások. Hiba esetén semmi nem marad félkészen beállítva.</p>
|
||||||
|
<form id="ns-add-form" onsubmit="return netStorageAdd(event)">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="ns-protocol">Protokoll</label>
|
||||||
|
<select id="ns-protocol" class="form-control" onchange="nsToggleSmb()">
|
||||||
|
<option value="smb">SMB (Synology, QNAP — a legtöbb NAS)</option>
|
||||||
|
<option value="nfs">NFS (TrueNAS, Linux szerver)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="ns-name">Név (azonosító) <span class="required">*</span></label>
|
||||||
|
<input id="ns-name" type="text" class="form-control" placeholder="pl. media"
|
||||||
|
pattern="[a-zA-Z0-9_-]+" required style="max-width:220px">
|
||||||
|
<span class="form-hint">A megosztás a /mnt/felhom-drives/<név> útvonalon lesz elérhető.</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="ns-server">Szerver (IP vagy hosztnév) <span class="required">*</span></label>
|
||||||
|
<input id="ns-server" type="text" class="form-control" placeholder="pl. 192.168.0.10" required style="max-width:220px">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="ns-export" id="ns-export-label">Megosztás neve</label>
|
||||||
|
<input id="ns-export" type="text" class="form-control" placeholder="pl. media" required style="max-width:320px">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="ns-uid">Alkalmazás felhasználói azonosító (uid)</label>
|
||||||
|
<input id="ns-uid" type="number" value="1000" class="form-control" style="max-width:120px" oninput="nsUpdateHostID()">
|
||||||
|
<span class="form-hint">A legtöbb médiaalkalmazás 1000-es azonosítóval fut.</span>
|
||||||
|
</div>
|
||||||
|
<div id="ns-smb-creds">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="ns-username">SMB felhasználónév <span class="required">*</span></label>
|
||||||
|
<input id="ns-username" type="text" autocomplete="off" class="form-control" style="max-width:220px">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="ns-password">SMB jelszó <span class="required">*</span></label>
|
||||||
|
<input id="ns-password" type="password" autocomplete="new-password" class="form-control" style="max-width:220px">
|
||||||
|
<span class="form-hint">A jelszót a gazda ügynök 0600-as fájlba írja; a vezérlő nem tárolja.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions" style="gap:.75rem">
|
||||||
|
<button type="submit" class="btn btn-primary" id="ns-add-btn">Csatlakoztatás</button>
|
||||||
|
</div>
|
||||||
|
<div id="ns-add-result" style="margin-top:1rem"></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-card">
|
||||||
|
<h3>Mit kell beállítani a NAS-on?</h3>
|
||||||
|
<details>
|
||||||
|
<summary>Útmutató megjelenítése</summary>
|
||||||
|
<p>Engedélyezze a Felhom gép IP-címét a megosztáson. Proxmox-fürt esetén minden csomópont IP-címét.</p>
|
||||||
|
<h4>SMB (Synology, QNAP — a legtöbb NAS)</h4>
|
||||||
|
<p>Hozzon létre (vagy jelöljön ki) egy felhasználót a NAS-on, és adjon neki írás-olvasás jogot a
|
||||||
|
megosztásra. Más beállítás nem szükséges — a fájlok a NAS-on ennek a felhasználónak a nevében
|
||||||
|
jönnek létre.</p>
|
||||||
|
<h4>NFS</h4>
|
||||||
|
<p>Egyszerű (a legtöbb NAS-hoz): kapcsolja be az exporton a „minden felhasználó leképezése” (map all
|
||||||
|
users / all squash) opciót írás-olvasás móddal — bármelyik helyi felhasználóra. A fájlok
|
||||||
|
tulajdonosa a rendszerben „nobody”-ként látszik; az alkalmazások túlnyomó többségének ez megfelelő.</p>
|
||||||
|
<p>Teljes értékű (TrueNAS / Linux szerver): export a következő opciókkal:
|
||||||
|
<code class="mono">rw,all_squash,anonuid=<span class="ns-hostid">101000</span>,anongid=<span class="ns-hostid">101000</span></code>
|
||||||
|
— így a tulajdonos-információk is hibátlanok.</p>
|
||||||
|
<p class="form-hint">A Synology/QNAP felületére szabott lépésről lépésre útmutató készül.</p>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function nsToggleSmb(){
|
||||||
|
var smb = document.getElementById('ns-protocol').value === 'smb';
|
||||||
|
document.getElementById('ns-smb-creds').style.display = smb ? 'block' : 'none';
|
||||||
|
document.getElementById('ns-export-label').textContent = smb ? 'Megosztás neve' : 'Megosztás (NFS export útvonal)';
|
||||||
|
document.getElementById('ns-export').placeholder = smb ? 'pl. media' : 'pl. /volume1/media';
|
||||||
|
document.getElementById('ns-username').required = smb;
|
||||||
|
document.getElementById('ns-password').required = smb;
|
||||||
|
}
|
||||||
|
function nsUpdateHostID(){
|
||||||
|
var uid = parseInt(document.getElementById('ns-uid').value||'1000',10);
|
||||||
|
if(isNaN(uid) || uid < 0){ uid = 1000; }
|
||||||
|
document.querySelectorAll('.ns-hostid').forEach(function(el){ el.textContent = String(uid + 100000); });
|
||||||
|
}
|
||||||
|
function nsEsc(s){ return String(s==null?'':s).replace(/[&<>"]/g,function(c){return {'&':'&','<':'<','>':'>','"':'"'}[c];}); }
|
||||||
|
|
||||||
|
// Poll-driven progress: the add starts a server-side verify job; we poll its status and show the
|
||||||
|
// staged Hungarian text until a terminal phase.
|
||||||
|
var nsPhaseText = {
|
||||||
|
agent_add: 'Kapcsolódás a szerverhez…',
|
||||||
|
verifying: 'Csatolási teszt…',
|
||||||
|
probing: 'Írásteszt…',
|
||||||
|
registering: 'Regisztrálás…'
|
||||||
|
};
|
||||||
|
var nsPollTimer = null;
|
||||||
|
|
||||||
|
function netStorageAdd(ev){
|
||||||
|
ev.preventDefault();
|
||||||
|
var btn = document.getElementById('ns-add-btn');
|
||||||
|
var out = document.getElementById('ns-add-result');
|
||||||
|
var uid = parseInt(document.getElementById('ns-uid').value||'1000',10);
|
||||||
|
var body = {
|
||||||
|
name: (document.getElementById('ns-name').value||'').trim(),
|
||||||
|
protocol: document.getElementById('ns-protocol').value,
|
||||||
|
server: (document.getElementById('ns-server').value||'').trim(),
|
||||||
|
export: (document.getElementById('ns-export').value||'').trim(),
|
||||||
|
mapped_uid: uid,
|
||||||
|
mapped_gid: uid,
|
||||||
|
username: (document.getElementById('ns-username').value||''),
|
||||||
|
password: (document.getElementById('ns-password').value||'')
|
||||||
|
};
|
||||||
|
btn.disabled = true;
|
||||||
|
out.innerHTML = '<p class="form-hint">' + nsPhaseText.agent_add + '</p>';
|
||||||
|
fetch('/api/storage/netstorage/add',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},csrfHeaders()),body:JSON.stringify(body)})
|
||||||
|
.then(function(r){return r.json();}).then(function(d){
|
||||||
|
if(!d.ok){ nsShowError((d.error||'ismeretlen hiba'), ''); btn.disabled=false; return; }
|
||||||
|
nsPollTimer = setInterval(nsPollStatus, 1500);
|
||||||
|
}).catch(function(e){ nsShowError(String(e), ''); btn.disabled=false; });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nsPollStatus(){
|
||||||
|
fetch('/api/storage/netstorage/add/status')
|
||||||
|
.then(function(r){return r.json();}).then(function(d){
|
||||||
|
var st = (d.data||{});
|
||||||
|
var out = document.getElementById('ns-add-result');
|
||||||
|
if(st.phase === 'done'){
|
||||||
|
clearInterval(nsPollTimer);
|
||||||
|
var warn = st.warn ? ' <span class="form-hint">(' + nsEsc(st.warn) + ')</span>' : '';
|
||||||
|
out.innerHTML = '<div class="alert alert-success">A hálózati tárhely sikeresen csatlakoztatva és ellenőrizve: <strong class="mono">' + nsEsc(st.path||'') + '</strong>.' + warn + '</div>';
|
||||||
|
setTimeout(function(){ location.reload(); }, 1500);
|
||||||
|
} else if(st.phase === 'failed'){
|
||||||
|
clearInterval(nsPollTimer);
|
||||||
|
nsShowError(st.message || 'A csatolás sikertelen.', st.detail || '');
|
||||||
|
document.getElementById('ns-add-btn').disabled = false;
|
||||||
|
} else if(nsPhaseText[st.phase]){
|
||||||
|
out.innerHTML = '<p class="form-hint">' + nsPhaseText[st.phase] + '</p>';
|
||||||
|
}
|
||||||
|
}).catch(function(){ /* transient poll error — keep polling */ });
|
||||||
|
}
|
||||||
|
|
||||||
|
function nsShowError(message, detail){
|
||||||
|
var out = document.getElementById('ns-add-result');
|
||||||
|
var html = '<div class="alert alert-error">' + nsEsc(message);
|
||||||
|
if(detail){
|
||||||
|
html += '<details class="mono" style="margin-top:.5rem"><summary>Technikai részletek</summary><p style="white-space:pre-wrap">' + nsEsc(detail) + '</p></details>';
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
out.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function netStorageRemove(name,label){
|
||||||
|
openDialog({title:'Hálózati tárhely eltávolítása', confirmLabel:'Eltávolítás',
|
||||||
|
message:'Biztosan eltávolítja a(z) '+label+' hálózati tárhelyet?\n\nA megosztás leválasztásra kerül; a NAS-on lévő adatok érintetlenek maradnak.',
|
||||||
|
onConfirm:function(){
|
||||||
|
fetch('/api/storage/netstorage/remove',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},csrfHeaders()),body:JSON.stringify({name:name})})
|
||||||
|
.then(function(r){return r.json();}).then(function(d){
|
||||||
|
if(d.ok){ location.reload(); } else { alert('Hiba: '+(d.error||'ismeretlen')); }
|
||||||
|
}).catch(function(e){ alert('Hiba: '+e); });
|
||||||
|
}});
|
||||||
|
}
|
||||||
|
nsToggleSmb();
|
||||||
|
nsUpdateHostID();
|
||||||
|
</script>
|
||||||
<div id="dialog-root"></div>
|
<div id="dialog-root"></div>
|
||||||
<script>
|
<script>
|
||||||
// Light overlay dialog (D1) — replaces the native blocking browser dialogs (same texts).
|
// Light overlay dialog (D1) — replaces the native blocking browser dialogs (same texts).
|
||||||
|
|||||||
Reference in New Issue
Block a user