新增 HTTP/HTTPS 与 SQL Server 支持,完善账号映射、凭据生命周期、角色和 API Token 隔离。实现健康检查、告警、配置导入导出、模板与历史差异,并补充管理界面、回归测试和运维文档。
375 lines
10 KiB
Go
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")
|
|
}
|
|
}
|