164 lines
6.5 KiB
Go
164 lines
6.5 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"log"
|
|
"os"
|
|
"time"
|
|
|
|
"service-center/internal/auth"
|
|
"service-center/internal/db"
|
|
"service-center/internal/file"
|
|
"service-center/internal/registry"
|
|
"service-center/internal/roles"
|
|
"service-center/internal/staff"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
"github.com/gofiber/fiber/v2/middleware/cors"
|
|
"github.com/gofiber/fiber/v2/middleware/limiter"
|
|
"github.com/gofiber/fiber/v2/middleware/logger"
|
|
"github.com/gofiber/fiber/v2/middleware/recover"
|
|
"github.com/joho/godotenv"
|
|
)
|
|
|
|
func main() {
|
|
godotenv.Load()
|
|
|
|
// An unset/short JWT_SECRET would let auth.GenerateAccessToken sign (and
|
|
// auth.ParseToken accept) HS256 tokens keyed on an empty or guessable
|
|
// string — anyone who tries the empty key can mint themselves an
|
|
// owner-role token with no DB access at all. Fail fast at startup
|
|
// rather than silently issuing forgeable tokens. 32 bytes matches
|
|
// HS256's own recommended minimum key size.
|
|
if len(os.Getenv("JWT_SECRET")) < 32 {
|
|
log.Fatal("JWT_SECRET is not set (or shorter than 32 characters)")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
pgPool, err := db.NewPostgres(ctx)
|
|
if err != nil {
|
|
log.Fatalf("postgres: %v", err)
|
|
}
|
|
defer pgPool.Close()
|
|
|
|
if err := db.RunMigrations(os.Getenv("DATABASE_URL")); err != nil {
|
|
log.Fatalf("migrations: %v", err)
|
|
}
|
|
|
|
fileHandler, err := file.NewHandler()
|
|
if err != nil {
|
|
log.Fatalf("minio: %v", err)
|
|
}
|
|
|
|
if err := staff.EnsureOwner(ctx, pgPool); err != nil {
|
|
log.Printf("owner bootstrap skipped: %v", err)
|
|
}
|
|
|
|
authHandler := auth.NewHandler(pgPool)
|
|
staffHandler := staff.NewHandler(pgPool)
|
|
registryHandler := registry.NewHandler(pgPool)
|
|
rolesHandler := roles.NewHandler(pgPool)
|
|
|
|
app := fiber.New(fiber.Config{
|
|
AppName: "service-center-api",
|
|
// Without this, c.IP() (what the limiter below keys rate limits on,
|
|
// including /auth/login) returns the raw TCP peer address — for
|
|
// every request reaching this container through the host-level
|
|
// reverse proxy the real deployment puts in front of it, that's the
|
|
// proxy itself, the SAME address for every visitor. Confirmed live
|
|
// in this repo's own dev environment: a request from the Docker
|
|
// host to the published port arrives here as the bridge gateway IP
|
|
// (172.x.x.1), not 127.0.0.1 — hence trusting the whole private
|
|
// range below, not a literal loopback address. Effect of the bug:
|
|
// the 60 req/min limit becomes one shared budget for the whole app,
|
|
// and login brute-force protection does nothing (an attacker rides
|
|
// the same "trusted" bucket as everyone else).
|
|
EnableTrustedProxyCheck: true,
|
|
ProxyHeader: fiber.HeaderXForwardedFor,
|
|
// Scoped to this compose project's own default bridge subnet
|
|
// (172.22.0.0/16, per `docker network inspect service-center_default`)
|
|
// rather than the old 172.16.0.0/12 supernet, which also covered
|
|
// platform_net (172.23.0.0/16, shared with production's and site's
|
|
// own backend containers) and every unrelated docker project on this
|
|
// host — this is the single most security-critical service in the
|
|
// platform (JWT issuer), so an overly broad trusted range here was
|
|
// the worst instance of this bug. A container anywhere in the old
|
|
// range could reach this backend and spoof X-Forwarded-For to
|
|
// bypass the login-brute-force rate limiter entirely; this narrower
|
|
// range still covers the actual gateway address a host-level
|
|
// reverse proxy appears as.
|
|
TrustedProxies: []string{"172.22.0.0/16", "127.0.0.1"},
|
|
})
|
|
|
|
app.Use(recover.New())
|
|
app.Use(logger.New())
|
|
app.Use(cors.New(cors.Config{
|
|
AllowOrigins: os.Getenv("CORS_ORIGINS"),
|
|
AllowCredentials: true,
|
|
}))
|
|
app.Use(limiter.New(limiter.Config{
|
|
Max: 60,
|
|
}))
|
|
|
|
app.Get("/api/health", func(c *fiber.Ctx) error {
|
|
return c.JSON(fiber.Map{"ok": true})
|
|
})
|
|
|
|
api := app.Group("/api")
|
|
|
|
// Tighter than the global 60/min: brute-forcing a password needs many
|
|
// attempts against the SAME endpoint, where a legitimate user only ever
|
|
// needs a handful (a couple of typos, at most) per minute.
|
|
loginLimiter := limiter.New(limiter.Config{
|
|
Max: 10,
|
|
Expiration: 1 * time.Minute,
|
|
})
|
|
api.Post("/auth/login", loginLimiter, authHandler.Login)
|
|
|
|
// Module heartbeat authenticates itself (X-Module-Token), not via staff JWT.
|
|
api.Post("/modules/:name/heartbeat", registryHandler.Heartbeat)
|
|
|
|
// Middleware passed explicitly as handler args on every route below, never
|
|
// via Group("", middlewares...) — that resolves to the parent's own
|
|
// prefix and registers as a global path-matched app.Use(), so it
|
|
// cumulatively applies to every route declared AFTER it in this file
|
|
// regardless of which "group" variable it came from. Found and fixed in
|
|
// production the same way (see that repo's history) — converting here
|
|
// too while adding new routes into this exact spot, rather than letting
|
|
// the fragile pattern silently keep working by luck of declaration order.
|
|
staffAuth := auth.Middleware()
|
|
// staffPerm/modulesPerm replace the old ownerOnly := auth.RequireRole("owner")
|
|
// gate — a custom role with the "staff" or "modules" permission (see
|
|
// migrations/004_roles.sql) can now do what only the literal owner role
|
|
// could before. The three system roles' seeded permissions preserve
|
|
// today's exact behavior (owner has both, manager/master have neither).
|
|
staffPerm := auth.RequirePermission("staff")
|
|
modulesPerm := auth.RequirePermission("modules")
|
|
|
|
api.Get("/auth/me", staffAuth, authHandler.Me)
|
|
api.Post("/files", staffAuth, fileHandler.Upload)
|
|
|
|
api.Post("/staff", staffAuth, staffPerm, staffHandler.Create)
|
|
api.Get("/staff", staffAuth, staffPerm, staffHandler.List)
|
|
api.Get("/staff/assignable", staffAuth, staffHandler.ListAssignable)
|
|
api.Patch("/staff/:id", staffAuth, staffPerm, staffHandler.Update)
|
|
api.Post("/staff/:id/reset-password", staffAuth, staffPerm, staffHandler.ResetPassword)
|
|
api.Get("/roles", staffAuth, rolesHandler.List)
|
|
api.Post("/roles", staffAuth, staffPerm, rolesHandler.Create)
|
|
api.Patch("/roles/:id", staffAuth, staffPerm, rolesHandler.Update)
|
|
api.Delete("/roles/:id", staffAuth, staffPerm, rolesHandler.Delete)
|
|
api.Post("/modules", staffAuth, modulesPerm, registryHandler.Create)
|
|
api.Get("/modules", staffAuth, modulesPerm, registryHandler.List)
|
|
api.Patch("/modules/:name", staffAuth, modulesPerm, registryHandler.Update)
|
|
api.Delete("/modules/:name", staffAuth, modulesPerm, registryHandler.Delete)
|
|
api.Post("/modules/:name/restart", staffAuth, modulesPerm, registryHandler.Restart)
|
|
api.Post("/modules/:name/maintenance", staffAuth, modulesPerm, registryHandler.SetMaintenance)
|
|
|
|
addr := os.Getenv("LISTEN_ADDR")
|
|
if addr == "" {
|
|
addr = ":3000"
|
|
}
|
|
log.Fatal(app.Listen(addr))
|
|
}
|