HH-867: 控制面单用户认证与停机通知 (#30)
This commit is contained in:
@@ -782,13 +782,17 @@ func newTestAppWithNetwork(t *testing.T, store *memoryStore, gateway *fakeGatewa
|
||||
return app
|
||||
}
|
||||
|
||||
func do(app *fiber.App, method, path, body string) *httptest.ResponseRecorder {
|
||||
func do(app *fiber.App, method, path, body string, credentials ...string) *httptest.ResponseRecorder {
|
||||
response := httptest.NewRecorder()
|
||||
var reader io.Reader
|
||||
if body != "" {
|
||||
reader = strings.NewReader(body)
|
||||
}
|
||||
adaptor.FiberApp(app).ServeHTTP(response, httptest.NewRequest(method, path, reader))
|
||||
request := httptest.NewRequest(method, path, reader)
|
||||
if len(credentials) == 2 {
|
||||
request.SetBasicAuth(credentials[0], credentials[1])
|
||||
}
|
||||
adaptor.FiberApp(app).ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,11 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -15,6 +19,7 @@ import (
|
||||
|
||||
"git.ipao.vip/rogee/creator-hub/internal/hub"
|
||||
"git.ipao.vip/rogee/creator-hub/internal/phasea"
|
||||
"git.ipao.vip/rogee/creator-hub/internal/taskstate"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -22,10 +27,9 @@ import (
|
||||
)
|
||||
|
||||
type config struct {
|
||||
listenAddr string
|
||||
webDir string
|
||||
databaseURL string
|
||||
logLevel logrus.Level
|
||||
listenAddr, webDir, databaseURL string
|
||||
username, password string
|
||||
logLevel logrus.Level
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -65,6 +69,9 @@ func newCommand() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
defer hubStore.Close()
|
||||
notify := newAttentionNotifier(os.Stderr)
|
||||
phaseAStore.SetTaskNotifier(notify)
|
||||
hubStore.SetTaskNotifier(notify)
|
||||
logStartup(cfg)
|
||||
heartbeatContext, stopHeartbeat := context.WithCancel(command.Context())
|
||||
heartbeatDone := make(chan struct{})
|
||||
@@ -72,7 +79,7 @@ func newCommand() *cobra.Command {
|
||||
defer close(heartbeatDone)
|
||||
runtimeLeaseHeartbeat(heartbeatContext, hubStore)
|
||||
}()
|
||||
listenErr := newHandlerWithStores(cfg.webDir, phaseAStore, hubStore).Listen(cfg.listenAddr, fiber.ListenConfig{
|
||||
listenErr := newHandlerWithStores(cfg.webDir, cfg.username, cfg.password, phaseAStore, hubStore).Listen(cfg.listenAddr, fiber.ListenConfig{
|
||||
GracefulContext: command.Context(),
|
||||
DisableStartupMessage: true,
|
||||
})
|
||||
@@ -84,6 +91,21 @@ func newCommand() *cobra.Command {
|
||||
return command
|
||||
}
|
||||
|
||||
func newAttentionNotifier(output io.Writer) taskstate.Notifier {
|
||||
logger := logrus.New()
|
||||
logger.SetFormatter(&logrus.JSONFormatter{})
|
||||
logger.SetOutput(output)
|
||||
return func(transition taskstate.Transition) {
|
||||
if transition.State != "policy_hold" && transition.State != "needs_confirmation" {
|
||||
return
|
||||
}
|
||||
logger.WithFields(logrus.Fields{
|
||||
"service": "control-plane", "notification_channel": "structured_log", "event_type": transition.State,
|
||||
"reason_code": transition.ReasonCode, "account_id": transition.AccountID, "task_id": transition.TaskID,
|
||||
}).Warn("operator attention required")
|
||||
}
|
||||
}
|
||||
|
||||
func runtimeLeaseHeartbeat(ctx context.Context, store hubStore) {
|
||||
ticker := time.NewTicker(20 * time.Second)
|
||||
defer ticker.Stop()
|
||||
@@ -116,6 +138,8 @@ func loadConfig() (config, error) {
|
||||
_ = v.BindEnv("web_dir", "WEB_DIR")
|
||||
_ = v.BindEnv("database_url", "DATABASE_URL")
|
||||
_ = v.BindEnv("log_level", "LOG_LEVEL")
|
||||
_ = v.BindEnv("username", "CONTROL_PLANE_USERNAME")
|
||||
_ = v.BindEnv("password", "CONTROL_PLANE_PASSWORD")
|
||||
|
||||
level, err := logrus.ParseLevel(v.GetString("log_level"))
|
||||
if err != nil {
|
||||
@@ -125,6 +149,8 @@ func loadConfig() (config, error) {
|
||||
listenAddr: strings.TrimSpace(v.GetString("listen_addr")),
|
||||
webDir: strings.TrimSpace(v.GetString("web_dir")),
|
||||
databaseURL: strings.TrimSpace(v.GetString("database_url")),
|
||||
username: strings.TrimSpace(v.GetString("username")),
|
||||
password: v.GetString("password"),
|
||||
logLevel: level,
|
||||
}
|
||||
if cfg.listenAddr == "" {
|
||||
@@ -136,6 +162,12 @@ func loadConfig() (config, error) {
|
||||
if cfg.webDir == "" {
|
||||
return config{}, errors.New("WEB_DIR must not be empty")
|
||||
}
|
||||
if cfg.username == "" || strings.Contains(cfg.username, ":") {
|
||||
return config{}, errors.New("CONTROL_PLANE_USERNAME must be non-empty and contain no colon")
|
||||
}
|
||||
if len(cfg.password) < 16 {
|
||||
return config{}, errors.New("CONTROL_PLANE_PASSWORD must contain at least 16 bytes")
|
||||
}
|
||||
databaseURL, err := url.Parse(cfg.databaseURL)
|
||||
if err != nil || databaseURL.Host == "" || databaseURL.Path == "" || databaseURL.Path == "/" ||
|
||||
(databaseURL.Scheme != "postgres" && databaseURL.Scheme != "postgresql") {
|
||||
@@ -156,11 +188,11 @@ func validateListenAddr(addr string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func newHandler(webDirectory string) *fiber.App {
|
||||
return newHandlerWithStores(webDirectory, nil, nil)
|
||||
func newHandler(webDirectory, username, password string) *fiber.App {
|
||||
return newHandlerWithStores(webDirectory, username, password, nil, nil)
|
||||
}
|
||||
|
||||
func newHandlerWithStores(webDirectory string, phaseAStore *phasea.Store, hubStore *hub.Store) *fiber.App {
|
||||
func newHandlerWithStores(webDirectory, username, password string, phaseAStore *phasea.Store, hubStore *hub.Store) *fiber.App {
|
||||
app := fiber.New(fiber.Config{
|
||||
AppName: "CreatorHub control plane",
|
||||
BodyLimit: 1 << 20,
|
||||
@@ -172,6 +204,7 @@ func newHandlerWithStores(webDirectory string, phaseAStore *phasea.Store, hubSto
|
||||
c.Status(fiber.StatusNoContent)
|
||||
return nil
|
||||
})
|
||||
app.Use(authenticate(username, password))
|
||||
if hubStore != nil {
|
||||
registerHub(app, hubStore)
|
||||
}
|
||||
@@ -182,6 +215,23 @@ func newHandlerWithStores(webDirectory string, phaseAStore *phasea.Store, hubSto
|
||||
return app
|
||||
}
|
||||
|
||||
func authenticate(username, password string) fiber.Handler {
|
||||
wantUser, wantPassword := sha256.Sum256([]byte(username)), sha256.Sum256([]byte(password))
|
||||
return func(c fiber.Ctx) error {
|
||||
encoded, ok := strings.CutPrefix(c.Get(fiber.HeaderAuthorization), "Basic ")
|
||||
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
||||
user, suppliedPassword, found := strings.Cut(string(decoded), ":")
|
||||
gotUser, gotPassword := sha256.Sum256([]byte(user)), sha256.Sum256([]byte(suppliedPassword))
|
||||
userOK := subtle.ConstantTimeCompare(gotUser[:], wantUser[:])
|
||||
passwordOK := subtle.ConstantTimeCompare(gotPassword[:], wantPassword[:])
|
||||
if !ok || err != nil || !found || userOK != 1 || passwordOK != 1 {
|
||||
c.Set(fiber.HeaderWWWAuthenticate, `Basic realm="CreatorHub", charset="UTF-8"`)
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(map[string]string{"error": "authentication required"})
|
||||
}
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func spaHandler(directory string) fiber.Handler {
|
||||
return func(c fiber.Ctx) error {
|
||||
path, err := url.PathUnescape(strings.TrimPrefix(c.Path(), "/"))
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
|
||||
"git.ipao.vip/rogee/creator-hub/internal/hub"
|
||||
"git.ipao.vip/rogee/creator-hub/internal/phasea"
|
||||
"git.ipao.vip/rogee/creator-hub/internal/taskstate"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -67,12 +68,116 @@ func TestExecuteContextStopsOnSIGTERM(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLoadConfigRejectsInvalidDatabase(t *testing.T) {
|
||||
t.Setenv("CONTROL_PLANE_USERNAME", "operator")
|
||||
t.Setenv("CONTROL_PLANE_PASSWORD", "unit-test-password")
|
||||
t.Setenv("DATABASE_URL", "file:///tmp/creatorhub.db")
|
||||
if _, err := loadConfig(); err == nil {
|
||||
t.Fatal("expected non-Postgres database URL to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigRequiresControlPlaneCredentials(t *testing.T) {
|
||||
t.Setenv("CONTROL_PLANE_USERNAME", "")
|
||||
t.Setenv("CONTROL_PLANE_PASSWORD", "")
|
||||
if _, err := loadConfig(); err == nil {
|
||||
t.Fatal("expected missing control-plane credentials to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestControlPlaneAuthentication(t *testing.T) {
|
||||
logger := logrus.StandardLogger()
|
||||
previousOutput := logger.Out
|
||||
var logs bytes.Buffer
|
||||
logrus.SetOutput(&logs)
|
||||
t.Cleanup(func() { logrus.SetOutput(previousOutput) })
|
||||
|
||||
directory := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(directory, "index.html"), []byte("index"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := newHandler(directory, "operator", "unit-test-password")
|
||||
|
||||
health, err := app.Test(httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||
if err != nil || health.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("health check must remain public: status=%d err=%v", health.StatusCode, err)
|
||||
}
|
||||
health.Body.Close()
|
||||
|
||||
for _, path := range []string{
|
||||
"/", "/api/phase-a/accounts", "/api/browsers", "/api/network-exits", "/api/phase-a/tasks", "/api/phase-a/audit",
|
||||
} {
|
||||
request := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
response, err := app.Test(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response.Body.Close()
|
||||
if response.StatusCode != http.StatusUnauthorized || response.Header.Get("WWW-Authenticate") == "" {
|
||||
t.Fatalf("GET %s was not protected: status=%d", path, response.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
for name, test := range map[string]struct {
|
||||
user, password string
|
||||
want int
|
||||
}{
|
||||
"valid": {"operator", "unit-test-password", http.StatusOK},
|
||||
"wrong user": {"other", "unit-test-password", http.StatusUnauthorized},
|
||||
"wrong password": {"operator", "credential-must-not-be-logged", http.StatusUnauthorized},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
request := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
request.SetBasicAuth(test.user, test.password)
|
||||
response, err := app.Test(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response.Body.Close()
|
||||
if response.StatusCode != test.want {
|
||||
t.Fatalf("status=%d want=%d", response.StatusCode, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
if strings.Contains(logs.String(), "credential-must-not-be-logged") {
|
||||
t.Fatalf("authentication credential reached logs: %s", logs.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperatorNotificationFiltersAndRedacts(t *testing.T) {
|
||||
previousLevel := logrus.GetLevel()
|
||||
t.Cleanup(func() { logrus.SetLevel(previousLevel) })
|
||||
var output bytes.Buffer
|
||||
notify := newAttentionNotifier(&output)
|
||||
|
||||
notify(taskstate.Transition{State: "succeeded", ReasonCode: "task_succeeded", AccountID: "account-a", TaskID: "task-a"})
|
||||
if output.Len() != 0 {
|
||||
t.Fatalf("successful task emitted a notification: %s", output.String())
|
||||
}
|
||||
|
||||
for _, level := range []logrus.Level{logrus.ErrorLevel, logrus.FatalLevel, logrus.PanicLevel} {
|
||||
for _, test := range []struct{ state, reason string }{
|
||||
{"policy_hold", "account_paused"}, {"needs_confirmation", "exit_unhealthy"},
|
||||
} {
|
||||
output.Reset()
|
||||
logrus.SetLevel(level)
|
||||
notify(taskstate.Transition{State: test.state, ReasonCode: test.reason, AccountID: "account-a", TaskID: "task-a"})
|
||||
var entry map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &entry); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if entry["event_type"] != test.state || entry["reason_code"] != test.reason ||
|
||||
entry["notification_channel"] != "structured_log" || entry["account_id"] != "account-a" || entry["task_id"] != "task-a" {
|
||||
t.Fatalf("unexpected notification at LOG_LEVEL=%s: %#v", level, entry)
|
||||
}
|
||||
for _, forbidden := range []string{"password", "authorization", "credential", "token", "secret"} {
|
||||
if strings.Contains(strings.ToLower(output.String()), forbidden) {
|
||||
t.Fatalf("notification contained sensitive field %q: %s", forbidden, output.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhaseAAccountRequestRejectsSecretsAndUnknownFields(t *testing.T) {
|
||||
for name, body := range map[string]string{
|
||||
"raw password": `{"id":"account-a","platform":"mock","platform_account_key":"a","authorization_kind":"owned","credential_reference":{"id":"credential-a","provider":"os_keyring","key":"creatorhub/account-a"},"password":"secret"}`,
|
||||
@@ -116,17 +221,38 @@ func TestPhaseAAccountHTTPWorkflowRedactsSecrets(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := hubStore.Close(); err != nil {
|
||||
t.Cleanup(func() { _ = hubStore.Close() })
|
||||
|
||||
webDirectory := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(webDirectory, "index.html"), []byte("index"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
protectedApp := newHandlerWithStores(webDirectory, "operator", "unit-test-password", store, hubStore)
|
||||
for _, route := range []struct{ method, path string }{
|
||||
{http.MethodGet, "/api/phase-a/accounts"}, {http.MethodPost, "/api/phase-a/accounts"},
|
||||
{http.MethodPost, "/api/phase-a/accounts/account-http/pause"}, {http.MethodGet, "/api/phase-a/tasks"},
|
||||
{http.MethodPost, "/api/phase-a/mock/execute"}, {http.MethodGet, "/api/phase-a/audit"},
|
||||
{http.MethodGet, "/api/browsers"}, {http.MethodPost, "/api/browsers/account-http/start"},
|
||||
{http.MethodDelete, "/api/browsers/account-http"}, {http.MethodPut, "/api/browser-images/1"},
|
||||
{http.MethodPost, "/api/network-exits/exit-http/check"}, {http.MethodPost, "/api/network-exits/exit-http/disable"},
|
||||
} {
|
||||
if response := do(protectedApp, route.method, route.path, ""); response.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unauthenticated %s %s returned %d", route.method, route.path, response.Code)
|
||||
}
|
||||
}
|
||||
for _, path := range []string{"/api/phase-a/accounts", "/api/browsers", "/api/phase-a/audit"} {
|
||||
if response := do(protectedApp, http.MethodGet, path, "", "operator", "unit-test-password"); response.Code != http.StatusOK {
|
||||
t.Fatalf("authenticated GET %s returned %d: %s", path, response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Use(authenticate("operator", "unit-test-password"))
|
||||
registerPhaseA(app, store, nil)
|
||||
credentialKey := "creatorhub/phase-http-secret"
|
||||
accountBody := `{"id":"account-http","platform":"mock","platform_account_key":"phase-http","authorization_kind":"owned","credential_reference":{"id":"credential-http","provider":"os_keyring","key":"` + credentialKey + `"}}`
|
||||
request := func(method, path, body string, wantStatus int) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
response := do(app, method, path, body)
|
||||
response := do(app, method, path, body, "operator", "unit-test-password")
|
||||
if response.Code != wantStatus {
|
||||
t.Fatalf("%s %s returned %d, want %d: %s", method, path, response.Code, wantStatus, response.Body.String())
|
||||
}
|
||||
@@ -204,7 +330,7 @@ func TestSPAServesFileAndIndexFallback(t *testing.T) {
|
||||
if err := os.WriteFile(filepath.Join(directory, "..", "outside-secret.txt"), []byte("secret"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := newHandler(directory)
|
||||
app := newHandler(directory, "operator", "unit-test-password")
|
||||
for path, want := range map[string]string{
|
||||
"/app.js": "asset",
|
||||
"/hello%20world.js": "space",
|
||||
@@ -212,7 +338,9 @@ func TestSPAServesFileAndIndexFallback(t *testing.T) {
|
||||
"/%2e%2e%2foutside-secret.txt": "index",
|
||||
"/dashboard": "index",
|
||||
} {
|
||||
response, err := app.Test(httptest.NewRequest(http.MethodGet, path, nil))
|
||||
request := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
request.SetBasicAuth("operator", "unit-test-password")
|
||||
response, err := app.Test(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user