diff --git a/docs/configuration.md b/docs/configuration.md index bdb5bca..b7c9b45 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -34,6 +34,7 @@ same table; `vps config list` shows the live values with this annotation. | `panel.session_days` | operator | restart panel | login session lifetime (days) | | `panel.url_path` | **fixed at install** | — | secret prefix of the user panel; settable only while empty (re-enable) | | `panel.admin_url_path` | operator | restart panel | secret prefix of the admin panel; an **empty value disables the admin panel** (shown as `disabled`) | +| `panel.show_footer` | operator | restart panel | user panel footer with project link and installed/build version; `false` hides the entire footer | | `panel.admin_pass_hash` | managed elsewhere | — | bcrypt hash of the admin password; stored in the **DB**, set via `vps admin-passwd` / web UI | | `net.subnet` | **fixed at install** | — | container subnet `10..0.0/24`; changing breaks existing containers | | `net.gateway` | **fixed at install** | — | bridge gateway (derived from subnet) | diff --git a/src/internal/cfg/cfg.go b/src/internal/cfg/cfg.go index ea16371..54e40b5 100644 --- a/src/internal/cfg/cfg.go +++ b/src/internal/cfg/cfg.go @@ -127,7 +127,8 @@ type PanelCfg struct { // AdminPath is the secret prefix of the admin panel (e.g. /Xy-9ab_cdE), // a second random path generated at install. Requests that match neither // URLPath nor AdminPath get a bare, headerless 404. - AdminPath string `yaml:"admin_url_path,omitempty"` + AdminPath string `yaml:"admin_url_path,omitempty"` + ShowFooter bool `yaml:"show_footer"` } type NetCfg struct { @@ -239,7 +240,7 @@ type SnapshotsCfg struct { func Default() *Config { c := &Config{} - c.Panel = PanelCfg{Listen: DefaultListen, Cert: DefaultDataDir + "/panel.crt", Key: DefaultDataDir + "/panel.key", DB: DefaultDB, SessionDays: 3} + c.Panel = PanelCfg{Listen: DefaultListen, Cert: DefaultDataDir + "/panel.crt", Key: DefaultDataDir + "/panel.key", DB: DefaultDB, SessionDays: 3, ShowFooter: true} c.Net = NetCfg{Subnet: DefaultSubnet, Gateway: DefaultGateway, V4Forward: true, Traefik: true, UserPorts: DefaultUserPorts} c.Incus = IncusCfg{Image: DefaultImage, ImageFallback: DefaultImageFB, Pool: DefaultPool, Bridge: DefaultBridge, Socket: DefaultSocket, SwapRatio: DefaultSwapRatio} c.Snapshots = SnapshotsCfg{Limit: 1} diff --git a/src/internal/cfg/registry.go b/src/internal/cfg/registry.go index 540d56a..c961c26 100644 --- a/src/internal/cfg/registry.go +++ b/src/internal/cfg/registry.go @@ -215,6 +215,18 @@ var Fields = []Field{ c.Panel.AdminPath = v return nil }}, + {"panel.show_footer", KindOperator, ApplyRestart, + "user panel footer with project link and version; false = hide the entire footer", + "true or false", + getStr(func(c *Config) string { return strconv.FormatBool(c.Panel.ShowFooter) }), + func(c *Config, v string) error { + b, ok := parseBool(v) + if !ok { + return fmt.Errorf("panel.show_footer must be true/false or 1/0") + } + c.Panel.ShowFooter = b + return nil + }}, {"panel.admin_pass_hash", KindSpecial, ApplyNone, "admin password bcrypt hash — set via `vps admin-passwd` (stored in the DB)", "", diff --git a/src/internal/cfg/registry_test.go b/src/internal/cfg/registry_test.go index e9e8b06..585badc 100644 --- a/src/internal/cfg/registry_test.go +++ b/src/internal/cfg/registry_test.go @@ -42,6 +42,9 @@ func TestFieldValueReadsConfig(t *testing.T) { if v := FieldValue(c, "net.traefik"); v != "true" { t.Errorf("net.traefik default = %q", v) } + if v := FieldValue(c, "panel.show_footer"); v != "true" { + t.Errorf("panel.show_footer default = %q", v) + } } func TestSnapshotsLimitField(t *testing.T) { @@ -105,6 +108,17 @@ func TestAssignValidators(t *testing.T) { if err := FieldFor("net.traefik").Assign(c, "maybe"); err == nil { t.Error("traefik=maybe accepted") } + for _, v := range []string{"true", "1", "on", "false", "0", "off"} { + if err := FieldFor("panel.show_footer").Assign(c, v); err != nil { + t.Errorf("show_footer=%q: %v", v, err) + } + } + if err := FieldFor("panel.show_footer").Assign(c, "maybe"); err == nil { + t.Error("show_footer=maybe accepted") + } + if c.Panel.ShowFooter { + t.Error("show_footer should be false after the final test assignment") + } if err := FieldFor("net.ipv6_subnet").Assign(c, "2001:db8::/64"); err != nil { t.Fatalf("ipv6_subnet valid: %v", err) @@ -157,6 +171,7 @@ func TestEditableClassification(t *testing.T) { "net.ipv6_subnet": "yes", "panel.url_path": "only-when-empty", "panel.admin_url_path": "yes", + "panel.show_footer": "yes", "net.subnet": "no", "net.gateway": "no", "incus.pool": "no", diff --git a/src/internal/panel/panel.go b/src/internal/panel/panel.go index e9f3648..feb6c55 100644 --- a/src/internal/panel/panel.go +++ b/src/internal/panel/panel.go @@ -13,6 +13,7 @@ import ( "vpsmgr/internal/csrf" "vpsmgr/internal/db" "vpsmgr/internal/mgr" + "vpsmgr/internal/ver" ) //go:embed templates/*.html @@ -170,6 +171,8 @@ type pageData struct { // default). The overview tints its background and accents with it so an // impersonating admin can tell users apart. Users cannot set it themselves. ThemeColor string + ShowFooter bool + Version string } func (s *Server) Handler() http.Handler { @@ -311,6 +314,8 @@ func (s *Server) buildData(u *db.User, msg, errMsg string) pageData { SSH: "ssh -p " + itoa(u.SSHPort) + " root@" + s.cfg.DisplayIP(), V4Forward: s.mgr.V4ForwardLive(), TraefikEnabled: s.mgr.TraefikLive(), + ShowFooter: s.cfg.Panel.ShowFooter, + Version: ver.Version, InitScript: u.InitScript, MaxNotesPlaintext: cfg.MaxNotesPlaintextBytes, QuotaCPU: mgr.FormatCPU(u.CPU), diff --git a/src/internal/panel/panel_test.go b/src/internal/panel/panel_test.go index aaa5fc7..347a993 100644 --- a/src/internal/panel/panel_test.go +++ b/src/internal/panel/panel_test.go @@ -184,6 +184,33 @@ func TestUnknownSubpathUnderPrefix(t *testing.T) { } } +func TestOverviewFooter(t *testing.T) { + srv, _ := newTestServer(t) + shown := srv.renderToString(t, "overview.html", pageData{ + User: &db.User{Name: "alice"}, + Prefix: "/" + testSecret, + ShowFooter: true, + Version: "9.8.7-local", + }) + for _, want := range []string{ + ``, + } { + if !strings.Contains(shown, want) { + t.Errorf("shown footer missing %q", want) + } + } + + hidden := srv.renderToString(t, "overview.html", pageData{ + User: &db.User{Name: "alice"}, + Prefix: "/" + testSecret, + ShowFooter: false, + Version: "9.8.7-local", + }) + if strings.Contains(hidden, "