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,170 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// PluginSummary is the wire shape of a capability/plugin in the admin list.
|
||||
type PluginSummary struct {
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Description string `json:"description"`
|
||||
AllowInGroup bool `json:"allowInGroup"`
|
||||
AllowInStranger bool `json:"allowInStranger"`
|
||||
}
|
||||
|
||||
// ChannelSummary is the wire shape of a registered channel adapter.
|
||||
type ChannelSummary struct {
|
||||
Name string `json:"name"`
|
||||
Connected bool `json:"connected"`
|
||||
}
|
||||
|
||||
// policyRequest is the POST /api/v1/admin/plugins/policy body.
|
||||
type policyRequest struct {
|
||||
Name string `json:"name"`
|
||||
AllowInGroup bool `json:"allowInGroup"`
|
||||
AllowInStranger bool `json:"allowInStranger"`
|
||||
}
|
||||
|
||||
// adminListPersonas lists persona summaries (admin read).
|
||||
func (s *Server) adminListPersonas(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, s.collectPersonas())
|
||||
}
|
||||
|
||||
// adminListSessions lists channel sessions (admin read).
|
||||
func (s *Server) adminListSessions(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, s.collectSessions(r.Context()))
|
||||
}
|
||||
|
||||
// adminListMemories lists memory entries (admin read).
|
||||
func (s *Server) adminListMemories(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, s.collectMemories(r.Context()))
|
||||
}
|
||||
|
||||
// adminListPlugins lists every registered capability (admin read).
|
||||
func (s *Server) adminListPlugins(w http.ResponseWriter, r *http.Request) {
|
||||
out := make([]PluginSummary, 0)
|
||||
if s.caps != nil {
|
||||
for _, cap := range s.caps.List("") {
|
||||
out = append(out, PluginSummary{
|
||||
Name: cap.Name,
|
||||
Kind: string(cap.Kind),
|
||||
Description: cap.Description,
|
||||
AllowInGroup: cap.Policy.AllowInGroup,
|
||||
AllowInStranger: cap.Policy.AllowInStranger,
|
||||
})
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// adminListChannels lists the registered channel adapters (admin read).
|
||||
func (s *Server) adminListChannels(w http.ResponseWriter, r *http.Request) {
|
||||
out := make([]ChannelSummary, 0)
|
||||
if s.caps != nil {
|
||||
for _, cap := range s.caps.List(capreg.KindChannel) {
|
||||
ch, ok := cap.Value.(sdk.Channel)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, ChannelSummary{Name: ch.Name(), Connected: ch.IsConnected()})
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// adminListIoT lists IoT devices (admin read).
|
||||
func (s *Server) adminListIoT(w http.ResponseWriter, r *http.Request) {
|
||||
out := make([]iotDeviceSummary, 0)
|
||||
if s.iot != nil {
|
||||
devices, err := s.iot.List(r.Context())
|
||||
if err != nil {
|
||||
s.log.Error("list iot", "err", err)
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "iot_failed", "failed to list devices"))
|
||||
return
|
||||
}
|
||||
for _, d := range devices {
|
||||
out = append(out, iotDeviceSummary{ID: d.ID, Name: d.Name, Type: d.Type, State: d.State, Online: d.Online})
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// adminSetPolicy upserts the reachability markers for a capability. It is the
|
||||
// audited admin write example (owner-only, enforced by the route registration).
|
||||
func (s *Server) adminSetPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
var req policyRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
|
||||
return
|
||||
}
|
||||
if req.Name == "" {
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "name is required"))
|
||||
return
|
||||
}
|
||||
if s.caps == nil {
|
||||
s.auditClaims(r, "policy.set", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "policy_unavailable", "capability service is not configured"))
|
||||
return
|
||||
}
|
||||
err := s.caps.SetPolicy(req.Name, capreg.ToolPolicy{AllowInGroup: req.AllowInGroup, AllowInStranger: req.AllowInStranger})
|
||||
if err != nil {
|
||||
s.auditClaims(r, "policy.set", "error")
|
||||
if errors.Is(err, capreg.ErrNotFound) {
|
||||
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "capability %q not found", req.Name))
|
||||
return
|
||||
}
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "policy_failed", "failed to set policy"))
|
||||
return
|
||||
}
|
||||
s.auditClaims(r, "policy.set", "ok")
|
||||
writeJSON(w, http.StatusOK, map[string]any{"name": req.Name, "allowInGroup": req.AllowInGroup, "allowInStranger": req.AllowInStranger})
|
||||
}
|
||||
|
||||
// collectSessions returns a non-nil session slice from the configured store.
|
||||
func (s *Server) collectSessions(ctx context.Context) []SessionSummary {
|
||||
out := make([]SessionSummary, 0)
|
||||
if s.sessions == nil {
|
||||
return out
|
||||
}
|
||||
list, err := s.sessions.List(ctx)
|
||||
if err != nil {
|
||||
s.log.Error("list sessions", "err", err)
|
||||
return out
|
||||
}
|
||||
for _, sess := range list {
|
||||
out = append(out, summaryOfSession(sess))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// collectMemories returns a non-nil memory slice from the configured store.
|
||||
func (s *Server) collectMemories(ctx context.Context) []MemorySummary {
|
||||
out := make([]MemorySummary, 0)
|
||||
if s.memories == nil {
|
||||
return out
|
||||
}
|
||||
list, err := s.memories.List(ctx)
|
||||
if err != nil {
|
||||
s.log.Error("list memories", "err", err)
|
||||
return out
|
||||
}
|
||||
out = append(out, list...)
|
||||
return out
|
||||
}
|
||||
|
||||
// iotDeviceSummary is the wire shape of an IoT device.
|
||||
type iotDeviceSummary struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
State map[string]any `json:"state"`
|
||||
Online bool `json:"online"`
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
)
|
||||
|
||||
// auditClaims records an admin write with the caller identity read from the
|
||||
// request context (populated by auth.Authorize). It is the who/what/when/result
|
||||
// audit trail required for admin operations; the "主代理代审" user-facing note is
|
||||
// the frontend's job, not the gateway's.
|
||||
func (s *Server) auditClaims(r *http.Request, what, result string) {
|
||||
who := "anonymous"
|
||||
if claims, ok := auth.ClaimsFromContext(r.Context()); ok {
|
||||
who = claims.Sub + ":" + string(claims.Role)
|
||||
}
|
||||
s.audit(who, what, result)
|
||||
}
|
||||
|
||||
// audit emits one structured audit record on the gateway logger.
|
||||
func (s *Server) audit(who, what, result string) {
|
||||
s.log.Info("admin audit",
|
||||
"who", who,
|
||||
"what", what,
|
||||
"when", time.Now().UTC().Format(time.RFC3339),
|
||||
"result", result,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"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/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
const testSecret = "0123456789abcdef0123456789abcdef"
|
||||
|
||||
// stubRunner is a Chatter whose Run returns a fixed result.
|
||||
type stubRunner struct {
|
||||
res *loop.Result
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *stubRunner) Run(ctx context.Context, sessionID string, messages []model.Message) (*loop.Result, error) {
|
||||
return s.res, s.err
|
||||
}
|
||||
|
||||
// stubTool is a minimal sdk.Tool for capability-registry tests.
|
||||
type stubTool struct{}
|
||||
|
||||
func (stubTool) Name() string { return "echo" }
|
||||
func (stubTool) Description() string { return "echoes input back" }
|
||||
func (stubTool) Parameters() (map[string]any, error) { return nil, nil }
|
||||
func (stubTool) Execute(ctx context.Context, args any) (any, error) { return args, nil }
|
||||
|
||||
// testEnv bundles everything a test needs.
|
||||
type testEnv struct {
|
||||
srv *Server
|
||||
ts *httptest.Server
|
||||
auth *auth.JWTService
|
||||
bus bus.Bus
|
||||
caps *capreg.Registry
|
||||
}
|
||||
|
||||
// newEnv builds a Server backed by a real JWT service and in-process bus, plus a
|
||||
// httptest server. runner may be nil (chat returns 500).
|
||||
func newEnv(t *testing.T, runner Chatter, withCaps bool) *testEnv {
|
||||
t.Helper()
|
||||
jwt := auth.New(config.AuthConfig{JWTSecret: testSecret})
|
||||
b := bus.New()
|
||||
cfg := &config.Config{Auth: config.AuthConfig{AdminUser: "admin", AdminPass: "secret", JWTSecret: testSecret}}
|
||||
deps := Deps{Auth: jwt, Bus: b, Loop: runner}
|
||||
if withCaps {
|
||||
caps := capreg.New()
|
||||
if err := caps.RegisterTool(stubTool{}); err != nil {
|
||||
t.Fatalf("register tool: %v", err)
|
||||
}
|
||||
deps.Registry = caps
|
||||
}
|
||||
srv, err := New(cfg, deps)
|
||||
if err != nil {
|
||||
t.Fatalf("new server: %v", err)
|
||||
}
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
t.Cleanup(ts.Close)
|
||||
env := &testEnv{srv: srv, ts: ts, auth: jwt, bus: b}
|
||||
if withCaps {
|
||||
env.caps = deps.Registry.(*capreg.Registry)
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func (e *testEnv) token(t *testing.T, role auth.Role) string {
|
||||
t.Helper()
|
||||
tok, err := e.auth.Sign("acct", role, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("sign token: %v", err)
|
||||
}
|
||||
return tok
|
||||
}
|
||||
|
||||
// doReq performs an HTTP request and returns the response plus the raw body.
|
||||
func doReq(t *testing.T, method, url string, body any, token string) (*http.Response, []byte) {
|
||||
t.Helper()
|
||||
var rd io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal body: %v", err)
|
||||
}
|
||||
rd = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequest(method, url, rd)
|
||||
if err != nil {
|
||||
t.Fatalf("new request: %v", err)
|
||||
}
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("do request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read response: %v", err)
|
||||
}
|
||||
return resp, bodyBytes
|
||||
}
|
||||
|
||||
// decodeBody decodes a response body into a generic map.
|
||||
func decodeBody(t *testing.T, b []byte) map[string]json.RawMessage {
|
||||
t.Helper()
|
||||
var m map[string]json.RawMessage
|
||||
if err := json.Unmarshal(b, &m); err != nil {
|
||||
t.Fatalf("decode body %s: %v", b, err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// numOrZero returns the numeric value of a JSON number in the data envelope.
|
||||
func wantErrorShape(t *testing.T, b []byte) {
|
||||
t.Helper()
|
||||
env := decodeBody(t, b)
|
||||
var eobj struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(env["error"], &eobj); err != nil {
|
||||
t.Fatalf("error envelope: %v (body=%s)", err, b)
|
||||
}
|
||||
if eobj.Code == "" || eobj.Message == "" {
|
||||
t.Fatalf("error envelope missing code/message: %s", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealth(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newEnv(t, nil, false)
|
||||
resp, body := doReq(t, http.MethodGet, env.ts.URL+"/api/v1/health", nil, "")
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
data := string(decodeBody(t, body)["data"])
|
||||
if !strings.Contains(data, `"ok"`) {
|
||||
t.Fatalf("health data = %s, want ok", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginIssuesJWT(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newEnv(t, nil, false)
|
||||
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/auth/login", map[string]any{"username": "admin", "password": "secret"}, "")
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
var res struct {
|
||||
Token string `json:"token"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if err := json.Unmarshal(decodeBody(t, body)["data"], &res); err != nil {
|
||||
t.Fatalf("decode login data: %v", err)
|
||||
}
|
||||
if res.Token == "" {
|
||||
t.Fatal("token is empty")
|
||||
}
|
||||
if res.Role != "owner" {
|
||||
t.Fatalf("role = %q, want owner", res.Role)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRejectsBadCredentials(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newEnv(t, nil, false)
|
||||
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/auth/login", map[string]any{"username": "admin", "password": "nope"}, "")
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
wantErrorShape(t, body)
|
||||
}
|
||||
|
||||
func TestChatReturnsReply(t *testing.T) {
|
||||
t.Parallel()
|
||||
res := &loop.Result{Text: "hello world", Steps: 1, StopReason: "stop"}
|
||||
env := newEnv(t, &stubRunner{res: res}, false)
|
||||
tok := env.token(t, auth.RoleOwner)
|
||||
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/chat", map[string]any{"sessionId": "s1", "text": "hi"}, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
var resData struct {
|
||||
Reply string `json:"reply"`
|
||||
Steps int `json:"steps"`
|
||||
StopReason string `json:"stopReason"`
|
||||
}
|
||||
if err := json.Unmarshal(decodeBody(t, body)["data"], &resData); err != nil {
|
||||
t.Fatalf("decode chat data: %v", err)
|
||||
}
|
||||
if resData.Reply != "hello world" {
|
||||
t.Fatalf("reply = %q, want hello world", resData.Reply)
|
||||
}
|
||||
if resData.StopReason != "stop" {
|
||||
t.Fatalf("stopReason = %q, want stop", resData.StopReason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatMissingText(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newEnv(t, &stubRunner{}, false)
|
||||
tok := env.token(t, auth.RoleOwner)
|
||||
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/chat", map[string]any{"text": " "}, tok)
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
wantErrorShape(t, body)
|
||||
}
|
||||
|
||||
func TestChatUnavailableWhenNoRunner(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newEnv(t, nil, false)
|
||||
tok := env.token(t, auth.RoleOwner)
|
||||
resp, _ := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/chat", map[string]any{"text": "hi"}, tok)
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSessionsAndPersonasEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newEnv(t, nil, true)
|
||||
tok := env.token(t, auth.RoleOwner)
|
||||
for _, path := range []string{"/api/v1/sessions", "/api/v1/personas", "/api/v1/admin/sessions", "/api/v1/admin/personas", "/api/v1/admin/memories", "/api/v1/admin/iot"} {
|
||||
resp, body := doReq(t, http.MethodGet, env.ts.URL+path, nil, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("%s status = %d, want 200 (body=%s)", path, resp.StatusCode, body)
|
||||
}
|
||||
if got := string(decodeBody(t, body)["data"]); got != "[]" {
|
||||
t.Fatalf("%s data = %s, want []", path, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPluginsFromRegistry(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newEnv(t, nil, true)
|
||||
tok := env.token(t, auth.RoleOwner)
|
||||
resp, body := doReq(t, http.MethodGet, env.ts.URL+"/api/v1/admin/plugins", nil, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
var items []struct {
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
}
|
||||
if err := json.Unmarshal(decodeBody(t, body)["data"], &items); err != nil {
|
||||
t.Fatalf("decode plugins: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, it := range items {
|
||||
if it.Name == "echo" && it.Kind == "tool" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("echo/tool not in plugins: %+v", items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRBACForbiddenAndUnauthorized(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newEnv(t, nil, true)
|
||||
owner := env.token(t, auth.RoleOwner)
|
||||
viewer := env.token(t, auth.RoleViewer)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
url string
|
||||
token string
|
||||
want int
|
||||
}{
|
||||
{name: "chat no token", url: "/api/v1/chat", token: "", want: http.StatusUnauthorized},
|
||||
{name: "chat viewer", url: "/api/v1/chat", token: viewer, want: http.StatusForbidden},
|
||||
{name: "admin no token", url: "/api/v1/admin/plugins", token: "", want: http.StatusUnauthorized},
|
||||
{name: "admin viewer", url: "/api/v1/admin/plugins", token: viewer, want: http.StatusForbidden},
|
||||
{name: "admin owner ok", url: "/api/v1/admin/plugins", token: owner, want: http.StatusOK},
|
||||
{name: "sessions viewer ok", url: "/api/v1/sessions", token: viewer, want: http.StatusOK},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
method := http.MethodGet
|
||||
if tc.url == "/api/v1/chat" {
|
||||
method = http.MethodPost
|
||||
}
|
||||
resp, body := doReq(t, method, env.ts.URL+tc.url, map[string]any{"text": "hi"}, tc.token)
|
||||
if resp.StatusCode != tc.want {
|
||||
t.Fatalf("status = %d, want %d (body=%s)", resp.StatusCode, tc.want, body)
|
||||
}
|
||||
if tc.want == http.StatusUnauthorized || tc.want == http.StatusForbidden {
|
||||
wantErrorShape(t, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSetPolicyAndAudit(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newEnv(t, nil, true)
|
||||
owner := env.token(t, auth.RoleOwner)
|
||||
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/policy", map[string]any{"name": "echo", "allowInGroup": true}, owner)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
data := string(decodeBody(t, body)["data"])
|
||||
if !strings.Contains(data, `"allowInGroup":true`) {
|
||||
t.Fatalf("policy data = %s, want allowInGroup true", data)
|
||||
}
|
||||
|
||||
// Unknown capability → 404.
|
||||
resp2, _ := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/policy", map[string]any{"name": "nope"}, owner)
|
||||
if resp2.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", resp2.StatusCode)
|
||||
}
|
||||
|
||||
// Viewer is forbidden from the write.
|
||||
viewer := env.token(t, auth.RoleViewer)
|
||||
resp3, _ := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/policy", map[string]any{"name": "echo"}, viewer)
|
||||
if resp3.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("viewer status = %d, want 403", resp3.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWSUpgradeRejectsNoToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newEnv(t, nil, false)
|
||||
wsURL := "ws" + strings.TrimPrefix(env.ts.URL, "http") + "/ws/chat"
|
||||
_, resp, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
if err == nil {
|
||||
t.Fatal("dial without token should fail")
|
||||
}
|
||||
if resp == nil || resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWSUpgradeAndBroadcast(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newEnv(t, nil, false)
|
||||
tok := env.token(t, auth.RoleOwner)
|
||||
wsURL := "ws" + strings.TrimPrefix(env.ts.URL, "http") + "/ws/chat?token=" + tok
|
||||
|
||||
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetReadDeadline(time.Now().Add(3 * time.Second))
|
||||
|
||||
// Round-trip a mode_switch first: the relay re-broadcasts it, which
|
||||
// guarantees the connection is registered before we trigger a broadcast.
|
||||
if err := conn.WriteJSON(wsInbound{Type: "mode_switch", Data: json.RawMessage(`{"mode":"chat"}`)}); err != nil {
|
||||
t.Fatalf("write mode_switch: %v", err)
|
||||
}
|
||||
if got := readWS(t, conn); got.Type != "mode_switch" {
|
||||
t.Fatalf("first frame type = %q, want mode_switch (frame=%+v)", got.Type, got)
|
||||
}
|
||||
|
||||
// Publish an outbound event; the relay must forward it to the client.
|
||||
if err := env.bus.Publish(context.Background(), "ws.thinking", map[string]any{"text": "hmm"}); err != nil {
|
||||
t.Fatalf("publish: %v", err)
|
||||
}
|
||||
if got := readWS(t, conn); got.Type != "thinking" {
|
||||
t.Fatalf("broadcast type = %q, want thinking (frame=%+v)", got.Type, got)
|
||||
}
|
||||
}
|
||||
|
||||
// readWS reads one text frame and decodes it into a wsEvent.
|
||||
func readWS(t *testing.T, conn *websocket.Conn) wsEvent {
|
||||
t.Helper()
|
||||
_, msg, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
t.Fatalf("read ws: %v", err)
|
||||
}
|
||||
var ev wsEvent
|
||||
if err := json.Unmarshal(msg, &ev); err != nil {
|
||||
t.Fatalf("decode ws frame %s: %v", msg, err)
|
||||
}
|
||||
return ev
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
|
||||
)
|
||||
|
||||
// cors sets the Access-Control-* headers for origins in the configured allow
|
||||
// list and short-circuits OPTIONS preflight requests. An empty origin (same-origin
|
||||
// or non-browser client) is passed through untouched.
|
||||
func (s *Server) cors(next http.Handler) http.Handler {
|
||||
allowed, allowAll := s.allowedOrigins()
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if origin := r.Header.Get("Origin"); origin != "" && (allowAll || allowed[origin]) {
|
||||
if allowAll {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
} else {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
}
|
||||
w.Header().Set("Vary", "Origin")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
||||
w.Header().Set("Access-Control-Max-Age", "600")
|
||||
}
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// allowedOrigins resolves the configured origin list into a lookup set plus an
|
||||
// allow-all flag for the "*" wildcard.
|
||||
func (s *Server) allowedOrigins() (map[string]bool, bool) {
|
||||
list := s.cfg.AllowedOrigins
|
||||
if len(list) == 0 {
|
||||
return map[string]bool{}, false
|
||||
}
|
||||
allowAll := false
|
||||
out := make(map[string]bool, len(list))
|
||||
for _, o := range list {
|
||||
if o == "*" {
|
||||
allowAll = true
|
||||
continue
|
||||
}
|
||||
out[o] = true
|
||||
}
|
||||
return out, allowAll
|
||||
}
|
||||
|
||||
// recover converts a panic in a downstream handler into a 500 {error:...}
|
||||
// response instead of crashing the process, and logs the panic.
|
||||
func (s *Server) recover(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
s.log.Error("panic recovered", "panic", rec, "method", r.Method, "path", r.URL.Path)
|
||||
writeError(w, apperr.ErrInternal)
|
||||
}
|
||||
}()
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// logging records one structured line per request (method, path, status, dur).
|
||||
func (s *Server) logging(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
|
||||
next.ServeHTTP(rec, r)
|
||||
s.log.Info("request",
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"status", rec.status,
|
||||
"duration_ms", time.Since(start).Milliseconds(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// statusRecorder captures the written status so the logging middleware can report
|
||||
// it. It forwards the optional Hijacker/Flusher interfaces so the WebSocket
|
||||
// upgrader (which requires http.Hijacker) and streaming handlers still work.
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
// WriteHeader records the status before delegating.
|
||||
func (r *statusRecorder) WriteHeader(code int) {
|
||||
r.status = code
|
||||
r.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
// Hijack forwards to the underlying writer when it supports connection hijacking.
|
||||
func (r *statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
if hj, ok := r.ResponseWriter.(http.Hijacker); ok {
|
||||
return hj.Hijack()
|
||||
}
|
||||
return nil, nil, fmt.Errorf("gateway: hijack unsupported by %T", r.ResponseWriter)
|
||||
}
|
||||
|
||||
// Flush forwards to the underlying writer when it supports flushing.
|
||||
func (r *statusRecorder) Flush() {
|
||||
if f, ok := r.ResponseWriter.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
|
||||
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// tokenTTL is how long an issued JWT remains valid.
|
||||
const tokenTTL = 24 * time.Hour
|
||||
|
||||
// PersonaSummary is the wire shape of a persona in REST/admin list responses.
|
||||
type PersonaSummary struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Character string `json:"character"`
|
||||
IsDefault bool `json:"isDefault"`
|
||||
Mode string `json:"mode"`
|
||||
MemoryNS string `json:"memoryNamespace"`
|
||||
}
|
||||
|
||||
// MemorySummary is the wire shape of a memory entry in the admin list response.
|
||||
type MemorySummary struct {
|
||||
ID string `json:"id"`
|
||||
Scope string `json:"scope"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// SessionSummary is the wire shape of a channel session in list responses.
|
||||
type SessionSummary struct {
|
||||
Platform string `json:"platform"`
|
||||
ChannelID string `json:"channelId"`
|
||||
SessionID string `json:"sessionId"`
|
||||
ActorRole string `json:"actorRole"`
|
||||
Owner bool `json:"owner"`
|
||||
IsGroup bool `json:"isGroup"`
|
||||
MemWrite bool `json:"memWritable"`
|
||||
}
|
||||
|
||||
// loginRequest is the POST /api/v1/auth/login body.
|
||||
type loginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// loginResponse is the login success payload.
|
||||
type loginResponse struct {
|
||||
Token string `json:"token"`
|
||||
ExpiresAt int64 `json:"expiresAt"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
// chatRequest is the POST /api/v1/chat body. When Stream is true the gateway
|
||||
// triggers streaming over the WebSocket channel and returns immediately.
|
||||
type chatRequest struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
Text string `json:"text"`
|
||||
Stream bool `json:"stream"`
|
||||
}
|
||||
|
||||
// chatResponse is the synchronous chat reply payload.
|
||||
type chatResponse struct {
|
||||
Reply string `json:"reply"`
|
||||
Steps int `json:"steps"`
|
||||
StopReason string `json:"stopReason"`
|
||||
}
|
||||
|
||||
// handleHealth reports liveness. It is unauthenticated because it is a probe.
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"status": "ok"})
|
||||
}
|
||||
|
||||
// handleLogin exchanges the admin username/password for a signed JWT.
|
||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
var req loginRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
|
||||
return
|
||||
}
|
||||
cfg := s.cfg.Auth
|
||||
if req.Username != cfg.AdminUser || req.Password != cfg.AdminPass {
|
||||
writeError(w, apperr.Wrap(apperr.ErrUnauthorized, "unauthorized", "invalid username or password"))
|
||||
return
|
||||
}
|
||||
token, err := s.auth.Sign(req.Username, auth.RoleOwner, tokenTTL)
|
||||
if err != nil {
|
||||
s.log.Error("login sign", "err", err)
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "login_failed", "could not sign token"))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, loginResponse{
|
||||
Token: token,
|
||||
ExpiresAt: time.Now().Add(tokenTTL).Unix(),
|
||||
Role: string(auth.RoleOwner),
|
||||
})
|
||||
}
|
||||
|
||||
// handleChat runs one synchronous agent turn (or triggers streaming).
|
||||
func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
|
||||
var req chatRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Text) == "" {
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "text is required"))
|
||||
return
|
||||
}
|
||||
if req.Stream {
|
||||
s.publishChatStream(r.Context(), req.SessionID, req.Text)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"stream": true})
|
||||
return
|
||||
}
|
||||
if s.runner == nil {
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "chat_unavailable", "chat service is not configured"))
|
||||
return
|
||||
}
|
||||
if req.SessionID == "" {
|
||||
req.SessionID = "web"
|
||||
}
|
||||
res, err := s.runner.Run(r.Context(), req.SessionID, []model.Message{model.TextMessage(model.RoleUser, req.Text)})
|
||||
if err != nil {
|
||||
s.log.Error("chat run", "err", err, "session", req.SessionID)
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "chat_failed", "chat failed"))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, chatResponse{Reply: res.Text, Steps: res.Steps, StopReason: res.StopReason})
|
||||
}
|
||||
|
||||
// handleListSessions returns the known sessions (or [] when no store is wired).
|
||||
func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) {
|
||||
out := make([]SessionSummary, 0)
|
||||
if s.sessions != nil {
|
||||
list, err := s.sessions.List(r.Context())
|
||||
if err != nil {
|
||||
s.log.Error("list sessions", "err", err)
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "sessions_failed", "failed to list sessions"))
|
||||
return
|
||||
}
|
||||
for _, sess := range list {
|
||||
out = append(out, summaryOfSession(sess))
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// handleListPersonas returns the personas (or [] when no store is wired).
|
||||
func (s *Server) handleListPersonas(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, s.collectPersonas())
|
||||
}
|
||||
|
||||
// collectPersonas returns a non-nil persona slice from the configured store.
|
||||
func (s *Server) collectPersonas() []PersonaSummary {
|
||||
out := make([]PersonaSummary, 0)
|
||||
if s.personas != nil {
|
||||
out = append(out, s.personas.List()...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// summaryOfSession maps a session.Session to its wire shape.
|
||||
func summaryOfSession(sess session.Session) SessionSummary {
|
||||
return SessionSummary{
|
||||
Platform: sess.Platform,
|
||||
ChannelID: sess.ChannelID,
|
||||
SessionID: sess.SessionID,
|
||||
ActorRole: string(sess.ActorRole),
|
||||
Owner: sess.Owner,
|
||||
IsGroup: sess.IsGroup,
|
||||
MemWrite: sess.MemWritable,
|
||||
}
|
||||
}
|
||||
|
||||
// writeJSON encodes v as the {data: v} envelope with the given status.
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"data": v})
|
||||
}
|
||||
|
||||
// writeError encodes err as the {error:{code,message}} envelope with the status
|
||||
// resolved by apperr.HTTPStatus.
|
||||
func writeError(w http.ResponseWriter, err error) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(apperr.HTTPStatus(err))
|
||||
_ = json.NewEncoder(w).Encode(apperr.ToJSON(err))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
|
||||
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// WebSocket tuning constants. The ping/pong cadence keeps intermediary proxies
|
||||
// from dropping idle connections.
|
||||
const (
|
||||
wsEventsPattern = "ws.>"
|
||||
wsPrefix = "ws."
|
||||
wsChatSubject = "ws.chat"
|
||||
wsReadLimit = 4096
|
||||
wsWriteWait = 10 * time.Second
|
||||
wsPongWait = 60 * time.Second
|
||||
wsPingPeriod = 54 * time.Second
|
||||
wsSendBuffer = 32
|
||||
)
|
||||
|
||||
// wsEvent is the outbound (server→client) frame.
|
||||
type wsEvent struct {
|
||||
Type string `json:"type"`
|
||||
Data any `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// wsInbound is the client→server frame. Data carries the per-type payload.
|
||||
type wsInbound struct {
|
||||
Type string `json:"type"`
|
||||
ReqID string `json:"reqId,omitempty"`
|
||||
Data json.RawMessage `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// wsConn wraps one WebSocket connection. Writes are serialized through the
|
||||
// buffered send channel drained by a single writePump, so the gorilla conn is
|
||||
// only ever touched by one goroutine at a time.
|
||||
type wsConn struct {
|
||||
conn *websocket.Conn
|
||||
send chan []byte
|
||||
done chan struct{}
|
||||
h *hub
|
||||
claims *auth.Claims
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
// hub owns the connection set and relays bus events to every client.
|
||||
type hub struct {
|
||||
mu sync.RWMutex
|
||||
conns map[*wsConn]struct{}
|
||||
bus bus.Bus
|
||||
runner Chatter
|
||||
log *slog.Logger
|
||||
upgrader websocket.Upgrader
|
||||
origins map[string]bool
|
||||
allowAll bool
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// newHub builds a hub bound to the server's dependencies.
|
||||
func newHub(s *Server) *hub {
|
||||
origins, allowAll := s.allowedOrigins()
|
||||
return &hub{
|
||||
conns: make(map[*wsConn]struct{}),
|
||||
bus: s.bus,
|
||||
runner: s.runner,
|
||||
log: s.log,
|
||||
origins: origins,
|
||||
allowAll: allowAll,
|
||||
upgrader: websocket.Upgrader{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
CheckOrigin: originChecker(origins, allowAll),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// start subscribes the hub to the bus event stream. Publishing to ws.> arrives
|
||||
// here and is fanned out to clients; nothing happens when no bus is wired.
|
||||
func (h *hub) start() {
|
||||
if h.bus == nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
h.cancel = cancel
|
||||
if _, err := h.bus.Subscribe(ctx, wsEventsPattern, h.relay); err != nil {
|
||||
h.log.Warn("bus subscribe", "pattern", wsEventsPattern, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// close cancels the bus subscription and shuts every live connection down.
|
||||
func (h *hub) close() {
|
||||
if h.cancel != nil {
|
||||
h.cancel()
|
||||
}
|
||||
h.mu.Lock()
|
||||
conns := make([]*wsConn, 0, len(h.conns))
|
||||
for c := range h.conns {
|
||||
conns = append(conns, c)
|
||||
}
|
||||
h.conns = make(map[*wsConn]struct{})
|
||||
h.mu.Unlock()
|
||||
for _, c := range conns {
|
||||
c.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
// register adds a connection to the set.
|
||||
func (h *hub) register(c *wsConn) {
|
||||
h.mu.Lock()
|
||||
h.conns[c] = struct{}{}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// unregister removes a connection from the set.
|
||||
func (h *hub) unregister(c *wsConn) {
|
||||
h.mu.Lock()
|
||||
if _, ok := h.conns[c]; ok {
|
||||
delete(h.conns, c)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// connCount reports the number of live connections (observability/tests).
|
||||
func (h *hub) connCount() int {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
return len(h.conns)
|
||||
}
|
||||
|
||||
// broadcast fans an event out to every registered connection without blocking on
|
||||
// any single (possibly slow) client: a full send buffer drops the frame.
|
||||
func (h *hub) broadcast(ev wsEvent) {
|
||||
b, err := json.Marshal(ev)
|
||||
if err != nil {
|
||||
h.log.Warn("marshal broadcast", "err", err)
|
||||
return
|
||||
}
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
for c := range h.conns {
|
||||
c.enqueue(b)
|
||||
}
|
||||
}
|
||||
|
||||
// relay is the bus handler for the ws.> pattern. The ws.chat subject is a
|
||||
// streaming trigger (run the loop and emit chunks); every other subject becomes
|
||||
// an outbound event of its design type.
|
||||
func (h *hub) relay(ctx context.Context, e bus.Event) error {
|
||||
if e.Subject == wsChatSubject {
|
||||
go h.streamChat(ctx, e.Payload)
|
||||
return nil
|
||||
}
|
||||
h.broadcast(wsEvent{Type: eventType(e.Subject), Data: e.Payload})
|
||||
return nil
|
||||
}
|
||||
|
||||
// streamChat runs one agent turn for a chat trigger and broadcasts the result as
|
||||
// stream_chunk/stream_end. It runs on its own goroutine so a slow model call does
|
||||
// not stall the bus subscription worker.
|
||||
func (h *hub) streamChat(ctx context.Context, payload any) {
|
||||
var req chatStreamRequest
|
||||
if b, err := json.Marshal(payload); err == nil {
|
||||
_ = json.Unmarshal(b, &req)
|
||||
}
|
||||
if h.runner == nil || strings.TrimSpace(req.Text) == "" {
|
||||
h.broadcast(wsEvent{Type: "stream_end", Data: map[string]any{"stopReason": "idle"}})
|
||||
return
|
||||
}
|
||||
if req.SessionID == "" {
|
||||
req.SessionID = "web"
|
||||
}
|
||||
res, err := h.runner.Run(ctx, req.SessionID, []model.Message{model.TextMessage(model.RoleUser, req.Text)})
|
||||
if err != nil {
|
||||
h.log.Error("ws chat run", "err", err, "session", req.SessionID)
|
||||
h.broadcast(wsEvent{Type: "stream_end", Data: map[string]any{"stopReason": "error", "error": err.Error()}})
|
||||
return
|
||||
}
|
||||
h.broadcast(wsEvent{Type: "stream_chunk", Data: map[string]any{"delta": res.Text}})
|
||||
h.broadcast(wsEvent{Type: "stream_end", Data: map[string]any{"stopReason": res.StopReason, "steps": res.Steps}})
|
||||
}
|
||||
|
||||
// chatStreamRequest is the decoded ws.chat payload.
|
||||
type chatStreamRequest struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// handleWS upgrades /ws/chat after authenticating the JWT from the query token or
|
||||
// the Authorization header. A failed upgrade writes a 401 {error:...} before the
|
||||
// handshake, so a browser/IM client sees a clean reject.
|
||||
func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "websocket requires GET"))
|
||||
return
|
||||
}
|
||||
claims, err := s.auth.Verify(wsToken(r))
|
||||
if err != nil {
|
||||
s.log.Debug("ws auth", "err", err)
|
||||
writeError(w, apperr.ErrUnauthorized)
|
||||
return
|
||||
}
|
||||
conn, err := s.hub.upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
s.log.Debug("ws upgrade", "err", err)
|
||||
return
|
||||
}
|
||||
c := &wsConn{
|
||||
conn: conn,
|
||||
send: make(chan []byte, wsSendBuffer),
|
||||
done: make(chan struct{}),
|
||||
h: s.hub,
|
||||
claims: claims,
|
||||
}
|
||||
s.hub.register(c)
|
||||
go c.writePump()
|
||||
go c.readPump()
|
||||
}
|
||||
|
||||
// readPump reads client frames until the connection closes, routing each by type.
|
||||
func (c *wsConn) readPump() {
|
||||
defer func() {
|
||||
c.h.unregister(c)
|
||||
c.shutdown()
|
||||
}()
|
||||
c.conn.SetReadLimit(wsReadLimit)
|
||||
_ = c.conn.SetReadDeadline(time.Now().Add(wsPongWait))
|
||||
c.conn.SetPongHandler(func(string) error {
|
||||
return c.conn.SetReadDeadline(time.Now().Add(wsPongWait))
|
||||
})
|
||||
for {
|
||||
_, msg, err := c.conn.ReadMessage()
|
||||
if err != nil {
|
||||
if websocket.IsUnexpectedCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
|
||||
c.h.log.Debug("ws read", "err", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
var in wsInbound
|
||||
if err := json.Unmarshal(msg, &in); err != nil {
|
||||
c.enqueueJSON(wsEvent{Type: "error", Data: map[string]any{"message": "invalid json"}})
|
||||
continue
|
||||
}
|
||||
c.route(in)
|
||||
}
|
||||
}
|
||||
|
||||
// writePump drains the send channel (and sends keepalive pings) until the
|
||||
// connection closes. It is the only writer on the gorilla conn.
|
||||
func (c *wsConn) writePump() {
|
||||
ticker := time.NewTicker(wsPingPeriod)
|
||||
defer func() {
|
||||
ticker.Stop()
|
||||
_ = c.conn.Close()
|
||||
}()
|
||||
for {
|
||||
select {
|
||||
case <-c.done:
|
||||
return
|
||||
case msg := <-c.send:
|
||||
_ = c.conn.SetWriteDeadline(time.Now().Add(wsWriteWait))
|
||||
if err := c.conn.WriteMessage(websocket.TextMessage, msg); err != nil {
|
||||
return
|
||||
}
|
||||
case <-ticker.C:
|
||||
_ = c.conn.SetWriteDeadline(time.Now().Add(wsWriteWait))
|
||||
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// route dispatches one inbound frame by its type.
|
||||
func (c *wsConn) route(in wsInbound) {
|
||||
switch in.Type {
|
||||
case "mode_switch":
|
||||
c.publishPublic("ws.mode_switch", rawData(in.Data))
|
||||
case "chat":
|
||||
c.publishPublic(wsChatSubject, rawData(in.Data))
|
||||
case "subagent_ask":
|
||||
c.enqueueJSON(wsEvent{Type: "subagent_event", Data: map[string]any{"state": "pending"}})
|
||||
case "task_output_query":
|
||||
c.enqueueJSON(wsEvent{Type: "task_output", Data: map[string]any{}})
|
||||
default:
|
||||
c.enqueueJSON(wsEvent{Type: "error", Data: map[string]any{"message": "unknown event type"}})
|
||||
}
|
||||
}
|
||||
|
||||
// publishPublic publishes a bus event without any per-connection reply.
|
||||
func (c *wsConn) publishPublic(subject string, payload any) {
|
||||
if c.h.bus == nil {
|
||||
return
|
||||
}
|
||||
_ = c.h.bus.Publish(context.Background(), subject, payload)
|
||||
}
|
||||
|
||||
// shutdown closes the connection exactly once.
|
||||
func (c *wsConn) shutdown() {
|
||||
c.once.Do(func() {
|
||||
close(c.done)
|
||||
_ = c.conn.Close()
|
||||
})
|
||||
}
|
||||
|
||||
// enqueue queues a serialized frame, dropping it when a slow client's buffer is
|
||||
// full so the producer never blocks.
|
||||
func (c *wsConn) enqueue(b []byte) {
|
||||
select {
|
||||
case c.send <- b:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// enqueueJSON serializes ev and queues it.
|
||||
func (c *wsConn) enqueueJSON(ev wsEvent) {
|
||||
b, err := json.Marshal(ev)
|
||||
if err != nil {
|
||||
c.h.log.Warn("marshal ws event", "err", err)
|
||||
return
|
||||
}
|
||||
c.enqueue(b)
|
||||
}
|
||||
|
||||
// publishChatStream publishes a ws.chat streaming trigger (REST stream mode).
|
||||
func (s *Server) publishChatStream(ctx context.Context, sessionID, text string) {
|
||||
if s.bus == nil {
|
||||
return
|
||||
}
|
||||
_ = s.bus.Publish(ctx, wsChatSubject, map[string]any{"sessionId": sessionID, "text": text})
|
||||
}
|
||||
|
||||
// wsToken extracts the JWT from the ?token= query or the Authorization header.
|
||||
func wsToken(r *http.Request) string {
|
||||
if t := r.URL.Query().Get("token"); t != "" {
|
||||
return t
|
||||
}
|
||||
const prefix = "Bearer "
|
||||
h := r.Header.Get("Authorization")
|
||||
if strings.HasPrefix(h, prefix) {
|
||||
return strings.TrimSpace(h[len(prefix):])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// eventType maps a bus subject to its outbound event type, dropping the ws. prefix.
|
||||
func eventType(subject string) string {
|
||||
return strings.TrimPrefix(subject, wsPrefix)
|
||||
}
|
||||
|
||||
// rawData converts a possibly-empty raw payload into a value safe to publish.
|
||||
func rawData(d json.RawMessage) any {
|
||||
if len(d) == 0 {
|
||||
return nil
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// originChecker allows same-origin (empty) and whitelisted WebSocket origins,
|
||||
// mirroring the CORS allow list. A "*" default allows all.
|
||||
func originChecker(allowed map[string]bool, allowAll bool) func(*http.Request) bool {
|
||||
return func(r *http.Request) bool {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin == "" {
|
||||
return true
|
||||
}
|
||||
if allowAll {
|
||||
return true
|
||||
}
|
||||
return allowed[origin]
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user