HH-867: 控制面单用户认证与停机通知 (#30)

This commit is contained in:
2026-08-31 18:31:11 +08:00
parent b117cc738f
commit 1d9fd9f0e0
14 changed files with 522 additions and 86 deletions
+6 -2
View File
@@ -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
}
+58 -8
View File
@@ -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(), "/"))
+133 -5
View File
@@ -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)
}