feat(gateway): 网关(REST /api/v1 + WS Hub /ws/chat + admin /api/v1/admin/* + JWT/RBAC + 审计,单端口 :8080)
This commit is contained in:
1 parent
ff34b62079
commit
05e23ea590
7 files changed
+1535
No files matched your search
@@ -0,0 +1,247 @@
|
||||
// Package gateway implements the Cyrene v2 HTTP gateway: the only socket the
|
||||
// single binary opens. It serves the versioned REST API (/api/v1), the realtime
|
||||
// WebSocket hub (/ws/chat) and the RBAC-protected admin API (/api/v1/admin/*)
|
||||
// all on one port (:8080).
|
||||
//
|
||||
// It is deliberately lean. All backend capability flows through injected
|
||||
// dependencies (construction-time dependency injection) and the in-process bus,
|
||||
// never through cross-process IPC, so the package never opens a listener of its
|
||||
// own beyond the gateway port.
|
||||
//
|
||||
// Client contract (DevDocs/02-development-constraints.md §九, §十一):
|
||||
// - REST responses are {data} on success and {error:{code,message}} on error.
|
||||
// - Authentication is JWT bearer (internal/auth); authorization is the
|
||||
// owner>admin>viewer RBAC hierarchy (internal/auth).
|
||||
// - Admin writes are audited (who/what/when/result).
|
||||
// - Realtime events follow DevDocs/design/07 §3 (WS protocol).
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
|
||||
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/iot"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// source is the log provenance tag for every record this package emits.
|
||||
const source = "gateway"
|
||||
|
||||
// Authenticator abstracts the credential + RBAC service. *auth.JWTService is the
|
||||
// production implementation; it is an interface so tests substitute a mock.
|
||||
type Authenticator interface {
|
||||
// Sign issues a token for sub with the given role and lifetime.
|
||||
Sign(sub string, role auth.Role, ttl time.Duration) (string, error)
|
||||
// Verify validates a token and returns its claims.
|
||||
Verify(token string) (*auth.Claims, error)
|
||||
// Authorize returns an HTTP middleware enforcing at least one required role.
|
||||
Authorize(required ...auth.Role) func(http.Handler) http.Handler
|
||||
}
|
||||
|
||||
// CapabilityStore abstracts the global capability registry used to enumerate
|
||||
// plugins and channels in the admin API. *capability.Registry is the production
|
||||
// implementation.
|
||||
type CapabilityStore interface {
|
||||
// List returns every capability whose Kind matches, or all when kind is
|
||||
// empty.
|
||||
List(kind capreg.Kind) []capreg.Capability
|
||||
// SetPolicy replaces the reachability markers for the named capability.
|
||||
SetPolicy(name string, p capreg.ToolPolicy) error
|
||||
}
|
||||
|
||||
// Chatter runs a single agent turn. *loop.Loop is the production implementation.
|
||||
type Chatter interface {
|
||||
// Run drives one turn for sessionID and returns the final answer.
|
||||
Run(ctx context.Context, sessionID string, messages []model.Message) (*loop.Result, error)
|
||||
}
|
||||
|
||||
// SessionStore lists known channel sessions. The concrete session manager does
|
||||
// not yet expose a list method, so production wiring may leave Sessions nil and
|
||||
// the handlers return [].
|
||||
type SessionStore interface {
|
||||
List(ctx context.Context) ([]session.Session, error)
|
||||
}
|
||||
|
||||
// PersonaStore lists persona summaries. The concrete persona registry does not
|
||||
// yet expose a list method, so production wiring may leave Personas nil.
|
||||
type PersonaStore interface {
|
||||
List() []PersonaSummary
|
||||
}
|
||||
|
||||
// MemoryStore lists memory entries. The concrete memory store does not yet
|
||||
// expose a list method, so production wiring may leave Memories nil.
|
||||
type MemoryStore interface {
|
||||
List(ctx context.Context) ([]MemorySummary, error)
|
||||
}
|
||||
|
||||
// IoTStore lists IoT devices. *iot.Manager is the production implementation.
|
||||
type IoTStore interface {
|
||||
List(ctx context.Context) ([]iot.Device, error)
|
||||
}
|
||||
|
||||
// Deps carries the gateway's injected dependencies. Every field is nil-tolerant
|
||||
// except Auth, which is required (authentication backs login and every RBAC
|
||||
// decision). Registry/Loop are satisfied by the concrete *capability.Registry and
|
||||
// *loop.Loop; Sessions/Personas/Memories remain nil until their managers expose a
|
||||
// list method, in which case the handlers return [].
|
||||
type Deps struct {
|
||||
// Log is the base logger. Nil uses a discard logger.
|
||||
Log *slog.Logger
|
||||
// Auth is the JWT/RBAC service. Required.
|
||||
Auth Authenticator
|
||||
// Bus is the in-process event bus the WS hub subscribes to. Optional.
|
||||
Bus bus.Bus
|
||||
// Registry enumerates capabilities/plugins/channels. Optional.
|
||||
Registry CapabilityStore
|
||||
// Loop runs chat turns. Optional (chat returns an error when unset).
|
||||
Loop Chatter
|
||||
// Session lists channel sessions. Optional.
|
||||
Session SessionStore
|
||||
// Personas lists personas. Optional.
|
||||
Personas PersonaStore
|
||||
// Memories lists memory entries. Optional.
|
||||
Memories MemoryStore
|
||||
// IoT lists IoT devices. Optional.
|
||||
IoT IoTStore
|
||||
}
|
||||
|
||||
// Server is the gateway. It owns a ServeMux aggregating REST + WS + admin and a
|
||||
// WebSocket hub. It is safe for concurrent use once constructed.
|
||||
type Server struct {
|
||||
cfg *config.Config
|
||||
auth Authenticator
|
||||
bus bus.Bus
|
||||
caps CapabilityStore
|
||||
runner Chatter
|
||||
sessions SessionStore
|
||||
personas PersonaStore
|
||||
memories MemoryStore
|
||||
iot IoTStore
|
||||
log *slog.Logger
|
||||
hub *hub
|
||||
}
|
||||
|
||||
// New validates cfg and deps and returns a ready Server. Auth is required; the
|
||||
// WebSocket hub subscribes to deps.Bus immediately so events published before
|
||||
// ListenAndServe are still relayed to clients.
|
||||
func New(cfg *config.Config, deps Deps) (*Server, error) {
|
||||
if cfg == nil {
|
||||
return nil, fmt.Errorf("gateway: nil config")
|
||||
}
|
||||
if deps.Auth == nil {
|
||||
return nil, fmt.Errorf("gateway: auth dependency required")
|
||||
}
|
||||
s := &Server{
|
||||
cfg: cfg,
|
||||
auth: deps.Auth,
|
||||
bus: deps.Bus,
|
||||
caps: deps.Registry,
|
||||
runner: deps.Loop,
|
||||
sessions: deps.Session,
|
||||
personas: deps.Personas,
|
||||
memories: deps.Memories,
|
||||
iot: deps.IoT,
|
||||
log: sourcedLog(deps.Log),
|
||||
}
|
||||
s.hub = newHub(s)
|
||||
s.hub.start()
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Handler returns the fully assembled http.Handler: the ServeMux routing
|
||||
// REST + WS + admin, wrapped in recovery, CORS and request logging.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Public, no auth.
|
||||
mux.HandleFunc("GET /api/v1/health", s.handleHealth)
|
||||
mux.HandleFunc("POST /api/v1/auth/login", s.handleLogin)
|
||||
|
||||
// User side, authenticated. Chat is a write (owner/admin); reads accept any
|
||||
// authenticated role so a viewer can still list personas/sessions.
|
||||
mux.Handle("POST /api/v1/chat", s.auth.Authorize(auth.RoleOwner, auth.RoleAdmin)(http.HandlerFunc(s.handleChat)))
|
||||
readAuth := s.auth.Authorize()
|
||||
mux.Handle("GET /api/v1/sessions", readAuth(http.HandlerFunc(s.handleListSessions)))
|
||||
mux.Handle("GET /api/v1/personas", readAuth(http.HandlerFunc(s.handleListPersonas)))
|
||||
|
||||
// Realtime.
|
||||
mux.HandleFunc("/ws/chat", s.handleWS)
|
||||
|
||||
// Admin: reads require owner or admin; the audited policy write requires owner.
|
||||
adminAuth := s.auth.Authorize(auth.RoleOwner, auth.RoleAdmin)
|
||||
mux.Handle("GET /api/v1/admin/personas", adminAuth(http.HandlerFunc(s.adminListPersonas)))
|
||||
mux.Handle("GET /api/v1/admin/sessions", adminAuth(http.HandlerFunc(s.adminListSessions)))
|
||||
mux.Handle("GET /api/v1/admin/memories", adminAuth(http.HandlerFunc(s.adminListMemories)))
|
||||
mux.Handle("GET /api/v1/admin/plugins", adminAuth(http.HandlerFunc(s.adminListPlugins)))
|
||||
mux.Handle("GET /api/v1/admin/channels", adminAuth(http.HandlerFunc(s.adminListChannels)))
|
||||
mux.Handle("GET /api/v1/admin/iot", adminAuth(http.HandlerFunc(s.adminListIoT)))
|
||||
mux.Handle("POST /api/v1/admin/plugins/policy", s.auth.Authorize(auth.RoleOwner)(http.HandlerFunc(s.adminSetPolicy)))
|
||||
|
||||
var h http.Handler = mux
|
||||
h = s.recover(h)
|
||||
h = s.cors(h)
|
||||
h = s.logging(h)
|
||||
return h
|
||||
}
|
||||
|
||||
// Start launches the gateway in a goroutine, returning a channel that carries the
|
||||
// ListenAndServe result. The server runs until ctx is canceled.
|
||||
func (s *Server) Start(ctx context.Context) (<-chan error, error) {
|
||||
errCh := make(chan error, 1)
|
||||
go func() { errCh <- s.ListenAndServe(ctx) }()
|
||||
return errCh, nil
|
||||
}
|
||||
|
||||
// ListenAndServe binds the configured host:port (default :8080) and blocks until
|
||||
// ctx is canceled, then gracefully drains in-flight requests.
|
||||
func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
addr := net.JoinHostPort(s.cfg.Server.Host, strconv.Itoa(s.cfg.Server.Port))
|
||||
httpSrv := &http.Server{Addr: addr, Handler: s.Handler()}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
s.log.Info("gateway shutting down", "addr", addr)
|
||||
s.hub.close()
|
||||
shCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
_ = httpSrv.Shutdown(shCtx)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
s.log.Info("gateway listening", "addr", addr)
|
||||
err := httpSrv.ListenAndServe()
|
||||
if err != nil && err != http.ErrServerClosed {
|
||||
return fmt.Errorf("gateway: listen: %w", err)
|
||||
}
|
||||
<-done
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close frees the hub's bus subscription and closes live connections.
|
||||
func (s *Server) Close() { s.hub.close() }
|
||||
|
||||
// sourcedLog returns a logger tagged with source "gateway", substituting a
|
||||
// discard logger for nil so Server can always log safely.
|
||||
func sourcedLog(l *slog.Logger) *slog.Logger {
|
||||
if l == nil {
|
||||
l = slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
return logger.WithSource(l, source)
|
||||
}
|
||||
Reference in new issue
Block a user