style: gofmt normalization — no logic changes
gofmt -w across the controller tree (46 files) so gofmt -l is empty — disarms the
formatting landmine where a targeted edit + accidental gofmt -w swept ~46 unrelated
files. Pure formatting: whitespace + gofmt's optional-semicolon removal in reflowed
inline closures. One doc comment reworded ('' -> 'the empty string') to avoid gofmt's
Go-1.19 doc-comment typographic substitition ('' -> curly quote) muddying its meaning.
No build/vet/test behavior change.
This commit is contained in:
@@ -9,8 +9,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func netStub(t *testing.T) (*httptest.Server, string, *struct {
|
func netStub(t *testing.T) (*httptest.Server, string, *struct {
|
||||||
addBody AddNetStorageRequest
|
addBody AddNetStorageRequest
|
||||||
removed string
|
removed string
|
||||||
}) {
|
}) {
|
||||||
captured := &struct {
|
captured := &struct {
|
||||||
addBody AddNetStorageRequest
|
addBody AddNetStorageRequest
|
||||||
|
|||||||
@@ -108,10 +108,10 @@ func TestComputeCaptureSet_ExcludedInvisible(t *testing.T) {
|
|||||||
|
|
||||||
func TestComputeCaptureSet_StructuralGuards(t *testing.T) {
|
func TestComputeCaptureSet_StructuralGuards(t *testing.T) {
|
||||||
binds := []ClassifiedBind{
|
binds := []ClassifiedBind{
|
||||||
{ComposeBind: ComposeBind{Root: RootHDD, RelPath: "../evil"}, Class: ClassMandatory, Origin: OriginDefaultWritable}, // d1 traversal
|
{ComposeBind: ComposeBind{Root: RootHDD, RelPath: "../evil"}, Class: ClassMandatory, Origin: OriginDefaultWritable}, // d1 traversal
|
||||||
{ComposeBind: ComposeBind{Root: RootHDD, RelPath: ""}, Class: ClassMandatory, Origin: OriginDefaultWritable}, // d2 bare hdd root
|
{ComposeBind: ComposeBind{Root: RootHDD, RelPath: ""}, Class: ClassMandatory, Origin: OriginDefaultWritable}, // d2 bare hdd root
|
||||||
{ComposeBind: ComposeBind{Root: RootHDD, RelPath: "backups/primary/x"}, Class: ClassMandatory, Origin: OriginDefaultWritable}, // d3 reserved zone
|
{ComposeBind: ComposeBind{Root: RootHDD, RelPath: "backups/primary/x"}, Class: ClassMandatory, Origin: OriginDefaultWritable}, // d3 reserved zone
|
||||||
{ComposeBind: ComposeBind{Root: RootUserdata, RelPath: ""}, Class: ClassMandatory, Origin: OriginDefaultWritable}, // d4 bare userdata — ALLOWED
|
{ComposeBind: ComposeBind{Root: RootUserdata, RelPath: ""}, Class: ClassMandatory, Origin: OriginDefaultWritable}, // d4 bare userdata — ALLOWED
|
||||||
}
|
}
|
||||||
cs := ComputeCaptureSet(binds, true, TierOffsite, drv)
|
cs := ComputeCaptureSet(binds, true, TierOffsite, drv)
|
||||||
|
|
||||||
|
|||||||
@@ -764,7 +764,8 @@ func getMariaDBPassword(ctx context.Context, containerID string) string {
|
|||||||
// - else known[containerName] → containerName (the container name IS the stack — don't strip, e.g. my-cache).
|
// - else known[containerName] → containerName (the container name IS the stack — don't strip, e.g. my-cache).
|
||||||
// - else longest known prefix → handles <stack>_postgres / <stack>-1 / compose-suffixed names.
|
// - else longest known prefix → handles <stack>_postgres / <stack>-1 / compose-suffixed names.
|
||||||
// - else → candidate (fall back to today's suffix-strip; preserves behaviour when
|
// - else → candidate (fall back to today's suffix-strip; preserves behaviour when
|
||||||
// the stack list is empty/unavailable, so nothing regresses).
|
// the stack list is empty/unavailable, so nothing regresses).
|
||||||
|
//
|
||||||
// A nil/empty `known` map = the legacy fast path (pure suffix-strip).
|
// A nil/empty `known` map = the legacy fast path (pure suffix-strip).
|
||||||
func deriveStackName(containerName string, known map[string]bool) string {
|
func deriveStackName(containerName string, known map[string]bool) string {
|
||||||
candidate := suffixStripStackName(containerName)
|
candidate := suffixStripStackName(containerName)
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
magicHeader = "FABE" // Felhom App Bundle Encrypted
|
magicHeader = "FABE" // Felhom App Bundle Encrypted
|
||||||
scryptN = 1 << 15 // 32768
|
scryptN = 1 << 15 // 32768
|
||||||
scryptR = 8
|
scryptR = 8
|
||||||
scryptP = 1
|
scryptP = 1
|
||||||
saltSize = 32
|
saltSize = 32
|
||||||
|
|||||||
@@ -468,8 +468,8 @@ func (e *Exporter) GetDebugInfo() map[string]interface{} {
|
|||||||
defer e.mu.Unlock()
|
defer e.mu.Unlock()
|
||||||
|
|
||||||
info := map[string]interface{}{
|
info := map[string]interface{}{
|
||||||
"debug_enabled": e.debug,
|
"debug_enabled": e.debug,
|
||||||
"version": e.version,
|
"version": e.version,
|
||||||
"has_active_job": e.activeJob != nil,
|
"has_active_job": e.activeJob != nil,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ func TestExport_HDDMountBasenameCollisionFailsLoud(t *testing.T) {
|
|||||||
|
|
||||||
prov := &hddProvider{
|
prov := &hddProvider{
|
||||||
rtProvider: &rtProvider{stackDir: srcStack, stacksDir: t.TempDir(), deployed: true},
|
rtProvider: &rtProvider{stackDir: srcStack, stacksDir: t.TempDir(), deployed: true},
|
||||||
mounts: []string{a, b}, hddPath: hdd,
|
mounts: []string{a, b}, hddPath: hdd,
|
||||||
}
|
}
|
||||||
drive := t.TempDir()
|
drive := t.TempDir()
|
||||||
e := NewExporter(prov, log.New(io.Discard, "", 0), "test")
|
e := NewExporter(prov, log.New(io.Discard, "", 0), "test")
|
||||||
@@ -229,7 +229,7 @@ func TestFabRoundTrip_UserdataPlacement(t *testing.T) {
|
|||||||
|
|
||||||
prov := &hddProvider{
|
prov := &hddProvider{
|
||||||
rtProvider: &rtProvider{stackDir: srcStack, stacksDir: t.TempDir(), deployed: true},
|
rtProvider: &rtProvider{stackDir: srcStack, stacksDir: t.TempDir(), deployed: true},
|
||||||
mounts: []string{ud}, hddPath: hdd,
|
mounts: []string{ud}, hddPath: hdd,
|
||||||
}
|
}
|
||||||
drive := t.TempDir()
|
drive := t.TempDir()
|
||||||
e := NewExporter(prov, lg, "test")
|
e := NewExporter(prov, lg, "test")
|
||||||
@@ -249,7 +249,7 @@ func TestFabRoundTrip_UserdataPlacement(t *testing.T) {
|
|||||||
|
|
||||||
prov2 := &hddProvider{
|
prov2 := &hddProvider{
|
||||||
rtProvider: &rtProvider{stackDir: srcStack, stacksDir: t.TempDir(), deployed: false},
|
rtProvider: &rtProvider{stackDir: srcStack, stacksDir: t.TempDir(), deployed: false},
|
||||||
hddPath: hdd,
|
hddPath: hdd,
|
||||||
}
|
}
|
||||||
e2 := NewExporter(prov2, lg, "test")
|
e2 := NewExporter(prov2, lg, "test")
|
||||||
if err := e2.StartImport(ImportRequest{FABPath: fabPath}); err != nil {
|
if err := e2.StartImport(ImportRequest{FABPath: fabPath}); err != nil {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ type fabProv struct {
|
|||||||
mounts []string
|
mounts []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *fabProv) GetStackHDDPath(string) string { return p.hddPath }
|
func (p *fabProv) GetStackHDDPath(string) string { return p.hddPath }
|
||||||
func (p *fabProv) GetStackHDDMounts(string) []string { return p.mounts }
|
func (p *fabProv) GetStackHDDMounts(string) []string { return p.mounts }
|
||||||
func (p *fabProv) GetStackClassifiedBinds(string) ([]appbackup.ClassifiedBind, bool) {
|
func (p *fabProv) GetStackClassifiedBinds(string) ([]appbackup.ClassifiedBind, bool) {
|
||||||
return p.binds, p.has
|
return p.binds, p.has
|
||||||
|
|||||||
@@ -35,22 +35,22 @@ func (p *rtProvider) GetStackDir(string) (string, bool) { return p.stackDir, tru
|
|||||||
func (p *rtProvider) GetStackComposePath(string) (string, bool) {
|
func (p *rtProvider) GetStackComposePath(string) (string, bool) {
|
||||||
return filepath.Join(p.stackDir, "docker-compose.yml"), true
|
return filepath.Join(p.stackDir, "docker-compose.yml"), true
|
||||||
}
|
}
|
||||||
func (p *rtProvider) GetStackHDDMounts(string) []string { return nil }
|
func (p *rtProvider) GetStackHDDMounts(string) []string { return nil }
|
||||||
func (p *rtProvider) GetStackHDDPath(string) string { return "" }
|
func (p *rtProvider) GetStackHDDPath(string) string { return "" }
|
||||||
func (p *rtProvider) GetStackClassifiedBinds(string) ([]appbackup.ClassifiedBind, bool) {
|
func (p *rtProvider) GetStackClassifiedBinds(string) ([]appbackup.ClassifiedBind, bool) {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
func (p *rtProvider) IsStackRunning(string) bool { return p.running }
|
func (p *rtProvider) IsStackRunning(string) bool { return p.running }
|
||||||
func (p *rtProvider) StopStack(string) error { p.stopped++; return nil }
|
func (p *rtProvider) StopStack(string) error { p.stopped++; return nil }
|
||||||
func (p *rtProvider) StartStack(string) error { p.started = true; return nil }
|
func (p *rtProvider) StartStack(string) error { p.started = true; return nil }
|
||||||
func (p *rtProvider) GetStackDisplayName(n string) string { return "RT " + n }
|
func (p *rtProvider) GetStackDisplayName(n string) string { return "RT " + n }
|
||||||
func (p *rtProvider) GetStackNeedsHDD(string) bool { return false }
|
func (p *rtProvider) GetStackNeedsHDD(string) bool { return false }
|
||||||
func (p *rtProvider) GetDockerVolumes(string) []string { return p.volumes }
|
func (p *rtProvider) GetDockerVolumes(string) []string { return p.volumes }
|
||||||
func (p *rtProvider) IsStackDeployed(string) bool { return p.deployed }
|
func (p *rtProvider) IsStackDeployed(string) bool { return p.deployed }
|
||||||
func (p *rtProvider) GetDecryptedEnv(string) map[string]string { return nil }
|
func (p *rtProvider) GetDecryptedEnv(string) map[string]string { return nil }
|
||||||
func (p *rtProvider) GetStacksBaseDir() string { return p.stacksDir }
|
func (p *rtProvider) GetStacksBaseDir() string { return p.stacksDir }
|
||||||
func (p *rtProvider) RefreshStacks() error { return nil }
|
func (p *rtProvider) RefreshStacks() error { return nil }
|
||||||
func (p *rtProvider) RemoveStackVolumes(string) error { p.removed++; return nil }
|
func (p *rtProvider) RemoveStackVolumes(string) error { p.removed++; return nil }
|
||||||
func (p *rtProvider) SaveEncryptedAppConfig(stackDir string, env map[string]string) error {
|
func (p *rtProvider) SaveEncryptedAppConfig(stackDir string, env map[string]string) error {
|
||||||
p.savedEnv = env
|
p.savedEnv = env
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -18,10 +18,11 @@ import (
|
|||||||
// RecoveryManifest describes an app's self-contained, SECRET-FREE recovery unit (Phase 2).
|
// RecoveryManifest describes an app's self-contained, SECRET-FREE recovery unit (Phase 2).
|
||||||
//
|
//
|
||||||
// The unit on a drive is `<nsRoot>/backups/primary/<app>/` and contains:
|
// The unit on a drive is `<nsRoot>/backups/primary/<app>/` and contains:
|
||||||
// compose/ docker-compose.yml + .felhom.yml + a SECRET-STRIPPED app.yaml
|
//
|
||||||
// db-dumps/ app-consistent DB dump(s) (written by the dump flow)
|
// compose/ docker-compose.yml + .felhom.yml + a SECRET-STRIPPED app.yaml
|
||||||
// volume-dumps/ named-volume tars (written by the dump flow)
|
// db-dumps/ app-consistent DB dump(s) (written by the dump flow)
|
||||||
// manifest.json this file
|
// volume-dumps/ named-volume tars (written by the dump flow)
|
||||||
|
// manifest.json this file
|
||||||
//
|
//
|
||||||
// The unit holds NO secret values, NO data-encrypting keys, and NOT the Docker image — only the
|
// The unit holds NO secret values, NO data-encrypting keys, and NOT the Docker image — only the
|
||||||
// pinned image tag(s) (re-pulled on restore) and the NAMES of the secret/data-key env vars. The
|
// pinned image tag(s) (re-pulled on restore) and the NAMES of the secret/data-key env vars. The
|
||||||
|
|||||||
@@ -187,10 +187,12 @@ func (m *Manager) tier2FitsSystemDrive(sys string, unitSizeBytes int64) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Tier-2 v2 layout (Task 3b, architecture §8): backups/secondary/<stack>/ holds
|
// Tier-2 v2 layout (Task 3b, architecture §8): backups/secondary/<stack>/ holds
|
||||||
// .felhom-tier2-layout (marker file, content "2" — written LAST, after all legs + reconcile)
|
//
|
||||||
// recovery-unit/ (the unit leg, layout-identical to v1)
|
// .felhom-tier2-layout (marker file, content "2" — written LAST, after all legs + reconcile)
|
||||||
// hdd/<relpath>/ (per-bind HDD legs, relpath-mirroring)
|
// recovery-unit/ (the unit leg, layout-identical to v1)
|
||||||
// userdata/<relpath>/ (per-bind USERDATA legs)
|
// hdd/<relpath>/ (per-bind HDD legs, relpath-mirroring)
|
||||||
|
// userdata/<relpath>/ (per-bind USERDATA legs)
|
||||||
|
//
|
||||||
// Relpath-mirroring represents N>1 dirs + nested binds natively (the v1 flat-appdata N>1 refusal is
|
// Relpath-mirroring represents N>1 dirs + nested binds natively (the v1 flat-appdata N>1 refusal is
|
||||||
// lifted structurally) and makes restore position-derivable (dest relpath → live path under the app's
|
// lifted structurally) and makes restore position-derivable (dest relpath → live path under the app's
|
||||||
// current HDD_PATH). The whole tree is DERIVED from live data — migration is delete-and-rebuild.
|
// current HDD_PATH). The whole tree is DERIVED from live data — migration is delete-and-rebuild.
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import (
|
|||||||
// is fetched once via the hub consume endpoint (never in config). Mirrors hub offsite.Descriptor.
|
// is fetched once via the hub consume endpoint (never in config). Mirrors hub offsite.Descriptor.
|
||||||
type OffsiteConfig struct {
|
type OffsiteConfig struct {
|
||||||
Enabled bool `yaml:"enabled"`
|
Enabled bool `yaml:"enabled"`
|
||||||
Type string `yaml:"type"` // "shared" | "dedicated"
|
Type string `yaml:"type"` // "shared" | "dedicated"
|
||||||
Host string `yaml:"host"`
|
Host string `yaml:"host"`
|
||||||
User string `yaml:"user"`
|
User string `yaml:"user"`
|
||||||
Port int `yaml:"port"` // 23
|
Port int `yaml:"port"` // 23
|
||||||
|
|||||||
@@ -44,13 +44,13 @@ type Handler interface {
|
|||||||
|
|
||||||
// StatusInfo is returned by the API for UI display.
|
// StatusInfo is returned by the API for UI display.
|
||||||
type StatusInfo struct {
|
type StatusInfo struct {
|
||||||
Key string `json:"key"` // "onlyoffice:filebrowser"
|
Key string `json:"key"` // "onlyoffice:filebrowser"
|
||||||
Provider string `json:"provider"`
|
Provider string `json:"provider"`
|
||||||
Target string `json:"target"`
|
Target string `json:"target"`
|
||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Status string `json:"status"` // "active", "error", "disabled", "provider_stopped", "target_unavailable"
|
Status string `json:"status"` // "active", "error", "disabled", "provider_stopped", "target_unavailable"
|
||||||
LastError string `json:"last_error,omitempty"`
|
LastError string `json:"last_error,omitempty"`
|
||||||
TargetDeployed bool `json:"target_deployed"`
|
TargetDeployed bool `json:"target_deployed"`
|
||||||
TargetRunning bool `json:"target_running"`
|
TargetRunning bool `json:"target_running"`
|
||||||
|
|||||||
@@ -101,7 +101,12 @@ func (m *Manager) OnStackStart(_ context.Context, stackName string) {
|
|||||||
provStack, pOk := m.stacks.GetStack(provider)
|
provStack, pOk := m.stacks.GetStack(provider)
|
||||||
if !pOk || !provStack.Deployed || !isStackUp(provStack.State) {
|
if !pOk || !provStack.Deployed || !isStackUp(provStack.State) {
|
||||||
if m.isDebug() {
|
if m.isDebug() {
|
||||||
m.logger.Printf("[DEBUG] [integrations] OnStackStart: skipping %s — provider %s not up (found=%v deployed=%v state=%v)", key, provider, pOk, pOk && provStack.Deployed, func() stacks.ContainerState { if pOk { return provStack.State }; return "" }())
|
m.logger.Printf("[DEBUG] [integrations] OnStackStart: skipping %s — provider %s not up (found=%v deployed=%v state=%v)", key, provider, pOk, pOk && provStack.Deployed, func() stacks.ContainerState {
|
||||||
|
if pOk {
|
||||||
|
return provStack.State
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}())
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -109,7 +114,12 @@ func (m *Manager) OnStackStart(_ context.Context, stackName string) {
|
|||||||
tgtStack, tOk := m.stacks.GetStack(target)
|
tgtStack, tOk := m.stacks.GetStack(target)
|
||||||
if !tOk || !tgtStack.Deployed || !isStackUp(tgtStack.State) {
|
if !tOk || !tgtStack.Deployed || !isStackUp(tgtStack.State) {
|
||||||
if m.isDebug() {
|
if m.isDebug() {
|
||||||
m.logger.Printf("[DEBUG] [integrations] OnStackStart: skipping %s — target %s not up (found=%v deployed=%v state=%v)", key, target, tOk, tOk && tgtStack.Deployed, func() stacks.ContainerState { if tOk { return tgtStack.State }; return "" }())
|
m.logger.Printf("[DEBUG] [integrations] OnStackStart: skipping %s — target %s not up (found=%v deployed=%v state=%v)", key, target, tOk, tOk && tgtStack.Deployed, func() stacks.ContainerState {
|
||||||
|
if tOk {
|
||||||
|
return tgtStack.State
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}())
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,7 +100,12 @@ func (m *Manager) Toggle(ctx context.Context, provider, target string, enable bo
|
|||||||
// Validate: provider must be deployed and running
|
// Validate: provider must be deployed and running
|
||||||
provStack, pOk := m.stacks.GetStack(provider)
|
provStack, pOk := m.stacks.GetStack(provider)
|
||||||
if m.isDebug() {
|
if m.isDebug() {
|
||||||
m.logger.Printf("[DEBUG] [integrations] Toggle: provider %s found=%v deployed=%v state=%v", provider, pOk, pOk && provStack.Deployed, func() stacks.ContainerState { if pOk { return provStack.State }; return "" }())
|
m.logger.Printf("[DEBUG] [integrations] Toggle: provider %s found=%v deployed=%v state=%v", provider, pOk, pOk && provStack.Deployed, func() stacks.ContainerState {
|
||||||
|
if pOk {
|
||||||
|
return provStack.State
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}())
|
||||||
}
|
}
|
||||||
if !pOk || !provStack.Deployed {
|
if !pOk || !provStack.Deployed {
|
||||||
return state, fmt.Errorf("a szolgáltató alkalmazás (%s) nincs telepítve", provider)
|
return state, fmt.Errorf("a szolgáltató alkalmazás (%s) nincs telepítve", provider)
|
||||||
@@ -113,7 +118,12 @@ func (m *Manager) Toggle(ctx context.Context, provider, target string, enable bo
|
|||||||
if target != "filebrowser" {
|
if target != "filebrowser" {
|
||||||
tgtStack, tOk := m.stacks.GetStack(target)
|
tgtStack, tOk := m.stacks.GetStack(target)
|
||||||
if m.isDebug() {
|
if m.isDebug() {
|
||||||
m.logger.Printf("[DEBUG] [integrations] Toggle: target %s found=%v deployed=%v state=%v", target, tOk, tOk && tgtStack.Deployed, func() stacks.ContainerState { if tOk { return tgtStack.State }; return "" }())
|
m.logger.Printf("[DEBUG] [integrations] Toggle: target %s found=%v deployed=%v state=%v", target, tOk, tOk && tgtStack.Deployed, func() stacks.ContainerState {
|
||||||
|
if tOk {
|
||||||
|
return tgtStack.State
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}())
|
||||||
}
|
}
|
||||||
if !tOk || !tgtStack.Deployed {
|
if !tOk || !tgtStack.Deployed {
|
||||||
return state, fmt.Errorf("a célalkalmazás (%s) nincs telepítve", target)
|
return state, fmt.Errorf("a célalkalmazás (%s) nincs telepítve", target)
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ func (h *OnlyOfficeNextcloudHandler) Apply(ac *ApplyContext) error {
|
|||||||
|
|
||||||
// Install and configure OnlyOffice app in Nextcloud
|
// Install and configure OnlyOffice app in Nextcloud
|
||||||
commands := []struct {
|
commands := []struct {
|
||||||
args []string
|
args []string
|
||||||
tolerate string // substring in output to tolerate as success
|
tolerate string // substring in output to tolerate as success
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -321,4 +321,3 @@ func buildStacksReport(stackMgr *stacks.Manager) StacksReport {
|
|||||||
|
|
||||||
return sr
|
return sr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import "log"
|
|||||||
// - Applied reads the persisted last-applied config_version (0 = none recorded yet).
|
// - Applied reads the persisted last-applied config_version (0 = none recorded yet).
|
||||||
// - Record persists a newly-applied config_version.
|
// - Record persists a newly-applied config_version.
|
||||||
// - Refresh re-pulls controller.yaml from the hub and writes it (re-merging local_api). It must
|
// - Refresh re-pulls controller.yaml from the hub and writes it (re-merging local_api). It must
|
||||||
// NOT touch settings.json. A hub-unreachable / write failure returns an error.
|
// NOT touch settings.json. A hub-unreachable / write failure returns an error.
|
||||||
// - Restart triggers the graceful self-restart (process exit → Docker restart → fresh config).
|
// - Restart triggers the graceful self-restart (process exit → Docker restart → fresh config).
|
||||||
type ConfigRefresher struct {
|
type ConfigRefresher struct {
|
||||||
Applied func() int
|
Applied func() int
|
||||||
|
|||||||
@@ -86,9 +86,9 @@ func TestEscrowConfirm_StaleBlobStaysPending(t *testing.T) {
|
|||||||
// Scenario C — no status / hash-less blob / no local password file → stay pending SILENTLY.
|
// Scenario C — no status / hash-less blob / no local password file → stay pending SILENTLY.
|
||||||
func TestEscrowConfirm_SilentPendingCases(t *testing.T) {
|
func TestEscrowConfirm_SilentPendingCases(t *testing.T) {
|
||||||
h := newConfirmer(t)
|
h := newConfirmer(t)
|
||||||
h.c.Reconcile(nil) // no escrow row on the hub
|
h.c.Reconcile(nil) // no escrow row on the hub
|
||||||
h.c.Reconcile(&EscrowStatus{IdentityBlobPresent: true}) // hash NULL (legacy blob)
|
h.c.Reconcile(&EscrowStatus{IdentityBlobPresent: true}) // hash NULL (legacy blob)
|
||||||
h.c.Reconcile(&EscrowStatus{IdentityBlobPresent: false, ResticPwSHA256: hubHash}) // hash without identity blob — fail-closed
|
h.c.Reconcile(&EscrowStatus{IdentityBlobPresent: false, ResticPwSHA256: hubHash}) // hash without identity blob — fail-closed
|
||||||
h.localOK = false
|
h.localOK = false
|
||||||
h.c.Reconcile(matchStatus(hubHash)) // no local password file
|
h.c.Reconcile(matchStatus(hubHash)) // no local password file
|
||||||
if h.flips != 0 || h.logbuf.Len() != 0 {
|
if h.flips != 0 || h.logbuf.Len() != 0 {
|
||||||
|
|||||||
@@ -9,22 +9,22 @@ import (
|
|||||||
|
|
||||||
// Report is the JSON payload pushed to the central hub.
|
// Report is the JSON payload pushed to the central hub.
|
||||||
type Report struct {
|
type Report struct {
|
||||||
Version int `json:"version"`
|
Version int `json:"version"`
|
||||||
CustomerID string `json:"customer_id"`
|
CustomerID string `json:"customer_id"`
|
||||||
CustomerName string `json:"customer_name"`
|
CustomerName string `json:"customer_name"`
|
||||||
ControllerVersion string `json:"controller_version"`
|
ControllerVersion string `json:"controller_version"`
|
||||||
ControllerURL string `json:"controller_url,omitempty"`
|
ControllerURL string `json:"controller_url,omitempty"`
|
||||||
ConfigHash string `json:"config_hash,omitempty"`
|
ConfigHash string `json:"config_hash,omitempty"`
|
||||||
Timestamp time.Time `json:"timestamp"`
|
Timestamp time.Time `json:"timestamp"`
|
||||||
ReportingDisabled bool `json:"reporting_disabled,omitempty"`
|
ReportingDisabled bool `json:"reporting_disabled,omitempty"`
|
||||||
System SystemReport `json:"system"`
|
System SystemReport `json:"system"`
|
||||||
Storage []StorageReport `json:"storage"`
|
Storage []StorageReport `json:"storage"`
|
||||||
Containers ContainerReport `json:"containers"`
|
Containers ContainerReport `json:"containers"`
|
||||||
Backup BackupReport `json:"backup"`
|
Backup BackupReport `json:"backup"`
|
||||||
Health HealthReport `json:"health"`
|
Health HealthReport `json:"health"`
|
||||||
Stacks StacksReport `json:"stacks"`
|
Stacks StacksReport `json:"stacks"`
|
||||||
AppTelemetry []AppTelemetry `json:"app_telemetry,omitempty"`
|
AppTelemetry []AppTelemetry `json:"app_telemetry,omitempty"`
|
||||||
GeoRestriction *GeoRestrictionReport `json:"geo_restriction,omitempty"`
|
GeoRestriction *GeoRestrictionReport `json:"geo_restriction,omitempty"`
|
||||||
|
|
||||||
// DR recipe — the controller (customer + apps) half of the secret-free reconstruction recipe
|
// DR recipe — the controller (customer + apps) half of the secret-free reconstruction recipe
|
||||||
// (SPIKE-dr-recipe-2026-06-16). The hub assembles it with the agent's storage/guest/PBS half.
|
// (SPIKE-dr-recipe-2026-06-16). The hub assembles it with the agent's storage/guest/PBS half.
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import (
|
|||||||
// CheckResult represents the outcome of a single self-test check.
|
// CheckResult represents the outcome of a single self-test check.
|
||||||
type CheckResult struct {
|
type CheckResult struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Status string `json:"status"` // "pass", "warn", "fail"
|
Status string `json:"status"` // "pass", "warn", "fail"
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,13 +12,13 @@ const stateFileName = "update-state.json"
|
|||||||
|
|
||||||
// UpdateState tracks the last update attempt. Persisted to disk as audit log.
|
// UpdateState tracks the last update attempt. Persisted to disk as audit log.
|
||||||
type UpdateState struct {
|
type UpdateState struct {
|
||||||
Status string `json:"status"` // "pending", "success", "failed"
|
Status string `json:"status"` // "pending", "success", "failed"
|
||||||
PreviousVersion string `json:"previous_version"`
|
PreviousVersion string `json:"previous_version"`
|
||||||
PreviousImage string `json:"previous_image"`
|
PreviousImage string `json:"previous_image"`
|
||||||
TargetVersion string `json:"target_version"`
|
TargetVersion string `json:"target_version"`
|
||||||
TargetImage string `json:"target_image"`
|
TargetImage string `json:"target_image"`
|
||||||
InitiatedAt string `json:"initiated_at"` // RFC3339
|
InitiatedAt string `json:"initiated_at"` // RFC3339
|
||||||
InitiatedBy string `json:"initiated_by"` // "manual" or "auto"
|
InitiatedBy string `json:"initiated_by"` // "manual" or "auto"
|
||||||
CompletedAt string `json:"completed_at,omitempty"`
|
CompletedAt string `json:"completed_at,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -454,9 +454,9 @@ type DryRunResult struct {
|
|||||||
CurrentVersion string `json:"current_version"`
|
CurrentVersion string `json:"current_version"`
|
||||||
LatestVersion string `json:"latest_version"`
|
LatestVersion string `json:"latest_version"`
|
||||||
UpdateAvailable bool `json:"update_available"`
|
UpdateAvailable bool `json:"update_available"`
|
||||||
AgentReachable bool `json:"agent_reachable"` // the host agent (which performs the swap) is wired
|
AgentReachable bool `json:"agent_reachable"` // the host agent (which performs the swap) is wired
|
||||||
PullCapable bool `json:"pull_capable"` // in-guest pull path available (full creds OR anonymous; false = half-configured creds)
|
PullCapable bool `json:"pull_capable"` // in-guest pull path available (full creds OR anonymous; false = half-configured creds)
|
||||||
TargetImage string `json:"target_image"` // what we would pull + swap to
|
TargetImage string `json:"target_image"` // what we would pull + swap to
|
||||||
BackupRunning bool `json:"backup_running"`
|
BackupRunning bool `json:"backup_running"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -842,4 +842,3 @@ var runCommandStdin = func(stdin, name string, args ...string) (string, error) {
|
|||||||
err := cmd.Run()
|
err := cmd.Run()
|
||||||
return out.String(), err
|
return out.String(), err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,11 +25,11 @@ type SMBSettings struct {
|
|||||||
// SMBShare is one exported folder. Path is an absolute host path under a registered storage root
|
// SMBShare is one exported folder. Path is an absolute host path under a registered storage root
|
||||||
// (validated by the web layer against the storage registry + deny-list before it ever reaches here).
|
// (validated by the web layer against the storage registry + deny-list before it ever reaches here).
|
||||||
type SMBShare struct {
|
type SMBShare struct {
|
||||||
Name string `json:"name"` // share name (NetBIOS-safe, ≤15); the [section] in smb.conf and the \\SERVER\<name> path
|
Name string `json:"name"` // share name (NetBIOS-safe, ≤15); the [section] in smb.conf and the \\SERVER\<name> path
|
||||||
Path string `json:"path"` // absolute host path
|
Path string `json:"path"` // absolute host path
|
||||||
ReadOnly bool `json:"read_only,omitempty"` // smb.conf `read only = yes` + a :ro compose bind
|
ReadOnly bool `json:"read_only,omitempty"` // smb.conf `read only = yes` + a :ro compose bind
|
||||||
Offsite bool `json:"offsite"` // [R4] true (default) → backup class mandatory; false → optional (tier-2 only)
|
Offsite bool `json:"offsite"` // [R4] true (default) → backup class mandatory; false → optional (tier-2 only)
|
||||||
CreatedAt string `json:"created_at"` // RFC3339
|
CreatedAt string `json:"created_at"` // RFC3339
|
||||||
}
|
}
|
||||||
|
|
||||||
// nbNameRe matches a NetBIOS-safe name: 1–15 chars, letters/digits/hyphen/underscore, not starting
|
// nbNameRe matches a NetBIOS-safe name: 1–15 chars, letters/digits/hyphen/underscore, not starting
|
||||||
@@ -63,7 +63,7 @@ func ValidateSMBShareName(name string) error {
|
|||||||
if len(name) > 15 {
|
if len(name) > 15 {
|
||||||
return fmt.Errorf("a megosztás neve legfeljebb 15 karakter lehet")
|
return fmt.Errorf("a megosztás neve legfeljebb 15 karakter lehet")
|
||||||
}
|
}
|
||||||
if strings.ContainsAny(name, `/\.` ) {
|
if strings.ContainsAny(name, `/\.`) {
|
||||||
return fmt.Errorf("a megosztás neve nem tartalmazhat perjelet vagy pontot")
|
return fmt.Errorf("a megosztás neve nem tartalmazhat perjelet vagy pontot")
|
||||||
}
|
}
|
||||||
// RESERVED NAMESPACE (R-7b). The backup engines key the shares source by the pseudo-stack „_shares"
|
// RESERVED NAMESPACE (R-7b). The backup engines key the shares source by the pseudo-stack „_shares"
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ type SetupState struct {
|
|||||||
|
|
||||||
// SelectedBackup tracks which backup the user chose.
|
// SelectedBackup tracks which backup the user chose.
|
||||||
type SelectedBackup struct {
|
type SelectedBackup struct {
|
||||||
Source string `json:"source"` // "local" or "hub"
|
Source string `json:"source"` // "local" or "hub"
|
||||||
DrivePath string `json:"drive_path"` // for local
|
DrivePath string `json:"drive_path"` // for local
|
||||||
CustomerID string `json:"customer_id"`
|
CustomerID string `json:"customer_id"`
|
||||||
Timestamp string `json:"timestamp"`
|
Timestamp string `json:"timestamp"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,9 +41,9 @@ func TestCatalogTemplateSlugs_IgnoresLifecycle(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
mk("bookstack", "") // available
|
mk("bookstack", "") // available
|
||||||
mk("plant-it", "abandoned") // withdrawn, but STILL IN THE CATALOG TREE
|
mk("plant-it", "abandoned") // withdrawn, but STILL IN THE CATALOG TREE
|
||||||
mk("someapp", "hidden") // withdrawn, ditto
|
mk("someapp", "hidden") // withdrawn, ditto
|
||||||
|
|
||||||
m := &Manager{
|
m := &Manager{
|
||||||
cfg: &config.Config{Paths: config.PathsConfig{DataDir: dataDir}},
|
cfg: &config.Config{Paths: config.PathsConfig{DataDir: dataDir}},
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import (
|
|||||||
// Creating ${USERDATA_PATH}/... then would land app data on the rootfs, shadowed when the drive returns.
|
// Creating ${USERDATA_PATH}/... then would land app data on the rootfs, shadowed when the drive returns.
|
||||||
// Companion red-proof: removing the gate in ensureUserdataMounts makes the dir get created → this fails.
|
// Companion red-proof: removing the gate in ensureUserdataMounts makes the dir get created → this fails.
|
||||||
func TestEnsureUserdataMounts_SkipsAbsentExternalDrive(t *testing.T) {
|
func TestEnsureUserdataMounts_SkipsAbsentExternalDrive(t *testing.T) {
|
||||||
m := newMigManager(t, "") // sysDataPath = /mnt/sys_drive
|
m := newMigManager(t, "") // sysDataPath = /mnt/sys_drive
|
||||||
m.isMountPoint = func(string) bool { return false } // external drive is NOT mounted
|
m.isMountPoint = func(string) bool { return false } // external drive is NOT mounted
|
||||||
stackDir := t.TempDir()
|
stackDir := t.TempDir()
|
||||||
if err := os.WriteFile(filepath.Join(stackDir, "docker-compose.yml"), []byte(beltCompose), 0o644); err != nil {
|
if err := os.WriteFile(filepath.Join(stackDir, "docker-compose.yml"), []byte(beltCompose), 0o644); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -51,10 +51,10 @@ type SyncStatus struct {
|
|||||||
|
|
||||||
// SyncResult holds the result of a single sync operation.
|
// SyncResult holds the result of a single sync operation.
|
||||||
type SyncResult struct {
|
type SyncResult struct {
|
||||||
OK bool `json:"ok"`
|
OK bool `json:"ok"`
|
||||||
NewApps []string `json:"new_apps,omitempty"`
|
NewApps []string `json:"new_apps,omitempty"`
|
||||||
Updated []string `json:"updated,omitempty"`
|
Updated []string `json:"updated,omitempty"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Syncer. rescanFn is called after a successful sync to trigger ScanStacks().
|
// New creates a new Syncer. rescanFn is called after a successful sync to trigger ScanStacks().
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ func debugf(format string, args ...any) {
|
|||||||
|
|
||||||
// SystemInfo holds system resource usage information.
|
// SystemInfo holds system resource usage information.
|
||||||
type SystemInfo struct {
|
type SystemInfo struct {
|
||||||
TotalMemMB uint64 `json:"total_mem_mb"`
|
TotalMemMB uint64 `json:"total_mem_mb"`
|
||||||
UsedMemMB uint64 `json:"used_mem_mb"`
|
UsedMemMB uint64 `json:"used_mem_mb"`
|
||||||
AvailMemMB uint64 `json:"avail_mem_mb"`
|
AvailMemMB uint64 `json:"avail_mem_mb"`
|
||||||
MemPercent float64 `json:"mem_percent"`
|
MemPercent float64 `json:"mem_percent"`
|
||||||
|
|
||||||
DiskTotalGB float64 `json:"disk_total_gb"`
|
DiskTotalGB float64 `json:"disk_total_gb"`
|
||||||
DiskUsedGB float64 `json:"disk_used_gb"`
|
DiskUsedGB float64 `json:"disk_used_gb"`
|
||||||
|
|||||||
@@ -276,7 +276,7 @@ func diskModel(device string) string {
|
|||||||
type ProbeStatus int
|
type ProbeStatus int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ProbeConnected ProbeStatus = iota
|
ProbeConnected ProbeStatus = iota
|
||||||
ProbeDisconnected
|
ProbeDisconnected
|
||||||
ProbeTimeout
|
ProbeTimeout
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ func CheckBackupDestination(path string) DestinationHealth {
|
|||||||
type ProbeStatus int
|
type ProbeStatus int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ProbeConnected ProbeStatus = iota
|
ProbeConnected ProbeStatus = iota
|
||||||
ProbeDisconnected
|
ProbeDisconnected
|
||||||
ProbeTimeout
|
ProbeTimeout
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -59,8 +59,8 @@ func TestAppExportDomainUsesCustomerDomainNotCSRFToken(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The empty-subdomain branch still yields '' — an app without a subdomain must not get a link to
|
// The empty-subdomain branch still yields the empty string — an app without a subdomain must not get
|
||||||
// a bare domain (the truthiness guard is what produces that, and the fix must not disturb it).
|
// a link to a bare domain (the truthiness guard is what produces that, and the fix must not disturb it).
|
||||||
func TestAppExportDomainEmptyWithoutSubdomain(t *testing.T) {
|
func TestAppExportDomainEmptyWithoutSubdomain(t *testing.T) {
|
||||||
line := exportScriptLine(t, renderExport(t, ""))
|
line := exportScriptLine(t, renderExport(t, ""))
|
||||||
|
|
||||||
|
|||||||
@@ -236,11 +236,11 @@ func TestBackupsTemplate_Tier3Live(t *testing.T) {
|
|||||||
html := renderBackupPage(t, "backups_apps", data)
|
html := renderBackupPage(t, "backups_apps", data)
|
||||||
|
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
"Sikeres", // active badge (LastStatus ok)
|
"Sikeres", // active badge (LastStatus ok)
|
||||||
"restic → u629488-sub1.your-storagebox.de", // the real off-box host
|
"restic → u629488-sub1.your-storagebox.de", // the real off-box host
|
||||||
"Utolsó:", // a relative last-run time is shown
|
"Utolsó:", // a relative last-run time is shown
|
||||||
"Kikapcsolva", // radarr off row
|
"Kikapcsolva", // radarr off row
|
||||||
"Bekapcsolás", // radarr enable link
|
"Bekapcsolás", // radarr enable link
|
||||||
`href="/backups/remote#offbox-section"`, // the cross-page anchor jump target (IA split)
|
`href="/backups/remote#offbox-section"`, // the cross-page anchor jump target (IA split)
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(html, want) {
|
if !strings.Contains(html, want) {
|
||||||
@@ -257,13 +257,13 @@ func TestBackupsTemplate_Tier3Live(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, banned := range []string{
|
for _, banned := range []string{
|
||||||
"hamarosan", // the dead placeholder copy
|
"hamarosan", // the dead placeholder copy
|
||||||
"Tier2DriveGroups", // dead template field
|
"Tier2DriveGroups", // dead template field
|
||||||
"ResticPassword", // dead template field
|
"ResticPassword", // dead template field
|
||||||
"restic-pw", // dead element id
|
"restic-pw", // dead element id
|
||||||
"toggleTier", // dead JS fn
|
"toggleTier", // dead JS fn
|
||||||
"details-tier", // dead Részletek markup
|
"details-tier", // dead Részletek markup
|
||||||
"Részletek", // the removed card title
|
"Részletek", // the removed card title
|
||||||
"2026-07-11T02:00:00Z", // raw RFC3339 must never leak (timeAgoStr wraps it)
|
"2026-07-11T02:00:00Z", // raw RFC3339 must never leak (timeAgoStr wraps it)
|
||||||
} {
|
} {
|
||||||
if strings.Contains(html, banned) {
|
if strings.Contains(html, banned) {
|
||||||
|
|||||||
@@ -43,18 +43,18 @@ func TestBackupsSplit_SectionsOnExactlyOnePage(t *testing.T) {
|
|||||||
|
|
||||||
// marker → the ONE page it belongs to
|
// marker → the ONE page it belongs to
|
||||||
markers := map[string]string{
|
markers := map[string]string{
|
||||||
"Tárhely áttekintés": "backups", // Section 0
|
"Tárhely áttekintés": "backups", // Section 0
|
||||||
"Rendszermentés (teljes mentés)": "backups", // whole-guest
|
"Rendszermentés (teljes mentés)": "backups", // whole-guest
|
||||||
">Adatmentés<": "backups", // stat cards (neutral branch)
|
">Adatmentés<": "backups", // stat cards (neutral branch)
|
||||||
`id="offbox-section"`: "backups_remote", // the offbox anchor target
|
`id="offbox-section"`: "backups_remote", // the offbox anchor target
|
||||||
"Távoli mentési cél beállítása": "backups_remote", // manual-target form
|
"Távoli mentési cél beállítása": "backups_remote", // manual-target form
|
||||||
"Mely alkalmazások mentődnek": "backups_remote", // toggle list
|
"Mely alkalmazások mentődnek": "backups_remote", // toggle list
|
||||||
"<h3>Ütemezés</h3>": "backups_apps", // schedule
|
"<h3>Ütemezés</h3>": "backups_apps", // schedule
|
||||||
"<h3>Adatbázisok</h3>": "backups_apps", // databases
|
"<h3>Adatbázisok</h3>": "backups_apps", // databases
|
||||||
"Alkalmazások mentési állapota": "backups_apps", // per-app rows
|
"Alkalmazások mentési állapota": "backups_apps", // per-app rows
|
||||||
`id="restore-app"`: "backups_restore", // restore panel
|
`id="restore-app"`: "backups_restore", // restore panel
|
||||||
"Visszaállítás a távoli tárolóból": "backups_restore", // the offsite restore list
|
"Visszaállítás a távoli tárolóból": "backups_restore", // the offsite restore list
|
||||||
`href="/backups/restore/app?name=`: "backups_restore", // R-48: the ONE entry per app
|
`href="/backups/restore/app?name=`: "backups_restore", // R-48: the ONE entry per app
|
||||||
}
|
}
|
||||||
for marker, home := range markers {
|
for marker, home := range markers {
|
||||||
for page, html := range pages {
|
for page, html := range pages {
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ func (s *Server) escrowPreflightAPIHandler(w http.ResponseWriter, r *http.Reques
|
|||||||
agentOK := escrowAgentSupported(agent.AgentVersion())
|
agentOK := escrowAgentSupported(agent.AgentVersion())
|
||||||
escrowJSON(w, http.StatusOK, map[string]any{
|
escrowJSON(w, http.StatusOK, map[string]any{
|
||||||
"ok": pf.OK && agentOK, "items": pf.Items,
|
"ok": pf.OK && agentOK, "items": pf.Items,
|
||||||
"agent_supported": agentOK,
|
"agent_supported": agentOK,
|
||||||
"offbox_configured": s.backupMgr != nil && s.backupMgr.OffboxConfigured(),
|
"offbox_configured": s.backupMgr != nil && s.backupMgr.OffboxConfigured(),
|
||||||
"escrow_state": escrowState,
|
"escrow_state": escrowState,
|
||||||
"stale": s.escrowStale(),
|
"stale": s.escrowStale(),
|
||||||
|
|||||||
@@ -50,13 +50,13 @@ func TestFabDownload_TraversalGuardAndCleanup(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
refused := []string{
|
refused := []string{
|
||||||
"..%2Fdecoy.fab", // ../decoy.fab
|
"..%2Fdecoy.fab", // ../decoy.fab
|
||||||
"..%5Cdecoy.fab", // ..\decoy.fab
|
"..%5Cdecoy.fab", // ..\decoy.fab
|
||||||
"%2Fetc%2Fpasswd", // absolute path
|
"%2Fetc%2Fpasswd", // absolute path
|
||||||
"sub%2Fx.fab", // separator
|
"sub%2Fx.fab", // separator
|
||||||
"..", // bare traversal
|
"..", // bare traversal
|
||||||
".hidden.fab", // not the exporter's naming (leading dot)
|
".hidden.fab", // not the exporter's naming (leading dot)
|
||||||
"x.txt", // not a .fab
|
"x.txt", // not a .fab
|
||||||
}
|
}
|
||||||
for _, f := range refused {
|
for _, f := range refused {
|
||||||
if rr := fetchDownload(s, f); rr.Code != http.StatusBadRequest {
|
if rr := fetchDownload(s, f); rr.Code != http.StatusBadRequest {
|
||||||
|
|||||||
@@ -29,23 +29,25 @@ func (p *fabWebProvider) GetStackDir(string) (string, bool) { return p.stackDir,
|
|||||||
func (p *fabWebProvider) GetStackComposePath(string) (string, bool) {
|
func (p *fabWebProvider) GetStackComposePath(string) (string, bool) {
|
||||||
return filepath.Join(p.stackDir, "docker-compose.yml"), true
|
return filepath.Join(p.stackDir, "docker-compose.yml"), true
|
||||||
}
|
}
|
||||||
func (p *fabWebProvider) GetStackHDDMounts(string) []string { return []string{appbackup.UserdataDir(p.hddPath)} }
|
func (p *fabWebProvider) GetStackHDDMounts(string) []string {
|
||||||
func (p *fabWebProvider) GetStackHDDPath(string) string { return p.hddPath }
|
return []string{appbackup.UserdataDir(p.hddPath)}
|
||||||
|
}
|
||||||
|
func (p *fabWebProvider) GetStackHDDPath(string) string { return p.hddPath }
|
||||||
func (p *fabWebProvider) GetStackClassifiedBinds(string) ([]appbackup.ClassifiedBind, bool) {
|
func (p *fabWebProvider) GetStackClassifiedBinds(string) ([]appbackup.ClassifiedBind, bool) {
|
||||||
return p.binds, true
|
return p.binds, true
|
||||||
}
|
}
|
||||||
func (p *fabWebProvider) IsStackRunning(string) bool { return false }
|
func (p *fabWebProvider) IsStackRunning(string) bool { return false }
|
||||||
func (p *fabWebProvider) StopStack(string) error { return nil }
|
func (p *fabWebProvider) StopStack(string) error { return nil }
|
||||||
func (p *fabWebProvider) StartStack(string) error { return nil }
|
func (p *fabWebProvider) StartStack(string) error { return nil }
|
||||||
func (p *fabWebProvider) GetStackDisplayName(n string) string { return n }
|
func (p *fabWebProvider) GetStackDisplayName(n string) string { return n }
|
||||||
func (p *fabWebProvider) GetStackNeedsHDD(string) bool { return true }
|
func (p *fabWebProvider) GetStackNeedsHDD(string) bool { return true }
|
||||||
func (p *fabWebProvider) GetDockerVolumes(string) []string { return nil }
|
func (p *fabWebProvider) GetDockerVolumes(string) []string { return nil }
|
||||||
func (p *fabWebProvider) IsStackDeployed(string) bool { return true }
|
func (p *fabWebProvider) IsStackDeployed(string) bool { return true }
|
||||||
func (p *fabWebProvider) GetDecryptedEnv(string) map[string]string { return nil }
|
func (p *fabWebProvider) GetDecryptedEnv(string) map[string]string { return nil }
|
||||||
func (p *fabWebProvider) GetStacksBaseDir() string { return p.stacksDir }
|
func (p *fabWebProvider) GetStacksBaseDir() string { return p.stacksDir }
|
||||||
func (p *fabWebProvider) SaveEncryptedAppConfig(string, map[string]string) error { return nil }
|
func (p *fabWebProvider) SaveEncryptedAppConfig(string, map[string]string) error { return nil }
|
||||||
func (p *fabWebProvider) RefreshStacks() error { return nil }
|
func (p *fabWebProvider) RefreshStacks() error { return nil }
|
||||||
func (p *fabWebProvider) RemoveStackVolumes(string) error { return nil }
|
func (p *fabWebProvider) RemoveStackVolumes(string) error { return nil }
|
||||||
|
|
||||||
// waitExportDone polls the exporter until the active job finishes.
|
// waitExportDone polls the exporter until the active job finishes.
|
||||||
func waitExportDone(t *testing.T, e *appexport.Exporter) {
|
func waitExportDone(t *testing.T, e *appexport.Exporter) {
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ func TestSkipFileBrowserPath(t *testing.T) {
|
|||||||
path string
|
path string
|
||||||
want bool
|
want bool
|
||||||
}{
|
}{
|
||||||
{StableParentDir + "/flash", false}, // external + mounted → keep
|
{StableParentDir + "/flash", false}, // external + mounted → keep
|
||||||
{StableParentDir + "/usb", true}, // external + NOT mounted → skip
|
{StableParentDir + "/usb", true}, // external + NOT mounted → skip
|
||||||
{"/mnt/sys_drive/felhom-data", false}, // system path (not under StableParentDir) → never skip
|
{"/mnt/sys_drive/felhom-data", false}, // system path (not under StableParentDir) → never skip
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
@@ -34,9 +34,9 @@ func TestFbNeedsRecreate(t *testing.T) {
|
|||||||
compose := []byte("services:\n filebrowser:\n image: x\n")
|
compose := []byte("services:\n filebrowser:\n image: x\n")
|
||||||
|
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
oldCfg, newCfg, oldCmp, newCmp []byte
|
oldCfg, newCfg, oldCmp, newCmp []byte
|
||||||
want bool
|
want bool
|
||||||
}{
|
}{
|
||||||
{"unchanged → no recreate", cfg, cfg, compose, compose, false},
|
{"unchanged → no recreate", cfg, cfg, compose, compose, false},
|
||||||
{"config differs → recreate", cfg, []byte("sources:\n - /srv/hdd\n"), compose, compose, true},
|
{"config differs → recreate", cfg, []byte("sources:\n - /srv/hdd\n"), compose, compose, true},
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ func (s *Server) templateFuncMap() template.FuncMap {
|
|||||||
},
|
},
|
||||||
// isOperational returns true for any state where the stack has containers
|
// isOperational returns true for any state where the stack has containers
|
||||||
// and is not stopped/exited — used by templates for showing action buttons
|
// and is not stopped/exited — used by templates for showing action buttons
|
||||||
"isOperational": isOperationalState,
|
"isOperational": isOperationalState,
|
||||||
"routeUnpublished": routeUnpublished,
|
"routeUnpublished": routeUnpublished,
|
||||||
"logoURL": func(slug string) string {
|
"logoURL": func(slug string) string {
|
||||||
return s.cfg.AppLogoURL(slug)
|
return s.cfg.AppLogoURL(slug)
|
||||||
|
|||||||
@@ -344,15 +344,15 @@ func (s *Server) driveGateLoop() {
|
|||||||
// retried → the app is stuck Exited forever even after the bind lands. The agent reports a `guest_boot_id`
|
// retried → the app is stuck Exited forever even after the bind lands. The agent reports a `guest_boot_id`
|
||||||
// that changes on every guest boot but is stable across a controller-only restart; the controller
|
// that changes on every guest boot but is stable across a controller-only restart; the controller
|
||||||
// persists the last-seen value. When it changes (the controller restarts WITH the guest), this:
|
// persists the last-seen value. When it changes (the controller restarts WITH the guest), this:
|
||||||
// 1. gathers the deployed drive-backed apps' stable drive paths,
|
// 1. gathers the deployed drive-backed apps' stable drive paths,
|
||||||
// 2. GATES on the REAL live in-guest bind — `pollLiveBinds`/`driveBindLive` wait (bounded) until each
|
// 2. GATES on the REAL live in-guest bind — `pollLiveBinds`/`driveBindLive` wait (bounded) until each
|
||||||
// drive's stable path is an actual mountpoint in the controller's own /mnt (rslave) view, which is
|
// drive's stable path is an actual mountpoint in the controller's own /mnt (rslave) view, which is
|
||||||
// exactly when docker can recreate the app. (The old code sampled the agent's BoundUnderParent
|
// exactly when docker can recreate the app. (The old code sampled the agent's BoundUnderParent
|
||||||
// ONCE during fast startup, raced the ~18s rebind, recreated nothing, and persisted the boot-id —
|
// ONCE during fast startup, raced the ~18s rebind, recreated nothing, and persisted the boot-id —
|
||||||
// burning its one-shot. That is the bug this fixes.)
|
// burning its one-shot. That is the bug this fixes.)
|
||||||
// 3. recreates every deployed drive-backed app whose bind is now live (`shouldRecreateOnBoot` is
|
// 3. recreates every deployed drive-backed app whose bind is now live (`shouldRecreateOnBoot` is
|
||||||
// state-independent, so a stuck-Exited create-time-failure app is included), then persists the
|
// state-independent, so a stuck-Exited create-time-failure app is included), then persists the
|
||||||
// new boot-id.
|
// new boot-id.
|
||||||
//
|
//
|
||||||
// Apps on a drive that never goes live within the window are left to the normal gate (stop→return→
|
// Apps on a drive that never goes live within the window are left to the normal gate (stop→return→
|
||||||
// restart). Single-flight (runs once, before the periodic gate, in driveGateLoop). Best-effort.
|
// restart). Single-flight (runs once, before the periodic gate, in driveGateLoop). Best-effort.
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ func TestRecreateDriveBackedApps_SyncsFileBrowserAfterRecreate(t *testing.T) {
|
|||||||
flash := "/mnt/felhom-drives/felhom-flash"
|
flash := "/mnt/felhom-drives/felhom-flash"
|
||||||
present := map[string]bool{flash: true}
|
present := map[string]bool{flash: true}
|
||||||
stacks := []bootStack{
|
stacks := []bootStack{
|
||||||
{name: "romm", deployed: true, hdd: flash, hasContainers: true}, // drive-backed, live → recreate
|
{name: "romm", deployed: true, hdd: flash, hasContainers: true}, // drive-backed, live → recreate
|
||||||
{name: "actualbudget", deployed: true, hdd: "/mnt/sys_drive/felhom-data", hasContainers: true}, // SSD → not recreated
|
{name: "actualbudget", deployed: true, hdd: "/mnt/sys_drive/felhom-data", hasContainers: true}, // SSD → not recreated
|
||||||
{name: "stranded", deployed: true, hdd: "/mnt/felhom-drives/felhom-usb", hasContainers: true}, // drive-backed, bind NOT live → skipped
|
{name: "stranded", deployed: true, hdd: "/mnt/felhom-drives/felhom-usb", hasContainers: true}, // drive-backed, bind NOT live → skipped
|
||||||
}
|
}
|
||||||
@@ -247,12 +247,12 @@ func TestStablePathForName(t *testing.T) {
|
|||||||
// gate. Both fail here.
|
// gate. Both fail here.
|
||||||
func TestPlanDriveGates(t *testing.T) {
|
func TestPlanDriveGates(t *testing.T) {
|
||||||
paths := []settings.StoragePath{
|
paths := []settings.StoragePath{
|
||||||
{Path: "/mnt/felhom-drives/usb"}, // present + connected → no action
|
{Path: "/mnt/felhom-drives/usb"}, // present + connected → no action
|
||||||
{Path: "/mnt/felhom-drives/flash"}, // ABSENT + connected → STOP
|
{Path: "/mnt/felhom-drives/flash"}, // ABSENT + connected → STOP
|
||||||
{Path: "/mnt/felhom-drives/back", Disconnected: true}, // present + disconnected → RETURN
|
{Path: "/mnt/felhom-drives/back", Disconnected: true}, // present + disconnected → RETURN
|
||||||
{Path: "/mnt/felhom-drives/gone", Disconnected: true}, // ABSENT + disconnected → no action (steady)
|
{Path: "/mnt/felhom-drives/gone", Disconnected: true}, // ABSENT + disconnected → no action (steady)
|
||||||
{Path: "/mnt/felhom-drives/dead", Decommissioned: true}, // decommissioned → never touched
|
{Path: "/mnt/felhom-drives/dead", Decommissioned: true}, // decommissioned → never touched
|
||||||
{Path: "/mnt/sys_drive/felhom-data"}, // INTERNAL SSD (absent from agent) → never gated
|
{Path: "/mnt/sys_drive/felhom-data"}, // INTERNAL SSD (absent from agent) → never gated
|
||||||
}
|
}
|
||||||
disks := []agentapi.DiskInfo{
|
disks := []agentapi.DiskInfo{
|
||||||
// present = BoundUnderParent (the usable-in-guest signal), not merely State==attached.
|
// present = BoundUnderParent (the usable-in-guest signal), not merely State==attached.
|
||||||
|
|||||||
@@ -24,13 +24,13 @@ func lcStack(name, lifecycle string, deployed bool) stacks.Stack {
|
|||||||
// withdrawing an app dangerous in the first place.
|
// withdrawing an app dangerous in the first place.
|
||||||
func TestVisibleCatalogStacks(t *testing.T) {
|
func TestVisibleCatalogStacks(t *testing.T) {
|
||||||
in := []stacks.Stack{
|
in := []stacks.Stack{
|
||||||
lcStack("bookstack", "", false), // available, not deployed → OFFERED
|
lcStack("bookstack", "", false), // available, not deployed → OFFERED
|
||||||
lcStack("immich", "", true), // available, deployed → shown
|
lcStack("immich", "", true), // available, deployed → shown
|
||||||
lcStack("plant-it", "abandoned", false), // withdrawn, not deployed → HIDDEN
|
lcStack("plant-it", "abandoned", false), // withdrawn, not deployed → HIDDEN
|
||||||
lcStack("plant-it-run", "abandoned", true),// withdrawn, DEPLOYED → shown
|
lcStack("plant-it-run", "abandoned", true), // withdrawn, DEPLOYED → shown
|
||||||
lcStack("oldapp", "hidden", false), // withdrawn, not deployed → HIDDEN
|
lcStack("oldapp", "hidden", false), // withdrawn, not deployed → HIDDEN
|
||||||
lcStack("oldapp-run", "hidden", true), // withdrawn, DEPLOYED → shown
|
lcStack("oldapp-run", "hidden", true), // withdrawn, DEPLOYED → shown
|
||||||
lcStack("typoapp", "bogus", false), // unknown → available → OFFERED (fail-open)
|
lcStack("typoapp", "bogus", false), // unknown → available → OFFERED (fail-open)
|
||||||
}
|
}
|
||||||
got := map[string]bool{}
|
got := map[string]bool{}
|
||||||
for _, st := range visibleCatalogStacks(in) {
|
for _, st := range visibleCatalogStacks(in) {
|
||||||
|
|||||||
@@ -65,11 +65,11 @@ func TestMobileCSS_StripRemovedDrawerPresent(t *testing.T) {
|
|||||||
|
|
||||||
// The off-canvas drawer + no-JS fallback are present in the mobile block.
|
// The off-canvas drawer + no-JS fallback are present in the mobile block.
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
".js .sidebar", // JS path: sidebar becomes an off-canvas drawer
|
".js .sidebar", // JS path: sidebar becomes an off-canvas drawer
|
||||||
"translateX(-100%)", // parked off-canvas until opened
|
"translateX(-100%)", // parked off-canvas until opened
|
||||||
".no-js .sidebar", // no-JS path: sidebar renders static inline
|
".no-js .sidebar", // no-JS path: sidebar renders static inline
|
||||||
"body.nav-open", // scroll lock while the drawer is open
|
"body.nav-open", // scroll lock while the drawer is open
|
||||||
".mobile-topbar", // the sticky top bar is styled inside the mobile block
|
".mobile-topbar", // the sticky top bar is styled inside the mobile block
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(block, want) {
|
if !strings.Contains(block, want) {
|
||||||
t.Errorf("768px block missing %q — the drawer/fallback rules are incomplete", want)
|
t.Errorf("768px block missing %q — the drawer/fallback rules are incomplete", want)
|
||||||
@@ -82,7 +82,7 @@ func TestMobileCSS_StripRemovedDrawerPresent(t *testing.T) {
|
|||||||
t.Errorf("stylesheet missing base rule for %q", want)
|
t.Errorf("stylesheet missing base rule for %q", want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !strings.Contains(css, ".mobile-topbar { display: none") &&
|
if !strings.Contains(css, ".mobile-topbar { display: none") &&
|
||||||
!strings.Contains(css, ".mobile-topbar{display:none") {
|
!strings.Contains(css, ".mobile-topbar{display:none") {
|
||||||
t.Error("no desktop-default `.mobile-topbar { display:none }` — the top bar would leak onto desktop")
|
t.Error("no desktop-default `.mobile-topbar { display:none }` — the top bar would leak onto desktop")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,10 @@ func TestNetAddGate_OldAgent_RefusedUpFront(t *testing.T) {
|
|||||||
verifyErr: &agentapi.StatusError{Path: "/netstorage/verify-status", Code: http.StatusNotFound},
|
verifyErr: &agentapi.StatusError{Path: "/netstorage/verify-status", Code: http.StatusNotFound},
|
||||||
}
|
}
|
||||||
s.netAgentFn = func() (netAgent, error) { return agent, nil }
|
s.netAgentFn = func() (netAgent, error) { return agent, nil }
|
||||||
s.netProbeFn = func(context.Context, string) probeOutcome { t.Error("probe must never run on a gated add"); return probeOutcome{} }
|
s.netProbeFn = func(context.Context, string) probeOutcome {
|
||||||
|
t.Error("probe must never run on a gated add")
|
||||||
|
return probeOutcome{}
|
||||||
|
}
|
||||||
|
|
||||||
w := postNetAdd(t, s, "media")
|
w := postNetAdd(t, s, "media")
|
||||||
if w.Code != http.StatusPreconditionFailed {
|
if w.Code != http.StatusPreconditionFailed {
|
||||||
|
|||||||
@@ -221,11 +221,12 @@ func TestRunStorageInit_Success(t *testing.T) {
|
|||||||
|
|
||||||
// F6 (VALIDATION-n100) — initialize must end in a USABLE (mounted+registered) drive even when the
|
// F6 (VALIDATION-n100) — initialize must end in a USABLE (mounted+registered) drive even when the
|
||||||
// client disconnects mid-format. Two halves:
|
// client disconnects mid-format. Two halves:
|
||||||
// RED-PROOF: runStorageInit on a CANCELLED context (the disconnect) aborts at the mount step →
|
//
|
||||||
// the device is formatted but NOT registered (the N100-observed state). This is exactly what
|
// RED-PROOF: runStorageInit on a CANCELLED context (the disconnect) aborts at the mount step →
|
||||||
// the pre-fix handler did (it ran the chain on r.Context()).
|
// the device is formatted but NOT registered (the N100-observed state). This is exactly what
|
||||||
// FIX: startStorageInit runs the chain on a DETACHED context → it registers regardless of the
|
// the pre-fix handler did (it ran the chain on r.Context()).
|
||||||
// client, and leaves EXACTLY ONE registry entry (marker-last, Scenario B).
|
// FIX: startStorageInit runs the chain on a DETACHED context → it registers regardless of the
|
||||||
|
// client, and leaves EXACTLY ONE registry entry (marker-last, Scenario B).
|
||||||
func TestStorageInit_DetachedSurvivesClientDisconnect(t *testing.T) {
|
func TestStorageInit_DetachedSurvivesClientDisconnect(t *testing.T) {
|
||||||
newMock := func() *mockAgent {
|
newMock := func() *mockAgent {
|
||||||
return &mockAgent{
|
return &mockAgent{
|
||||||
|
|||||||
@@ -29,9 +29,9 @@ type storageInitJob struct {
|
|||||||
Where string `json:"where,omitempty"` // the registered stable path (done only)
|
Where string `json:"where,omitempty"` // the registered stable path (done only)
|
||||||
Phase string `json:"phase"` // formatting | mounting | registering | done | failed | needs_confirmation | refused
|
Phase string `json:"phase"` // formatting | mounting | registering | done | failed | needs_confirmation | refused
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
Reason string `json:"reason,omitempty"` // refusal/confirm reason (Hungarian, from the agent)
|
Reason string `json:"reason,omitempty"` // refusal/confirm reason (Hungarian, from the agent)
|
||||||
DurableID string `json:"durable_id,omitempty"` // needs_confirmation: the durable id to confirm against
|
DurableID string `json:"durable_id,omitempty"` // needs_confirmation: the durable id to confirm against
|
||||||
Opsign string `json:"opsign,omitempty"` // refused: the operator opsign command
|
Opsign string `json:"opsign,omitempty"` // refused: the operator opsign command
|
||||||
StartedAt time.Time `json:"started_at"`
|
StartedAt time.Time `json:"started_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
@@ -54,7 +54,7 @@ const storageInitDeadline = 65 * time.Minute
|
|||||||
// storageInitParams carries the validated init request into the detached job.
|
// storageInitParams carries the validated init request into the detached job.
|
||||||
type storageInitParams struct {
|
type storageInitParams struct {
|
||||||
device, fstype, where, label, durableID string
|
device, fstype, where, label, durableID string
|
||||||
setDefault, confirmed bool
|
setDefault, confirmed bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// storageInitState is the single-flight slot (netAddState shape: acquire/release/set/snapshot,
|
// storageInitState is the single-flight slot (netAddState shape: acquire/release/set/snapshot,
|
||||||
|
|||||||
@@ -28,13 +28,13 @@ func TestStorageNetworkTemplate_CanonicalClasses(t *testing.T) {
|
|||||||
html := buf.String()
|
html := buf.String()
|
||||||
|
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
`class="form-control"`, // the canonical input class
|
`class="form-control"`, // the canonical input class
|
||||||
`class="form-group"`, // the canonical field wrapper
|
`class="form-group"`, // the canonical field wrapper
|
||||||
"SMB (Synology, QNAP", // protocol-honest ordering: SMB listed first
|
"SMB (Synology, QNAP", // protocol-honest ordering: SMB listed first
|
||||||
"NFS (TrueNAS, Linux szerver)", // no bare "ajánlott" claim
|
"NFS (TrueNAS, Linux szerver)", // no bare "ajánlott" claim
|
||||||
"Árva", // the orphan badge renders
|
"Árva", // the orphan badge renders
|
||||||
"minden felhasználó leképezése", // the Route-A guidance block
|
"minden felhasználó leképezése", // the Route-A guidance block
|
||||||
"ns-hostid", // the live computed host-id span
|
"ns-hostid", // the live computed host-id span
|
||||||
"/api/storage/netstorage/add/status", // the poll-driven progress wiring
|
"/api/storage/netstorage/add/status", // the poll-driven progress wiring
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(html, want) {
|
if !strings.Contains(html, want) {
|
||||||
@@ -42,8 +42,8 @@ func TestStorageNetworkTemplate_CanonicalClasses(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, banned := range []string{
|
for _, banned := range []string{
|
||||||
"form-row", // nonexistent class — the old unstyled-look root cause
|
"form-row", // nonexistent class — the old unstyled-look root cause
|
||||||
"form-input", // nonexistent class
|
"form-input", // nonexistent class
|
||||||
`<summary class="btn`, // the summary-styled-as-button hack
|
`<summary class="btn`, // the summary-styled-as-button hack
|
||||||
} {
|
} {
|
||||||
if strings.Contains(html, banned) {
|
if strings.Contains(html, banned) {
|
||||||
|
|||||||
Reference in New Issue
Block a user