Files
LinkPort/internal/api/server.go
T
曾志威 5b6cb3e2bf feat: 完善多账号网关、运维监控与配置管理
新增 HTTP/HTTPS 与 SQL Server 支持,完善账号映射、凭据生命周期、角色和 API Token 隔离。实现健康检查、告警、配置导入导出、模板与历史差异,并补充管理界面、回归测试和运维文档。
2026-09-12 22:19:10 +08:00

375 lines
10 KiB
Go

package api
import (
"context"
"net"
"net/http"
"net/url"
"serviceport/internal/alerts"
"serviceport/internal/buildinfo"
"serviceport/internal/gateway"
"serviceport/internal/outbound"
"serviceport/internal/protocol/sshproxy"
"serviceport/internal/security"
"serviceport/internal/store"
"strings"
"sync"
"time"
)
type session struct {
User, CSRF, Revision string
Expires time.Time
Role string
}
type attempt struct {
Count int
Until time.Time
}
type probe struct {
Key, Fingerprint, Host string
Port int
Until time.Time
Session string
}
type Server struct {
alerts *alerts.Engine
store *store.Store
manager *gateway.Manager
mu sync.Mutex
ops sync.Mutex
sessions map[string]session
attempts map[string]attempt
probes map[string]probe
secure bool
authEnabled bool
devCSRF string
}
func New(s *store.Store, m *gateway.Manager, secure bool, authentication ...bool) *Server {
enabled := true
if len(authentication) > 0 {
enabled = authentication[0]
}
server := &Server{store: s, manager: m, sessions: map[string]session{}, attempts: map[string]attempt{}, probes: map[string]probe{}, secure: secure, authEnabled: enabled, devCSRF: security.Token(), alerts: alerts.New(s, m)}
server.alerts.Start()
m.OnShutdown(server.alerts.Close)
return server
}
func (s *Server) Handler(static http.Handler) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /readyz", s.readiness)
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { respond(w, 200, map[string]string{"status": "ok"}) })
mux.HandleFunc("/api/v1/", s.serve)
mux.Handle("/", static)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'")
mux.ServeHTTP(w, r)
})
}
func (s *Server) serve(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
path := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/v1/"), "/")
if r.Method != "GET" {
if origin := r.Header.Get("Origin"); origin != "" {
u, e := url.Parse(origin)
if e != nil || u.Host != r.Host || (u.Scheme != "http" && u.Scheme != "https") {
fail(w, 403, "origin rejected")
return
}
}
}
if path == "login" && r.Method == "POST" {
if !s.authEnabled {
fail(w, 400, "login is disabled in development mode")
return
}
s.login(w, r)
return
}
var sess session
role := "admin"
cookie := &http.Cookie{Value: "development"}
bearer := r.Header.Get("Authorization") != ""
if bearer {
value := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
credential, err := s.store.VerifyToken(value)
if err != nil {
fail(w, 401, "invalid or expired API token")
return
}
if !tokenPermits(credential, path, r.Method) {
fail(w, 403, "token scope does not permit this operation")
return
}
sess = session{User: credential.Owner, Revision: credential.Revision}
cookie.Value = "token:" + credential.ID
role = "admin"
w = tokenResponseWriter{w}
} else if !s.authEnabled {
ip := net.ParseIP(source(r))
host := r.Host
if h, _, e := net.SplitHostPort(host); e == nil {
host = h
}
hostIP := net.ParseIP(host)
if ip == nil || !ip.IsLoopback() || (host != "localhost" && (hostIP == nil || !hostIP.IsLoopback())) {
fail(w, 403, "development mode is local only")
return
}
sess = session{User: "开发者", CSRF: s.devCSRF}
} else {
var err error
cookie, err = r.Cookie("serviceport_session")
if err != nil {
fail(w, 401, "login required")
return
}
s.mu.Lock()
var ok bool
sess, ok = s.sessions[cookie.Value]
if time.Now().After(sess.Expires) {
delete(s.sessions, cookie.Value)
ok = false
}
s.mu.Unlock()
identity, rev, err := s.store.Identity(sess.User)
if !ok || err != nil || identity.Disabled || rev != sess.Revision {
fail(w, 401, "session expired")
return
}
role = identity.Role
}
if !bearer && r.Method != "GET" && r.Header.Get("X-CSRF-Token") != sess.CSRF {
fail(w, 403, "CSRF token required")
return
}
sess.Role = role
if path == "session" && r.Method == "GET" {
respond(w, 200, map[string]any{"username": sess.User, "csrfToken": sess.CSRF, "authEnabled": s.authEnabled, "role": role})
return
}
if !allowedRole(role, path, r.Method) {
fail(w, 403, "role does not permit this operation")
return
}
if path == "version" {
if r.Method != "GET" {
w.Header().Set("Allow", "GET")
fail(w, 405, "method not allowed")
return
}
respond(w, 200, buildinfo.Current())
return
}
if path == "metrics" && r.Method == "GET" {
s.prometheus(w, r)
return
}
if path == "templates" || strings.HasPrefix(path, "templates/") {
s.templates(w, r, path)
return
}
if path == "logout" && r.Method == "POST" {
s.mu.Lock()
delete(s.sessions, cookie.Value)
s.mu.Unlock()
http.SetCookie(w, &http.Cookie{Name: "serviceport_session", Path: "/", MaxAge: -1, HttpOnly: true, Secure: s.secure, SameSite: http.SameSiteStrictMode})
respond(w, 200, map[string]bool{"ok": true})
return
}
if path == "events" && r.Method == "GET" {
s.events(w, r)
return
}
if path == "commands" && r.Method == "GET" {
s.commands(w, r)
return
}
if path == "connections" || strings.HasPrefix(path, "connections/") {
s.connectionRoutes(w, r, path)
return
}
if path == "alerts" || strings.HasPrefix(path, "alerts/") {
if path == "alerts/settings" && role != "admin" {
fail(w, 403, "administrator role required")
return
}
s.alertRoutes(w, r, path)
return
}
if path == "monitoring" || strings.HasPrefix(path, "monitoring/") {
s.monitoring(w, r, path)
return
}
if path == "proxies" && r.Method == "GET" {
ps, e := s.store.List()
if e != nil {
fail(w, 500, "cannot decrypt configurations; verify master key")
return
}
items := make([]any, 0, len(ps))
for _, p := range ps {
items = append(items, roleView(s.view(p), role))
}
respond(w, 200, items)
return
}
s.ops.Lock()
defer s.ops.Unlock()
if path == "tokens" || strings.HasPrefix(path, "tokens/") {
s.tokens(w, r, path, sess)
return
}
if strings.HasPrefix(path, "configuration/") {
s.configuration(w, r, path)
return
}
if path == "users" || strings.HasPrefix(path, "users/") {
s.users(w, r, path, sess)
return
}
if path == "outbounds" || strings.HasPrefix(path, "outbounds/") {
s.outbounds(w, r, path, sess, cookie.Value)
return
}
if path == "targets" || strings.HasPrefix(path, "targets/") {
s.targets(w, r, path, sess, cookie.Value)
return
}
if path == "proxies" && r.Method == "POST" {
s.create(w, r)
return
}
parts := strings.Split(path, "/")
if len(parts) < 2 || parts[0] != "proxies" {
fail(w, 404, "not found")
return
}
p, e := s.store.Get(parts[1])
if e != nil {
fail(w, 404, "proxy not found")
return
}
if len(parts) == 2 {
switch r.Method {
case "GET":
respond(w, 200, roleView(s.view(p), role))
case "PUT":
s.update(w, r, p)
case "DELETE":
if s.manager.Status(p.ID).Running {
fail(w, 409, "stop proxy before deleting")
return
}
if e = s.store.Delete(p.ID); e != nil {
fail(w, 500, "delete failed")
return
}
s.store.Event(p.ID, source(r), "proxy_deleted", "")
respond(w, 200, map[string]bool{"ok": true})
default:
fail(w, 405, "method not allowed")
}
return
}
if len(parts) != 3 || r.Method != "POST" {
fail(w, 405, "method not allowed")
return
}
switch parts[2] {
case "start":
if e = s.manager.Start(p); e != nil {
fail(w, 409, e.Error())
return
}
p.Enabled = true
if e = s.store.Save(p); e != nil {
s.manager.Stop(p.ID)
fail(w, 500, "cannot persist enabled state")
return
}
s.store.Event(p.ID, source(r), "proxy_started", "")
respond(w, 200, s.view(p))
case "stop":
s.manager.Stop(p.ID)
p.Enabled = false
if e = s.store.Save(p); e != nil {
fail(w, 500, "cannot persist stopped state")
return
}
s.store.Event(p.ID, source(r), "proxy_stopped", "")
respond(w, 200, s.view(p))
case "test":
if p.TargetID == "" || p.TargetAccountID == "" {
fail(w, 409, "select a target account before testing the gateway")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 25*time.Second)
defer cancel()
result, e := s.manager.Test(ctx, p)
if e != nil {
stage := gateway.FailureCategory(e)
s.store.Event(p.ID, source(r), "test_failed", stage)
respond(w, 422, map[string]string{"error": gateway.FailureMessage(stage), "stage": stage})
return
}
respond(w, 200, result)
case "probe":
if p.Type != "ssh" {
fail(w, 400, "SSH only")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
result, e := sshproxy.Probe(ctx, p, outbound.Dialer(p))
if e != nil {
fail(w, 422, "SSH host key probe failed")
return
}
s.mu.Lock()
s.probes[p.ID] = probe{result.HostKey, result.Fingerprint, p.TargetHost, p.TargetPort, time.Now().Add(5 * time.Minute), cookie.Value}
s.mu.Unlock()
respond(w, 200, result)
case "confirm-host-key":
if p.Type != "ssh" || s.manager.Status(p.ID).Running {
fail(w, 409, "stop SSH proxy first")
return
}
var body struct {
Fingerprint string `json:"fingerprint"`
}
if !decode(w, r, &body) {
return
}
s.mu.Lock()
pr, ok := s.probes[p.ID]
s.mu.Unlock()
if !ok || pr.Session != cookie.Value || time.Now().After(pr.Until) || pr.Host != p.TargetHost || pr.Port != p.TargetPort || pr.Fingerprint != body.Fingerprint {
fail(w, 409, "probe again and confirm the exact fingerprint")
return
}
target, err := s.store.GetTarget(p.TargetID)
if err != nil || !s.targetStopped(w, p.TargetID) {
return
}
target.SSH.TargetHostKey = pr.Key
if e = s.store.SaveTarget(target); e != nil {
fail(w, 500, "save failed")
return
}
s.mu.Lock()
delete(s.probes, p.ID)
s.mu.Unlock()
s.store.Event(p.ID, source(r), "ssh_host_key_confirmed", pr.Fingerprint)
respond(w, 200, s.view(p))
case "reveal-password":
s.reveal(w, r, p, sess, cookie.Value)
default:
fail(w, 404, "not found")
}
}