+49
-19
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -25,7 +26,8 @@ type Config struct {
|
||||
ListenAddr, BaseURL, DataDir, StacksDir, AppSecret string
|
||||
SecureCookies, AuthDisabled bool
|
||||
OIDCIssuer, OIDCClientID, OIDCClientSecret, OIDCRedirectURL, OIDCAdminGroup, OIDCOperatorGroup string
|
||||
AgentToken string
|
||||
AgentToken, HostRoot string
|
||||
AllowHostUserManagement, AllowHostPermissionManagement bool
|
||||
CheckConcurrency, RetentionDays, AuditRetentionDays int
|
||||
HTTPTimeout time.Duration
|
||||
}
|
||||
@@ -51,25 +53,36 @@ func Load() (Config, error) {
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
allowHostUserManagement, err := envBoolStrict("ALLOW_HOST_USER_MANAGEMENT", false)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
allowHostPermissionManagement, err := envBoolStrict("ALLOW_HOST_PERMISSION_MANAGEMENT", false)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
c := Config{
|
||||
Mode: Mode(env("APP_MODE", "standalone")),
|
||||
ListenAddr: env("LISTEN_ADDR", ":8080"),
|
||||
BaseURL: strings.TrimRight(env("BASE_URL", "http://localhost:8080"), "/"),
|
||||
DataDir: env("DATA_DIR", "/data"),
|
||||
StacksDir: env("STACKS_DIR", "/stacks"),
|
||||
AppSecret: os.Getenv("APP_SECRET"),
|
||||
AuthDisabled: authDisabled,
|
||||
OIDCIssuer: strings.TrimRight(os.Getenv("OIDC_ISSUER"), "/"),
|
||||
OIDCClientID: os.Getenv("OIDC_CLIENT_ID"),
|
||||
OIDCClientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
|
||||
OIDCRedirectURL: os.Getenv("OIDC_REDIRECT_URL"),
|
||||
OIDCAdminGroup: env("OIDC_ADMIN_GROUP", "dockwatch-admins"),
|
||||
OIDCOperatorGroup: env("OIDC_OPERATOR_GROUP", "dockwatch-operators"),
|
||||
AgentToken: os.Getenv("AGENT_TOKEN"),
|
||||
CheckConcurrency: checkConcurrency,
|
||||
RetentionDays: retentionDays,
|
||||
AuditRetentionDays: auditRetentionDays,
|
||||
HTTPTimeout: time.Duration(httpTimeoutSeconds) * time.Second,
|
||||
Mode: Mode(env("APP_MODE", "standalone")),
|
||||
ListenAddr: env("LISTEN_ADDR", ":8080"),
|
||||
BaseURL: strings.TrimRight(env("BASE_URL", "http://localhost:8080"), "/"),
|
||||
DataDir: env("DATA_DIR", "/data"),
|
||||
StacksDir: env("STACKS_DIR", "/stacks"),
|
||||
AppSecret: os.Getenv("APP_SECRET"),
|
||||
AuthDisabled: authDisabled,
|
||||
OIDCIssuer: strings.TrimRight(os.Getenv("OIDC_ISSUER"), "/"),
|
||||
OIDCClientID: os.Getenv("OIDC_CLIENT_ID"),
|
||||
OIDCClientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
|
||||
OIDCRedirectURL: os.Getenv("OIDC_REDIRECT_URL"),
|
||||
OIDCAdminGroup: env("OIDC_ADMIN_GROUP", "dockwatch-admins"),
|
||||
OIDCOperatorGroup: env("OIDC_OPERATOR_GROUP", "dockwatch-operators"),
|
||||
AgentToken: os.Getenv("AGENT_TOKEN"),
|
||||
HostRoot: cleanOptionalPath(os.Getenv("HOST_ROOT")),
|
||||
AllowHostUserManagement: allowHostUserManagement,
|
||||
AllowHostPermissionManagement: allowHostPermissionManagement,
|
||||
CheckConcurrency: checkConcurrency,
|
||||
RetentionDays: retentionDays,
|
||||
AuditRetentionDays: auditRetentionDays,
|
||||
HTTPTimeout: time.Duration(httpTimeoutSeconds) * time.Second,
|
||||
}
|
||||
c.SecureCookies = strings.HasPrefix(c.BaseURL, "https://")
|
||||
if c.OIDCRedirectURL == "" {
|
||||
@@ -98,6 +111,15 @@ func Load() (Config, error) {
|
||||
return c, errors.New("BASE_URL must be an absolute http(s) URL without credentials, query or fragment")
|
||||
}
|
||||
}
|
||||
if c.HostRoot != "" && !filepath.IsAbs(c.HostRoot) {
|
||||
return c, errors.New("HOST_ROOT must be an absolute path")
|
||||
}
|
||||
if c.AllowHostUserManagement && c.HostRoot == "" {
|
||||
return c, errors.New("ALLOW_HOST_USER_MANAGEMENT=true requires HOST_ROOT")
|
||||
}
|
||||
if c.AllowHostPermissionManagement && c.HostRoot == "" {
|
||||
return c, errors.New("ALLOW_HOST_PERMISSION_MANAGEMENT=true requires HOST_ROOT")
|
||||
}
|
||||
if c.Mode == ModeAgent {
|
||||
if len(c.AgentToken) < 24 {
|
||||
return c, errors.New("AGENT_TOKEN must be at least 24 characters in agent mode")
|
||||
@@ -118,6 +140,14 @@ func (c Config) SecretFingerprint() string {
|
||||
h := sha256.Sum256([]byte(c.AppSecret))
|
||||
return base64.RawURLEncoding.EncodeToString(h[:6])
|
||||
}
|
||||
func cleanOptionalPath(v string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Clean(v)
|
||||
}
|
||||
|
||||
func env(k, f string) string {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
|
||||
@@ -16,3 +16,29 @@ func TestStrictBooleanParsing(t *testing.T) {
|
||||
t.Fatal("expected invalid AUTH_DISABLED to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostUserManagementRequiresHostRoot(t *testing.T) {
|
||||
t.Setenv("APP_MODE", "agent")
|
||||
t.Setenv("AGENT_TOKEN", "123456789012345678901234")
|
||||
t.Setenv("ALLOW_HOST_USER_MANAGEMENT", "true")
|
||||
t.Setenv("HOST_ROOT", "")
|
||||
if _, err := Load(); err == nil {
|
||||
t.Fatal("expected host user management without HOST_ROOT to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanOptionalHostRootPreservesFilesystemRoot(t *testing.T) {
|
||||
if got := cleanOptionalPath("/"); got != "/" {
|
||||
t.Fatalf("cleanOptionalPath(/) = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostPermissionManagementRequiresHostRoot(t *testing.T) {
|
||||
t.Setenv("AUTH_DISABLED", "true")
|
||||
t.Setenv("APP_SECRET", "01234567890123456789012345678901")
|
||||
t.Setenv("ALLOW_HOST_PERMISSION_MANAGEMENT", "true")
|
||||
t.Setenv("HOST_ROOT", "")
|
||||
if _, err := Load(); err == nil {
|
||||
t.Fatal("expected host permission management without HOST_ROOT to fail")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,10 @@ func New(c config.Config, a *auth.Service, ss *stacks.Service, n *nodes.Manager,
|
||||
api.HandleFunc("GET /api/docker/{kind}", s.dockerInventory)
|
||||
api.Handle("POST /api/docker/{kind}/actions/{action}", auth.RequireRole("operator", http.HandlerFunc(s.dockerAction)))
|
||||
api.Handle("GET /api/docker/{kind}/{id}/inspect", auth.RequireRole("operator", http.HandlerFunc(s.dockerInspect)))
|
||||
api.Handle("GET /api/docker/containers/{id}/identity", auth.RequireRole("operator", http.HandlerFunc(s.containerIdentity)))
|
||||
api.Handle("POST /api/docker/containers/{id}/bind-permissions/preview", auth.RequireRole("operator", http.HandlerFunc(s.bindPermissionPreview)))
|
||||
api.Handle("POST /api/host/bind-permissions/repair", auth.RequireRole("admin", http.HandlerFunc(s.repairBindPermissions)))
|
||||
api.Handle("POST /api/host/users", auth.RequireRole("admin", http.HandlerFunc(s.createHostUser)))
|
||||
api.HandleFunc("GET /api/stacks", s.listStacks)
|
||||
api.Handle("GET /api/stacks/{name}", auth.RequireRole("operator", http.HandlerFunc(s.getStack)))
|
||||
api.HandleFunc("POST /api/compose/parse", s.composeParse)
|
||||
@@ -105,6 +109,7 @@ func New(c config.Config, a *auth.Service, ss *stacks.Service, n *nodes.Manager,
|
||||
api.Handle("GET /api/stacks/{name}/logs", auth.RequireRole("operator", http.HandlerFunc(s.logs)))
|
||||
api.Handle("DELETE /api/stacks/{name}", auth.RequireRole("operator", http.HandlerFunc(s.deleteStack)))
|
||||
api.HandleFunc("GET /api/stacks/{name}/graph", s.stackGraph)
|
||||
api.Handle("GET /api/stacks/{name}/bind-permissions", auth.RequireRole("operator", http.HandlerFunc(s.stackBindPermissions)))
|
||||
api.HandleFunc("GET /api/stacks/{name}/image-updates", s.stackImageUpdates)
|
||||
api.Handle("GET /api/stacks/{name}/terminal", auth.RequireRole("operator", http.HandlerFunc(s.stackTerminal)))
|
||||
api.Handle("GET /api/activity", auth.RequireRole("admin", http.HandlerFunc(s.activity)))
|
||||
@@ -140,6 +145,10 @@ func (s *Server) agent(m *http.ServeMux) {
|
||||
a.HandleFunc("GET /agent/v1/docker/{kind}", s.localDockerInventory)
|
||||
a.HandleFunc("POST /agent/v1/docker/{kind}/actions/{action}", s.localDockerAction)
|
||||
a.HandleFunc("GET /agent/v1/docker/{kind}/{id}/inspect", s.localDockerInspect)
|
||||
a.HandleFunc("GET /agent/v1/docker/containers/{id}/identity", s.localContainerIdentity)
|
||||
a.HandleFunc("POST /agent/v1/docker/containers/{id}/bind-permissions/preview", s.localBindPermissionPreview)
|
||||
a.HandleFunc("POST /agent/v1/host/bind-permissions/repair", s.localRepairBindPermissions)
|
||||
a.HandleFunc("POST /agent/v1/host/users", s.localCreateHostUser)
|
||||
a.HandleFunc("GET /agent/v1/stacks", s.localList)
|
||||
a.HandleFunc("GET /agent/v1/stacks/{name}", s.localGet)
|
||||
a.HandleFunc("PUT /agent/v1/stacks/{name}", s.localSave)
|
||||
@@ -149,6 +158,7 @@ func (s *Server) agent(m *http.ServeMux) {
|
||||
a.HandleFunc("POST /agent/v1/git/sync", s.localGitSync)
|
||||
a.HandleFunc("DELETE /agent/v1/stacks/{name}", s.localDelete)
|
||||
a.HandleFunc("GET /agent/v1/stacks/{name}/graph", s.localGraph)
|
||||
a.HandleFunc("GET /agent/v1/stacks/{name}/bind-permissions", s.localStackBindPermissions)
|
||||
a.HandleFunc("GET /agent/v1/stacks/{name}/image-updates", s.localImageUpdates)
|
||||
a.HandleFunc("GET /agent/v1/stacks/{name}/terminal", s.localTerminal)
|
||||
a.HandleFunc("POST /agent/v1/probe", func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -612,6 +622,137 @@ func (s *Server) localDockerInspect(w http.ResponseWriter, r *http.Request) {
|
||||
jsonOut(w, 200, v)
|
||||
}
|
||||
|
||||
func (s *Server) containerIdentity(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if node := nodeID(r); node > 0 {
|
||||
s.relay(w, r, node, "GET", "/agent/v1/docker/containers/"+url.PathEscape(id)+"/identity", nil)
|
||||
return
|
||||
}
|
||||
s.localContainerIdentity(w, r)
|
||||
}
|
||||
|
||||
func (s *Server) localContainerIdentity(w http.ResponseWriter, r *http.Request) {
|
||||
v, e := s.stacks.ContainerIdentity(r.Context(), r.PathValue("id"))
|
||||
if e != nil {
|
||||
http.Error(w, e.Error(), 400)
|
||||
return
|
||||
}
|
||||
jsonOut(w, 200, v)
|
||||
}
|
||||
|
||||
func (s *Server) bindPermissionPreview(w http.ResponseWriter, r *http.Request) {
|
||||
var in stacks.BindPermissionPreviewInput
|
||||
if e := read(r, &in); e != nil {
|
||||
http.Error(w, e.Error(), 400)
|
||||
return
|
||||
}
|
||||
in.ContainerID = r.PathValue("id")
|
||||
if node := nodeID(r); node > 0 {
|
||||
s.relay(w, r, node, "POST", "/agent/v1/docker/containers/"+url.PathEscape(in.ContainerID)+"/bind-permissions/preview", in)
|
||||
return
|
||||
}
|
||||
s.bindPermissionPreviewLocal(w, r, in)
|
||||
}
|
||||
|
||||
func (s *Server) localBindPermissionPreview(w http.ResponseWriter, r *http.Request) {
|
||||
var in stacks.BindPermissionPreviewInput
|
||||
if e := read(r, &in); e != nil {
|
||||
http.Error(w, e.Error(), 400)
|
||||
return
|
||||
}
|
||||
in.ContainerID = r.PathValue("id")
|
||||
s.bindPermissionPreviewLocal(w, r, in)
|
||||
}
|
||||
|
||||
func (s *Server) bindPermissionPreviewLocal(w http.ResponseWriter, r *http.Request, in stacks.BindPermissionPreviewInput) {
|
||||
v, e := s.stacks.BindPermissionPreview(r.Context(), in)
|
||||
if e != nil {
|
||||
http.Error(w, e.Error(), 400)
|
||||
return
|
||||
}
|
||||
jsonOut(w, 200, v)
|
||||
}
|
||||
|
||||
func (s *Server) repairBindPermissions(w http.ResponseWriter, r *http.Request) {
|
||||
var in stacks.RepairBindPermissionsInput
|
||||
if e := read(r, &in); e != nil {
|
||||
http.Error(w, e.Error(), 400)
|
||||
return
|
||||
}
|
||||
if node := nodeID(r); node > 0 {
|
||||
s.relay(w, r, node, "POST", "/agent/v1/host/bind-permissions/repair", in)
|
||||
return
|
||||
}
|
||||
s.repairBindPermissionsLocal(w, r, in)
|
||||
}
|
||||
|
||||
func (s *Server) localRepairBindPermissions(w http.ResponseWriter, r *http.Request) {
|
||||
var in stacks.RepairBindPermissionsInput
|
||||
if e := read(r, &in); e != nil {
|
||||
http.Error(w, e.Error(), 400)
|
||||
return
|
||||
}
|
||||
s.repairBindPermissionsLocal(w, r, in)
|
||||
}
|
||||
|
||||
func (s *Server) repairBindPermissionsLocal(w http.ResponseWriter, r *http.Request, in stacks.RepairBindPermissionsInput) {
|
||||
v, e := s.stacks.RepairBindPermissions(r.Context(), in)
|
||||
if e != nil {
|
||||
http.Error(w, e.Error(), 400)
|
||||
return
|
||||
}
|
||||
jsonOut(w, 200, v)
|
||||
}
|
||||
|
||||
func (s *Server) stackBindPermissions(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("name")
|
||||
if node := nodeID(r); node > 0 {
|
||||
s.relay(w, r, node, "GET", "/agent/v1/stacks/"+url.PathEscape(name)+"/bind-permissions", nil)
|
||||
return
|
||||
}
|
||||
s.localStackBindPermissions(w, r)
|
||||
}
|
||||
|
||||
func (s *Server) localStackBindPermissions(w http.ResponseWriter, r *http.Request) {
|
||||
v, e := s.stacks.StackBindPermissions(r.Context(), r.PathValue("name"))
|
||||
if e != nil {
|
||||
http.Error(w, e.Error(), 400)
|
||||
return
|
||||
}
|
||||
jsonOut(w, 200, v)
|
||||
}
|
||||
|
||||
func (s *Server) createHostUser(w http.ResponseWriter, r *http.Request) {
|
||||
var in stacks.CreateHostUserInput
|
||||
if e := read(r, &in); e != nil {
|
||||
http.Error(w, e.Error(), 400)
|
||||
return
|
||||
}
|
||||
if node := nodeID(r); node > 0 {
|
||||
s.relay(w, r, node, "POST", "/agent/v1/host/users", in)
|
||||
return
|
||||
}
|
||||
s.createHostUserLocal(w, r, in)
|
||||
}
|
||||
|
||||
func (s *Server) localCreateHostUser(w http.ResponseWriter, r *http.Request) {
|
||||
var in stacks.CreateHostUserInput
|
||||
if e := read(r, &in); e != nil {
|
||||
http.Error(w, e.Error(), 400)
|
||||
return
|
||||
}
|
||||
s.createHostUserLocal(w, r, in)
|
||||
}
|
||||
|
||||
func (s *Server) createHostUserLocal(w http.ResponseWriter, r *http.Request, in stacks.CreateHostUserInput) {
|
||||
v, e := s.stacks.CreateHostUser(r.Context(), in)
|
||||
if e != nil {
|
||||
http.Error(w, e.Error(), 400)
|
||||
return
|
||||
}
|
||||
jsonOut(w, 200, v)
|
||||
}
|
||||
|
||||
func (s *Server) listStacks(w http.ResponseWriter, r *http.Request) {
|
||||
if id := nodeID(r); id > 0 {
|
||||
s.relay(w, r, id, "GET", "/agent/v1/stacks", nil)
|
||||
|
||||
Reference in New Issue
Block a user