feat: 完善多账号网关、运维监控与配置管理
新增 HTTP/HTTPS 与 SQL Server 支持,完善账号映射、凭据生命周期、角色和 API Token 隔离。实现健康检查、告警、配置导入导出、模板与历史差异,并补充管理界面、回归测试和运维文档。
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
开发时双击根目录 `start-dev.cmd`,打开 `http://127.0.0.1:8080`(默认免登录)。该入口固定使用项目根目录下的 `data/serviceport.sqlite`,与启动时的工作目录无关,重启和重新构建后继续保留目标服务和网关配置。数据库、`master-key.dpapi` 和 `tls` 均保存在 `data` 中,备份时关闭服务并复制整个目录;密钥绑定当前 Windows 用户。直接运行发布包仍使用 `%APPDATA%/serviceport`。
|
||||
|
||||
使用 Go 和 Vue 3 构建的 MySQL、MongoDB、PostgreSQL、Redis、RabbitMQ、SSH 连接代理管理后台。目标服务独立保存地址、凭据及直连 / SOCKS5 / SSH 转发出口;网关引用目标,独立管理监听端口、访问凭据及可选只读策略。一个目标可被多个同协议网关复用。
|
||||
使用 Go 和 Vue 3 构建的 MySQL、MongoDB、PostgreSQL、SQL Server、Redis、RabbitMQ、SSH、SFTP、HTTP/HTTPS 连接代理管理后台。目标服务独立保存地址、凭据及直连 / SOCKS5 / SSH 转发出口;网关引用目标,独立管理监听端口、访问凭据及可选只读策略。一个目标可被多个同协议网关复用。
|
||||
|
||||
目标配置的「代理出口」可选择 SSH 转发,填写跳板机地址、端口、账号密码和已核对的 SHA256 主机指纹。跳板机需允许 TCP 转发;目标地址和域名由跳板机访问,连接失败不回退直连。当前跳板机认证支持密码。网关来源 IP/CIDR 为可选项,留空允许所有来源,填写后仅放行匹配项。
|
||||
|
||||
@@ -18,8 +18,15 @@
|
||||
- Redis:RESP 数组命令、AUTH/HELLO 凭据映射、流水线、订阅推送与命令审计;支持 standalone 目标,不自动处理 Cluster 重定向。
|
||||
- RabbitMQ:AMQP 0-9-1、PLAIN 凭据映射、队列/交换机/发布消费帧转发;审计方法及通道元数据,不保存消息正文。
|
||||
- PostgreSQL(pgsql):普通和扩展查询协议、上游 MD5/SCRAM 认证、网关密码认证、参数化查询及 SQL 审计。当前不代理独立 CancelRequest,也不提供只读策略。
|
||||
- SQL Server:TDS 7.x SQL 密码登录、TLS 协商、网关账号到目标账号映射及 SQL Batch/RPC 审计;当前不支持集成 Windows/Kerberos 登录。
|
||||
- HTTP/HTTPS:网关 Basic Auth、目标 Basic Auth 替换、TLS、请求审计和来源限制;仅转发普通 HTTP 请求,不支持 CONNECT 或 WebSocket 升级。
|
||||
- SFTP:复用 SSH 主机指纹和账号认证,仅开放 SFTP 子系统;文件操作写入审计,不记录文件内容。
|
||||
- SOCKS5:每条代理独立配置,支持无认证和用户名密码认证、远端 DNS;出口失败不回退直连。
|
||||
- 管理:单管理员、SQLite 加密配置、CSRF/会话、密码重新认证查看、来源白名单、连接限额、启停及 7 天审计。
|
||||
- 管理:管理员、只读运维与审计员角色,SQLite 加密配置、CSRF/会话、密码重新认证查看、来源白名单、连接限额、启停及 7 天审计。支持短期 API Token、作用域控制和 Prometheus 指标。
|
||||
|
||||
网关支持多个独立账号,可暂不关联目标;账号支持禁用、来源限制,MySQL/MongoDB 支持账号只读策略。新增批量创建和启停、健康检查与指标、加密配置备份及历史恢复。使用方式、旧配置迁移与验证边界见 [网关账号与运维](docs/gateway-operations.md),双实例部署示例见 [高可用部署](docs/high-availability.md)。
|
||||
|
||||
配置中心支持脱敏导出、加密备份、导入预览、配置模板和历史版本差异对比。导入预览按与实际导入相同的合并规则校验现有引用,展示新增、覆盖、未变更资源及脱敏字段差异,不写入数据库。确认前须手动停止所有网关;预览后配置发生变化会拒绝提交并要求重新预览。脱敏配置不会导出密码、私钥、口令或 Kubernetes 配置;加密备份包含用于恢复的凭据,须保管好备份口令。
|
||||
|
||||
目标版本与实测版本请区分,见 [验证说明](docs/testing.md)。
|
||||
|
||||
@@ -91,6 +98,22 @@ $env:SERVICEPORT_ADMIN_PASSWORD = [Net.NetworkCredential]::new('', $adminSecret)
|
||||
|
||||
`.env.example` 仅为示例,程序不自动加载 `.env`。远程管理使用 HTTPS 反向代理。
|
||||
|
||||
### API 自动化
|
||||
|
||||
管理员可在「监控运维 → API Token」创建短期 Token、选择作用域与到期时间、查看状态及撤销;创建需要启用管理登录。也可使用管理 API,创建请求示例:
|
||||
|
||||
```http
|
||||
POST /api/v1/tokens
|
||||
Content-Type: application/json
|
||||
X-CSRF-Token: <session-csrf>
|
||||
|
||||
{"name":"monitoring-ci","scopes":["metrics"],"expiresAt":"2026-12-31T00:00:00Z"}
|
||||
```
|
||||
|
||||
响应中的 `secret` 只返回一次,后续请求使用 `Authorization: Bearer <secret>`。作用域为 `read`、`write`、`audit`、`metrics`;Token 不能管理用户、创建 Token、读取秘密或修改 Webhook 设置,写入响应也会清除敏感字段。`DELETE /api/v1/tokens/{id}` 可撤销 Token;所属管理员密码或角色变更、被禁用时 Token 失效,单纯退出网页登录不会使其失效。指标通过 `GET /api/v1/metrics` 以 Prometheus 文本格式返回。
|
||||
|
||||
「监控运维 → 配置模板」支持将当前配置存为模板、导入模板文件、查看和导出 JSON、补齐凭据后预览应用及删除。模板只保存脱敏结构,仅管理员可管理;普通只读用户与审计员不可访问。健康检查间隔持久保存,重启后恢复,每轮检查所有目标账号;设为 0 可关闭。
|
||||
|
||||
### 数据库 TLS
|
||||
|
||||
MySQL/MongoDB 网关允许普通连接;配置证书后同一端口也支持客户端选择 TLS。未配置证书仍可启动数据库网关并接受普通连接。客户端 TLS 与目标服务的上游 TLS 独立。
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"golang.org/x/term"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"serviceport/internal/api"
|
||||
"serviceport/internal/buildinfo"
|
||||
"serviceport/internal/desktop"
|
||||
"serviceport/internal/gateway"
|
||||
"serviceport/internal/localcert"
|
||||
"serviceport/internal/model"
|
||||
"serviceport/internal/protocol/httpproxy"
|
||||
"serviceport/internal/protocol/mongoproxy"
|
||||
"serviceport/internal/protocol/mysqlproxy"
|
||||
"serviceport/internal/protocol/pgproxy"
|
||||
"serviceport/internal/protocol/rabbitproxy"
|
||||
"serviceport/internal/protocol/redisproxy"
|
||||
"serviceport/internal/protocol/sqlserverproxy"
|
||||
"serviceport/internal/protocol/sshproxy"
|
||||
"serviceport/internal/security"
|
||||
"serviceport/internal/store"
|
||||
"serviceport/web"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
log.Print(err)
|
||||
if desktop.Enabled() && len(os.Args) == 1 && term.IsTerminal(int(os.Stdin.Fd())) {
|
||||
fmt.Println("启动失败。请查看以上错误;按 Enter 关闭窗口。")
|
||||
fmt.Scanln()
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
func run() error {
|
||||
if len(os.Args) > 1 && (os.Args[1] == "version" || os.Args[1] == "--version" || os.Args[1] == "-version") {
|
||||
info := buildinfo.Current()
|
||||
if len(os.Args) == 3 && os.Args[2] == "--json" {
|
||||
return json.NewEncoder(os.Stdout).Encode(info)
|
||||
}
|
||||
if len(os.Args) != 2 {
|
||||
return fmt.Errorf("usage: serviceport version [--json]")
|
||||
}
|
||||
fmt.Println(info.String())
|
||||
return nil
|
||||
}
|
||||
if len(os.Args) > 1 && os.Args[1] == "generate-key" {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(base64.StdEncoding.EncodeToString(b))
|
||||
return nil
|
||||
}
|
||||
log.Print(buildinfo.Current().String())
|
||||
masterKey := os.Getenv("SERVICEPORT_MASTER_KEY")
|
||||
path := env("SERVICEPORT_DATA", "data/serviceport.sqlite")
|
||||
var local *desktop.Config
|
||||
if desktop.Enabled() {
|
||||
var err error
|
||||
local, err = desktop.Prepare()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer local.Close()
|
||||
masterKey, path = local.Key, local.Data
|
||||
}
|
||||
vault, err := security.NewVault(masterKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
s, err := store.Open(path, vault)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer s.Close()
|
||||
if len(os.Args) > 1 && os.Args[1] == "reset-password" {
|
||||
fmt.Print("New administrator password: ")
|
||||
a, e := term.ReadPassword(int(os.Stdin.Fd()))
|
||||
fmt.Println()
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
fmt.Print("Confirm password: ")
|
||||
b, e := term.ReadPassword(int(os.Stdin.Fd()))
|
||||
fmt.Println()
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if string(a) != string(b) {
|
||||
return fmt.Errorf("passwords do not match")
|
||||
}
|
||||
return s.ResetPassword(string(a))
|
||||
}
|
||||
authEnabled := strings.EqualFold(os.Getenv("SERVICEPORT_AUTH_ENABLED"), "true")
|
||||
if (authEnabled && local == nil) || os.Getenv("SERVICEPORT_ADMIN_PASSWORD") != "" {
|
||||
if err = s.InitAdmin(os.Getenv("SERVICEPORT_ADMIN_USER"), os.Getenv("SERVICEPORT_ADMIN_PASSWORD")); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var tlsConfig *tls.Config
|
||||
cert, key := os.Getenv("SERVICEPORT_TLS_CERT"), os.Getenv("SERVICEPORT_TLS_KEY")
|
||||
if local != nil && cert == "" && key == "" {
|
||||
dir := filepath.Join(local.Dir, "tls")
|
||||
cert, key = filepath.Join(dir, "cert.pem"), filepath.Join(dir, "key.pem")
|
||||
if _, e := os.Stat(cert); os.IsNotExist(e) {
|
||||
if e = localcert.Generate(dir, 365*24*time.Hour); e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
}
|
||||
if cert != "" || key != "" {
|
||||
pair, e := tls.LoadX509KeyPair(cert, key)
|
||||
if e != nil {
|
||||
return fmt.Errorf("load gateway TLS certificate: %w", e)
|
||||
}
|
||||
tlsConfig = &tls.Config{Certificates: []tls.Certificate{pair}, MinVersion: tls.VersionTLS12}
|
||||
}
|
||||
handlers := map[string]model.Handler{"mysql": mysqlproxy.New(), "mongodb": mongoproxy.New(), "ssh": sshproxy.New(), "sftp": sshproxy.New(), "http": httpproxy.New(), "https": httpproxy.New(), "sqlserver": sqlserverproxy.New(), "redis": redisproxy.New(), "rabbitmq": rabbitproxy.New(), "pgsql": pgproxy.New()}
|
||||
m := gateway.New(s, tlsConfig, handlers)
|
||||
defer m.Shutdown()
|
||||
if err = m.ResumeHealthChecks(); err != nil {
|
||||
return fmt.Errorf("restore health settings: %w", err)
|
||||
}
|
||||
ps, err := s.List()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot load configurations; verify master key: %w", err)
|
||||
}
|
||||
for _, p := range ps {
|
||||
if p.Enabled {
|
||||
if e := m.Start(p); e != nil {
|
||||
log.Printf("proxy %s restore failed: %v", p.ID, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
server := api.New(s, m, strings.EqualFold(os.Getenv("SERVICEPORT_SECURE_COOKIE"), "true"), authEnabled)
|
||||
srv := &http.Server{Addr: env("SERVICEPORT_HTTP_ADDR", "127.0.0.1:8080"), Handler: server.Handler(web.Handler()), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 45 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 1 << 16}
|
||||
if local != nil || !authEnabled {
|
||||
host, _, e := net.SplitHostPort(srv.Addr)
|
||||
if e != nil || net.ParseIP(host) == nil || !net.ParseIP(host).IsLoopback() {
|
||||
return fmt.Errorf("desktop/development mode requires a loopback SERVICEPORT_HTTP_ADDR, such as 127.0.0.1:8080; enable SERVICEPORT_AUTH_ENABLED for remote deployment")
|
||||
}
|
||||
}
|
||||
listener, err := net.Listen("tcp", srv.Addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot listen on %s (serviceport may already be running): %w", srv.Addr, err)
|
||||
}
|
||||
defer listener.Close()
|
||||
url := "http://" + listener.Addr().String()
|
||||
if local != nil {
|
||||
var setupPath string
|
||||
if authEnabled {
|
||||
srv.Handler, setupPath, err = desktop.Setup(s, srv.Handler, listener.Addr().String())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
url += setupPath
|
||||
fmt.Printf("数据目录:%s\n关闭服务请按 Ctrl+C。\n", local.Dir)
|
||||
}
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
log.Printf("serviceport management listening on %s", listener.Addr())
|
||||
done <- srv.Serve(listener)
|
||||
}()
|
||||
if local != nil {
|
||||
fmt.Println("管理页面:" + url)
|
||||
if os.Getenv("SERVICEPORT_NO_BROWSER") != "1" {
|
||||
if e := desktop.OpenBrowser(url); e != nil {
|
||||
log.Printf("请手动打开管理页面:%v", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
s.Prune()
|
||||
ticker := time.NewTicker(time.Hour)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case e := <-done:
|
||||
if e == http.ErrServerClosed {
|
||||
return nil
|
||||
}
|
||||
return e
|
||||
case <-ticker.C:
|
||||
s.Prune()
|
||||
case <-ctx.Done():
|
||||
shutdown, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
return srv.Shutdown(shutdown)
|
||||
}
|
||||
}
|
||||
}
|
||||
func env(key, fallback string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"serviceport/internal/buildinfo"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestVersionWithoutDataInitialization(t *testing.T) {
|
||||
data := filepath.Join(t.TempDir(), "must-not-exist", "serviceport.sqlite")
|
||||
t.Setenv("SERVICEPORT_DATA", data)
|
||||
t.Setenv("SERVICEPORT_MASTER_KEY", "invalid")
|
||||
oldArgs, oldStdout := os.Args, os.Stdout
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer r.Close()
|
||||
defer w.Close()
|
||||
t.Cleanup(func() { os.Args, os.Stdout = oldArgs, oldStdout })
|
||||
os.Args, os.Stdout = []string{"serviceport", "--version", "--json"}, w
|
||||
if err := run(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.Close()
|
||||
body, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got buildinfo.Info
|
||||
if err := json.Unmarshal(body, &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != buildinfo.Current() {
|
||||
t.Fatalf("CLI metadata: %+v", got)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Dir(data)); !os.IsNotExist(err) {
|
||||
t.Fatalf("version initialized data: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
global
|
||||
log stdout format raw local0
|
||||
|
||||
defaults
|
||||
log global
|
||||
mode tcp
|
||||
timeout connect 5s
|
||||
timeout client 1h
|
||||
timeout server 1h
|
||||
|
||||
# Replace these private addresses and the gateway port for your environment.
|
||||
# TLS passes through to the serviceport gateway on each node.
|
||||
frontend mysql_gateway
|
||||
bind :3307
|
||||
default_backend serviceport_mysql
|
||||
|
||||
backend serviceport_mysql
|
||||
balance leastconn
|
||||
option httpchk GET /readyz
|
||||
http-check expect status 200
|
||||
default-server inter 5s fall 3 rise 2
|
||||
server node_a 10.20.0.11:3307 check port 8080
|
||||
server node_b 10.20.0.12:3307 check port 8080 backup
|
||||
|
||||
# One frontend/backend pair is needed for each additional gateway port.
|
||||
# HTTP checks require all bound gateway listeners to run; drafts are ignored.
|
||||
# Target connectivity/authentication is not tested by readiness.
|
||||
# Keep management port 8080 reachable only from the load balancer and operators.
|
||||
@@ -0,0 +1,8 @@
|
||||
SERVICEPORT_AUTH_ENABLED=true
|
||||
SERVICEPORT_HTTP_ADDR=10.20.0.11:8080
|
||||
SERVICEPORT_DATA=/var/lib/serviceport-a/serviceport.sqlite
|
||||
SERVICEPORT_MASTER_KEY=REPLACE_WITH_EXTERNALLY_MANAGED_BASE64_KEY
|
||||
SERVICEPORT_TLS_CERT=/etc/serviceport/gateway-cert.pem
|
||||
SERVICEPORT_TLS_KEY=/etc/serviceport/gateway-key.pem
|
||||
# Supply first-start administrator credentials through your secret manager.
|
||||
# When cloning a provisioned snapshot, the administrator is already configured.
|
||||
@@ -0,0 +1,8 @@
|
||||
SERVICEPORT_AUTH_ENABLED=true
|
||||
SERVICEPORT_HTTP_ADDR=10.20.0.12:8080
|
||||
SERVICEPORT_DATA=/var/lib/serviceport-b/serviceport.sqlite
|
||||
SERVICEPORT_MASTER_KEY=REPLACE_WITH_EXTERNALLY_MANAGED_BASE64_KEY
|
||||
SERVICEPORT_TLS_CERT=/etc/serviceport/gateway-cert.pem
|
||||
SERVICEPORT_TLS_KEY=/etc/serviceport/gateway-key.pem
|
||||
# This is a separate machine with its own database and audit directory.
|
||||
# An encrypted snapshot clone requires the source encryption key.
|
||||
@@ -0,0 +1,109 @@
|
||||
# 网关账号与运维
|
||||
|
||||
网关支持先保存后关联目标。创建时可以省略 `targetId` 和
|
||||
`targetAccountId`;绑定时指定默认目标账号,或给每个启用的网关账号单独指定目标账号。更新时发送 `targetId: ""`
|
||||
解除关联,同时清空目标账号。未绑定网关不能启动或测试连接,修改运行中
|
||||
网关前须先停止。目标服务至少保留一个账号,目标用户名和网关用户名均必填,
|
||||
Redis 默认账号请显式填写 `default`。
|
||||
|
||||
## 多账号与权限
|
||||
|
||||
`gatewayAccounts` 最多包含 32 个账号,每项包含 `id`、`username`、
|
||||
`password`、`disabled`、`accessMode`、`allowCIDRs` 和 `authorizedKeys`。
|
||||
ID 与用户名在同一网关内唯一;密码可以相同。账号 ID 是凭据更新的稳定标识。
|
||||
|
||||
支持的连接类型包括 MySQL、PostgreSQL、SQL Server(TDS SQL 登录)、MongoDB、Redis、RabbitMQ、SSH、SFTP、HTTP 和 HTTPS。SFTP 使用 SSH 的主机密钥校验和账号映射,但只允许 SFTP 子系统;HTTP/HTTPS 使用网关 Basic Auth,转发前会替换为目标账号凭据并移除代理伪造头;SQL Server 当前支持 SQL 密码登录和 TDS 请求审计。
|
||||
更新时省略密码保留原密码,显式空字符串清除密码;非 SSH 网关不允许空密码。
|
||||
SSH 可使用账号公钥代替密码。
|
||||
|
||||
旧 `gatewayUser/gatewayPassword` 配置仍可使用;一旦配置账号列表,认证仅
|
||||
使用列表,不再回退到旧账号。MySQL、MongoDB 支持账号只读权限,账号无法
|
||||
提升网关本身的只读限制。其他协议不提供只读模式。账号来源限制与网关来源
|
||||
限制同时生效。账号可用 `targetAccountId` 选择同一目标下的独立账号;留空时使用网关默认目标账号。解除目标关联会清空默认和账号级映射。
|
||||
|
||||
网关账号和目标账号支持 `expiresAt`(RFC3339)及 `rotationReminderDays`(0 默认 7 天,最多 365 天)。到期后拒绝新的认证,不主动终止既有会话。凭据仍需手动轮换;修改运行中网关及其目标配置前须先停止网关。
|
||||
|
||||
密码通过账号编辑手动轮换;停止、更新、重新启动后生效。历史配置以主密钥
|
||||
加密保存,管理员可通过配置历史恢复旧凭据。没有后台自动轮换或上游密码
|
||||
修改功能。正常列表和历史元信息不返回密码。
|
||||
|
||||
六种协议记录 `authentication_succeeded`、`authentication_failed` 事件,
|
||||
事件详情包含用户名,不包含密码。可通过事件类别和关键词筛选账号认证记录。
|
||||
|
||||
## 管理角色
|
||||
|
||||
| 角色 | 查看配置和监控 | 查看审计 | 修改配置、查看密码、管理用户 |
|
||||
| --- | --- | --- | --- |
|
||||
| admin | 是 | 是 | 是 |
|
||||
| viewer | 是 | 否 | 否 |
|
||||
| auditor | 是 | 是 | 否 |
|
||||
|
||||
只读角色不能读取 Kubernetes 凭据。角色、密码、禁用状态变化会使旧会话失效。
|
||||
初始管理员不能从网页删除或降权,其密码沿用本地重置命令。
|
||||
管理认证关闭的本地开发模式具有管理员权限;部署时启用管理认证。
|
||||
|
||||
## 批量操作和监控
|
||||
|
||||
网关列表支持搜索、选择、批量启动/停止、复制创建和批量创建。
|
||||
批量创建递增监听端口,逐项显示结果;部分失败不会撤销已成功项。
|
||||
端口占用在启动监听时检测,不会抢占其他进程端口。
|
||||
|
||||
运维页面显示活动连接、认证成功/失败、连接失败率和平均认证耗时。目标健康检查会按协议执行轻量探测:MySQL 握手及 `COM_PING` 响应、PostgreSQL `SELECT 1`、MongoDB hello/buildInfo、Redis `PING`、RabbitMQ 虚拟主机连接、SQL Server TDS 登录、HTTP/HTTPS HEAD、SSH 认证,以及 SFTP 工作目录检查。结果包含协议、检查内容、阶段、延迟和协议返回摘要。
|
||||
TCP 连接和协议认证分别计数。手动健康检查最多尝试 3 次,总超时 25 秒;
|
||||
只重试固定健康探测,不重放业务命令。周期检查默认关闭,可设置为
|
||||
10–86400 秒,每轮依次检查每个目标的全部账号,失败账号不会阻止继续检查其他账号。
|
||||
间隔设置持久保存并在进程重启后恢复,设为 0 会持久关闭。指标与检查结果仍在重启后重置。
|
||||
|
||||
`/healthz` 表示进程存活;`/readyz` 要求至少一个已绑定网关,且所有已绑定
|
||||
网关的监听器均运行。两者都不保证目标服务可用或凭据正确。
|
||||
|
||||
## 配置备份和历史
|
||||
|
||||
运维页面的活跃会话显示账号、来源、目标账号和连接时长,管理员可单独断开连接,其他会话和网关监听不受影响。
|
||||
|
||||
告警中心每 30 秒检查目标健康结果、认证失败增量、凭据到期及网关 TLS 证书到期。目标不可用告警依赖已启用的周期健康检查或手动健康检查结果。认证失败按采样窗口统计。告警支持确认、去重和恢复;默认仅在站内显示。管理员显式启用 Webhook 后才发送通知,可配置 HMAC-SHA256 签名密钥,每次状态变化最多尝试 3 次,不跟随 HTTP 重定向。Webhook 设置加密保存,查询接口不返回地址或密钥。
|
||||
|
||||
`GET /api/v1/metrics` 提供 Prometheus 文本指标,包括监听状态、连接数、认证成功/失败计数、认证耗时和目标健康状态。自动化调用可使用短期 API Token:已登录管理员在运维页面或 `/api/v1/tokens` 创建并选择 `read`、`write`、`audit` 或 `metrics` 作用域,密钥只在创建时返回。页面支持查看所属管理员、作用域、到期时间和撤销。密码或角色变更、管理员被禁用会使其 Token 失效;退出网页登录不会撤销 Token。Token 无权获取加密备份、查看密码、管理用户/Token 或访问 Webhook 设置,读写响应均清除密码、私钥和 Kubernetes 配置等敏感字段。
|
||||
|
||||
恢复历史配置前,页面显示当前配置与历史版本的差异。密码、私钥、口令和 Kubernetes 凭据仅标记变更,不返回原值;账号按稳定 ID 对比。
|
||||
|
||||
- `GET /api/v1/configuration/export`:脱敏配置;恢复前需要补齐秘密。
|
||||
- `POST /api/v1/configuration/backup`:提交 `passphrase`,下载加密配置包。
|
||||
- `POST /api/v1/configuration/import`:提交 `{backup, passphrase, expectedRevision}` 或 `{bundle, expectedRevision}`;使用预览返回的 revision 防止覆盖预览后的修改,配置已变更返回 409。旧客户端仍可不传 expectedRevision。
|
||||
- `GET /api/v1/configuration/history?kind=gateways&id=...`:历史元信息;
|
||||
kind 也支持 `targets` 和 `outbounds`。
|
||||
- `POST /api/v1/configuration/restore`:提交 `{kind,id,revision}` 恢复版本。
|
||||
- `GET /api/v1/configuration/diff?kind=gateways&id=...&revision=...`:查看脱敏差异。
|
||||
- `POST /api/v1/configuration/import/preview`:与实际导入共用合并及引用校验,返回资源数量、revision、新增/覆盖/未变更列表及脱敏字段差异;支持部分配置引用现有资源,不写入数据库。
|
||||
- `GET/POST/DELETE /api/v1/templates[/{id}]`:保存、读取和删除脱敏配置模板。
|
||||
|
||||
模板页面支持将当前配置保存为模板、导入文件、查看/导出 JSON、补齐凭据后预览应用及删除。模板保存前清除秘密,读取旧模板时也会脱敏;只读运维和审计员无权访问。模板结构允许缺少凭据,实际应用必须通过完整配置校验。编辑器导出的当前 JSON 可能包含操作者刚填入的凭据。
|
||||
|
||||
备份采用随机盐、PBKDF2-SHA256 和 AES-GCM,包含恢复所需的凭据,可迁移到使用不同主密钥的实例。
|
||||
导入先校验全部引用,事务提交;导入的网关保持停止。导入和回滚要求所有
|
||||
网关先停止。配置包不包含管理用户、审计数据库、网页 TLS 文件和完整历史,
|
||||
不能代替完整数据目录备份。
|
||||
|
||||
高可用采用独立实例加外部负载均衡,见 [高可用部署](high-availability.md)。
|
||||
不提供内置复制、自动配置同步或会话迁移。
|
||||
|
||||
## 验证范围
|
||||
|
||||
新增后端测试使用临时 SQLite 和本地协议测试桩,前端测试使用独立数据目录
|
||||
及新构建程序。角色界面的部分测试使用模拟接口,后端另有真实接口权限测试。
|
||||
这些测试不等同于真实 MySQL/PostgreSQL/MongoDB/Redis/RabbitMQ/SSH 服务矩阵
|
||||
或双节点高可用集群验收。
|
||||
|
||||
2026-09-12 验证结果:Go 1.25.0 全量 `go test ./... -count=1 -timeout=120s`
|
||||
及 `go vet ./...` 通过,`npm.cmd --prefix web run build` 通过。
|
||||
Playwright 使用新构建的 `artifacts/serviceport-e2e.exe`,在
|
||||
`SERVICEPORT_BROWSER_CHANNEL=chromium` 下 22 项全部通过。套件启动两个独立
|
||||
临时数据库实例,18081 用于开发模式流程,18082 用于真实管理员登录和 Token
|
||||
创建、指标调用、权限拒绝及撤销;不会复用现有服务。
|
||||
|
||||
本轮回归覆盖模板存取脱敏、旧模板读取脱敏、角色访问限制、Token 读写响应
|
||||
凭据隔离和到期/撤销/用户变更失效、导入合并校验及过期预览拒绝、模板界面
|
||||
文件导入导出与校验失败保留输入、周期检查全部账号及间隔恢复。
|
||||
MySQL 测试桩验证收到 `COM_PING` 后必须取得响应才算健康;SSH 请求回复与
|
||||
通道关闭测试额外重复 50 轮通过。这些协议检查使用本地测试桩,未宣称通过
|
||||
外部真实服务或生产高可用环境验收。
|
||||
@@ -0,0 +1,118 @@
|
||||
# High availability deployment
|
||||
|
||||
Serviceport can run as two independent gateway nodes behind an external TCP
|
||||
load balancer. The example in `deploy/ha/haproxy.cfg` uses node A as primary and
|
||||
node B as standby. It is a deployment recipe, not an embedded cluster or a
|
||||
validated production cluster.
|
||||
|
||||
## Node layout
|
||||
|
||||
| Component | Node A | Node B |
|
||||
| --- | --- | --- |
|
||||
| Host | `10.20.0.11` | `10.20.0.12` |
|
||||
| Gateway listener | `0.0.0.0:3307` | `0.0.0.0:3307` |
|
||||
| Management listener | `10.20.0.11:8080` | `10.20.0.12:8080` |
|
||||
| SQLite | `/var/lib/serviceport-a/serviceport.sqlite` | `/var/lib/serviceport-b/serviceport.sqlite` |
|
||||
| Audit | Node A data directory | Node B data directory |
|
||||
|
||||
Use the same serviceport release and gateway/target configuration on both
|
||||
nodes. Adapt the environment examples to your service manager and load values
|
||||
from your secret manager. The `.env.example` files are templates; serviceport
|
||||
does not load them automatically. Restrict network access to gateway ports and
|
||||
use an authenticated HTTPS reverse proxy or an SSH tunnel for management.
|
||||
Firewall management port 8080 to the load balancer and operator network only;
|
||||
the listener itself is HTTP and must not be exposed to untrusted networks.
|
||||
|
||||
**Never let two instances open the same SQLite database.** Do not put SQLite,
|
||||
its WAL files, or audit files on a shared network volume for this arrangement.
|
||||
Management sessions, configuration revisions, metrics, and audit records are
|
||||
independent on each node. There is no distributed leader election, replication,
|
||||
or shared session state.
|
||||
|
||||
## Provision the standby
|
||||
|
||||
1. Configure and test the primary against isolated test accounts first. Confirm
|
||||
both nodes can reach the configured target services and outbound transports.
|
||||
2. Stop the owned primary service before taking an offline snapshot. Back up
|
||||
the complete data directory, including the database, any WAL files, audit
|
||||
directory, and TLS material. Keep the original backup immutable.
|
||||
3. Restore a copy of the snapshot into the standby's own data directory. Supply
|
||||
the same externally managed master key required to decrypt the snapshot.
|
||||
A Windows `master-key.dpapi` is tied to its original Windows user and cannot
|
||||
simply be copied to another machine as a portable key. For that case,
|
||||
provision credentials through the destination's management interface or
|
||||
use `POST /api/v1/configuration/backup` with a passphrase and
|
||||
`POST /api/v1/configuration/import` with the encrypted backup and passphrase.
|
||||
4. Configure each node's gateway bind address and port. Provision gateway TLS
|
||||
certificates valid for the public load-balancer hostname. Pin target SSH
|
||||
host keys independently and check their expected fingerprints.
|
||||
5. Start both services and their bound gateways, check management `/readyz`
|
||||
locally, test each gateway
|
||||
directly with a disposable account, and only then enable load-balancer
|
||||
routing. An offline data copy inherits configuration; it does not establish
|
||||
ongoing synchronization.
|
||||
|
||||
Future configuration changes must be applied to both nodes explicitly. Keep
|
||||
one authoritative change record, back up each node before an update, and verify
|
||||
target/account mappings, account permissions, TLS identity and listener ports
|
||||
on both nodes. A configuration import does not merge active sessions or audit
|
||||
history. Avoid overwriting a running node's database to synchronize it.
|
||||
Preview the import on the destination node, stop all its gateways, and submit
|
||||
the returned revision with the import. A stale preview is rejected with HTTP
|
||||
409. Imported gateways remain stopped until explicitly started. Templates and
|
||||
redacted exports omit credentials; encrypted configuration backups retain them.
|
||||
Health-check intervals persist locally and resume after restart, checking every
|
||||
target account. Configure and verify monitoring on each node independently.
|
||||
|
||||
## Health and failover
|
||||
|
||||
The example HAProxy checks call each node's management `/readyz` endpoint.
|
||||
It returns HTTP 200 with `{"status":"ready"}` only when at least one gateway
|
||||
has a bound target and all bound gateways have running listeners. Unbound
|
||||
drafts are ignored. Empty configuration, a stopped bound gateway, or a
|
||||
configuration read failure returns HTTP 503 with `{"status":"not_ready"}`.
|
||||
The unauthenticated response contains no configuration or credential details
|
||||
and disables caching. `/healthz` remains a process-liveness endpoint.
|
||||
|
||||
Readiness is node-wide: stopping any bound gateway removes the node from all
|
||||
backends using this probe, even when another gateway is still running. Drain
|
||||
the node before planned maintenance. Readiness checks listener state, not
|
||||
target connectivity, target authentication or matching configuration between
|
||||
nodes. Use
|
||||
`POST /api/v1/monitoring/check` with `targetId` and `accountId` for an authenticated
|
||||
protocol health probe. Periodic checks are enabled explicitly through
|
||||
`POST /api/v1/monitoring/settings` with `intervalSeconds` between 10 and 86400;
|
||||
zero disables them. Settings and metrics reset after service restart. Periodic
|
||||
checks select the first configured target account and do not automatically
|
||||
change load-balancer membership.
|
||||
|
||||
Health probe retries are limited to three attempts and 25 seconds total.
|
||||
Authentication and certificate errors are not retried. These probes never
|
||||
replay traffic from a real gateway session. Do not replace the example health
|
||||
check with recurring unauthenticated connections to protocol ports: those
|
||||
probes inflate socket-attempt and unauthenticated-closure metrics. Verify
|
||||
gateway listener availability with controlled authenticated client checks
|
||||
when changing configuration. Interpret connection and authentication failure
|
||||
rates separately.
|
||||
|
||||
The standby receives new connections after the primary fails its checks.
|
||||
Existing database transactions, subscriptions, cursors and SSH sessions cannot
|
||||
be transferred: clients must reconnect, and application owners must decide
|
||||
whether an operation can be retried. Serviceport does not replay commands.
|
||||
With the example's 5-second interval and three failures, detection takes
|
||||
roughly 15 seconds plus probe timeout. Tune timeouts for the application's
|
||||
long-lived sessions. The load balancer itself also needs redundancy.
|
||||
|
||||
TCP proxying changes the peer IP visible to serviceport to the load balancer's
|
||||
address. Source-IP rules therefore match that address; enforce original-client
|
||||
IP restrictions at the load balancer/firewall. Do not enable HAProxy
|
||||
`send-proxy`: serviceport does not consume the PROXY protocol preamble.
|
||||
|
||||
## Validation before production
|
||||
|
||||
Use isolated databases, credentials and ports to verify direct authentication
|
||||
on both nodes, rejection of disabled accounts, account permissions, TLS and SSH
|
||||
identity checks, failover of new connections, client reconnect behavior, and
|
||||
return of the primary. Validate the HAProxy configuration with
|
||||
`haproxy -c -f deploy/ha/haproxy.cfg` in the deployment environment. This example
|
||||
has not been exercised against a live two-node cluster.
|
||||
@@ -0,0 +1,7 @@
|
||||
# SSH 连接测试
|
||||
|
||||
独立「代理」菜单的 SSH 转发项支持“测试连接”,验证跳板机连通性、已保存的主机指纹和账号密码,显示服务器版本、指纹与耗时。测试结束会关闭 SSH 连接,不执行远程命令。
|
||||
|
||||
此测试不打开目标通道。验证转发权限、目标可达性和目标认证,请在「目标服务」中测试连接。SSH 目标测试前仍须先探测并确认目标主机指纹。
|
||||
|
||||
验证使用进程内 SSH 测试服务器,覆盖认证失败、指纹不匹配、取消和连接清理;界面成功/失败展示使用模拟 API 响应验证。
|
||||
@@ -0,0 +1,39 @@
|
||||
# Target Accounts
|
||||
|
||||
A target maintains one account list for all protocols. Accounts share the endpoint,
|
||||
proxy route and SSH host identity. Each account has a stable ID, label, username
|
||||
and password, or SSH private key and passphrase. Between 1 and 32 accounts are
|
||||
supported. There is no separate primary credential form.
|
||||
|
||||
In the target editor, use the accounts tab to maintain accounts. In the gateway
|
||||
editor, select the target and then its target account. This development version
|
||||
does not support the old credential schema. Old targets require accounts to be
|
||||
configured again; old gateways require an explicit account selection before they
|
||||
can start. There is no reserved default account or credential migration.
|
||||
Changing targets resets the account selection: a single account is selected
|
||||
automatically, while multiple accounts require an explicit choice. Dragging a
|
||||
gateway to a multi-account target requires selecting the account in its editor.
|
||||
|
||||
The gateway authenticates clients with gateway credentials and connects upstream
|
||||
with the selected account. A root account is a direct root login, not sudo or
|
||||
automatic elevation. The target SSH server must allow that login. Authentication
|
||||
failure does not fall back to another account.
|
||||
|
||||
Accounts are encrypted with the existing target configuration in SQLite. Public
|
||||
target and gateway responses redact passwords, private keys and passphrases.
|
||||
Omitted credentials retain their values by account ID; explicit empty values in
|
||||
the API clear them. The editor retains blank credential inputs and has an explicit
|
||||
passphrase-clear checkbox. Changing an account ID creates a distinct identity.
|
||||
|
||||
Referenced accounts cannot be removed until all gateways using them are rebound
|
||||
or deleted. Changing a gateway account requires stopping that gateway. Target
|
||||
connection/account changes require stopping all referencing gateways, consistent
|
||||
with the existing target configuration policy.
|
||||
|
||||
Do not expose the development management UI to gateway clients. Production
|
||||
management requires authentication; administrators can access existing credential
|
||||
reveal functions. Gateway authentication does not grant management API access.
|
||||
|
||||
Validation covers account selection, redaction, credential retention, deletion
|
||||
protection and local SSH fixture authentication. It does not establish that a
|
||||
particular production host permits root login.
|
||||
@@ -5,6 +5,7 @@ go 1.25.0
|
||||
require (
|
||||
github.com/go-mysql-org/go-mysql v1.13.0
|
||||
github.com/jackc/pgx/v5 v5.7.6
|
||||
github.com/microsoft/go-mssqldb v1.9.3
|
||||
github.com/pkg/sftp v1.13.9
|
||||
github.com/rabbitmq/amqp091-go v1.10.0
|
||||
github.com/xdg-go/scram v1.2.0
|
||||
@@ -31,6 +32,8 @@ require (
|
||||
github.com/go-openapi/swag v0.23.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
|
||||
github.com/golang-sql/sqlexp v0.1.0 // indirect
|
||||
github.com/golang/glog v1.2.4 // indirect
|
||||
github.com/google/gnostic-models v0.7.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
|
||||
@@ -34,6 +34,10 @@ github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
||||
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
|
||||
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
|
||||
github.com/golang/glog v1.2.4 h1:CNNw5U8lSiiBk7druxtSHHTsRWcxKoac6kZKm2peBBc=
|
||||
github.com/golang/glog v1.2.4/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
@@ -81,6 +85,8 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/microsoft/go-mssqldb v1.9.3 h1:hy4p+LDC8LIGvI3JATnLVmBOLMJbmn5X400mr5j0lPs=
|
||||
github.com/microsoft/go-mssqldb v1.9.3/go.mod h1:GBbW9ASTiDC+mpgWDGKdm3FnFLTUsLYN3iFL90lQ+PA=
|
||||
github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU=
|
||||
github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
// Package alerts evaluates operational conditions without including credentials in notifications.
|
||||
package alerts
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"serviceport/internal/gateway"
|
||||
"serviceport/internal/model"
|
||||
"serviceport/internal/store"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Source interface {
|
||||
HealthSnapshot() (int, []gateway.HealthResult)
|
||||
Metrics(string) gateway.Metrics
|
||||
}
|
||||
type sample struct {
|
||||
at time.Time
|
||||
count int64
|
||||
}
|
||||
type Engine struct {
|
||||
store *store.Store
|
||||
source Source
|
||||
mu sync.Mutex
|
||||
lifecycle sync.Mutex
|
||||
samples map[string][]sample
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func New(s *store.Store, source Source) *Engine {
|
||||
return &Engine{store: s, source: source, samples: map[string][]sample{}, client: &http.Client{Timeout: 5 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}}
|
||||
}
|
||||
func (e *Engine) Start() {
|
||||
e.lifecycle.Lock()
|
||||
defer e.lifecycle.Unlock()
|
||||
if e.cancel != nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
e.cancel = cancel
|
||||
e.done = make(chan struct{})
|
||||
go func() {
|
||||
defer close(e.done)
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
e.Check(ctx)
|
||||
e.Deliver(ctx)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
func (e *Engine) Close() {
|
||||
e.lifecycle.Lock()
|
||||
defer e.lifecycle.Unlock()
|
||||
if e.cancel != nil {
|
||||
e.cancel()
|
||||
<-e.done
|
||||
e.cancel = nil
|
||||
}
|
||||
}
|
||||
|
||||
// Check only updates in-app conditions. It never contacts configured webhooks.
|
||||
func (e *Engine) Check(ctx context.Context) error {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
return e.check(ctx, time.Now().UTC())
|
||||
}
|
||||
func (e *Engine) check(ctx context.Context, now time.Time) error {
|
||||
c, err := e.store.AlertSettings()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ps, err := e.store.List()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ts, err := e.store.Targets()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
put := func(a store.Alert, active bool) error {
|
||||
seen[a.Key] = true
|
||||
return e.store.ReconcileAlert(a, active, c.WebhookEnabled, now)
|
||||
}
|
||||
targets := map[string]bool{}
|
||||
for _, t := range ts {
|
||||
targets[t.ID] = true
|
||||
}
|
||||
_, health := e.source.HealthSnapshot()
|
||||
for _, h := range health {
|
||||
if !targets[h.TargetID] {
|
||||
continue
|
||||
}
|
||||
a := store.Alert{Key: "target_unavailable:" + h.TargetID + ":" + h.AccountID, Kind: "target_unavailable", ResourceID: h.TargetID, AccountID: h.AccountID, Severity: "critical", Message: "Target health check failed"}
|
||||
if err = put(a, !h.Healthy); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, p := range ps {
|
||||
if err = ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
count := e.source.Metrics(p.ID).AuthenticationFailed
|
||||
samples := e.samples[p.ID]
|
||||
if len(samples) > 0 && count < samples[len(samples)-1].count {
|
||||
samples = nil
|
||||
}
|
||||
samples = append(samples, sample{now, count})
|
||||
cut := now.Add(-time.Duration(c.AuthFailureWindowSeconds) * time.Second)
|
||||
// Keep the most recent baseline at or before the window boundary.
|
||||
for len(samples) > 1 && !samples[1].at.After(cut) {
|
||||
samples = samples[1:]
|
||||
}
|
||||
e.samples[p.ID] = samples
|
||||
delta := count - samples[0].count
|
||||
if err = put(store.Alert{Key: "auth_failure_spike:" + p.ID, Kind: "auth_failure_spike", ResourceID: p.ID, Severity: "warning", Message: "Gateway authentication failures exceeded the configured window threshold"}, delta >= int64(c.AuthFailureThreshold)); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, a := range p.GatewayAccounts {
|
||||
if err = e.credential(put, "gateway", p.ID, a.ID, a.ExpiresAt, a.RotationReminderDays, a.Disabled, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, t := range ts {
|
||||
for _, a := range t.Accounts {
|
||||
if err = e.credential(put, "target", t.ID, a.ID, a.ExpiresAt, a.RotationReminderDays, false, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if certs, ok := e.source.(interface{ CertificateExpirations() map[string]time.Time }); ok {
|
||||
for id, expiry := range certs.CertificateExpirations() {
|
||||
a := store.Alert{Key: "certificate_expiry:" + id, Kind: "certificate_expiring", ResourceID: id, Severity: "warning", Message: "TLS certificate expires soon"}
|
||||
if !expiry.After(now) {
|
||||
a.Kind = "certificate_expired"
|
||||
a.Severity = "critical"
|
||||
a.Message = "TLS certificate has expired"
|
||||
}
|
||||
if err = put(a, !expiry.After(now.Add(time.Duration(c.CertificateReminderDays)*24*time.Hour))); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
old, err := e.store.Alerts()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, a := range old {
|
||||
if !seen[a.Key] && a.Status == "active" {
|
||||
if err = e.store.ReconcileAlert(a, false, c.WebhookEnabled, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return e.store.PruneAlerts(now)
|
||||
}
|
||||
func (e *Engine) credential(put func(store.Alert, bool) error, scope, id, account, expiry string, days int, disabled bool, now time.Time) error {
|
||||
due, expired := model.CredentialDue(expiry, days, now)
|
||||
a := store.Alert{Key: "credential:" + scope + ":" + id + ":" + account, Kind: "credential_expiring", ResourceID: id, AccountID: account, Severity: "warning", Message: "Account credential expires soon; rotate it before expiry"}
|
||||
if expired {
|
||||
a.Kind = "credential_expired"
|
||||
a.Severity = "critical"
|
||||
a.Message = "Account credential has expired"
|
||||
}
|
||||
return put(a, due && !disabled)
|
||||
}
|
||||
|
||||
// Deliver is called only by the worker after the operator explicitly enables notifications.
|
||||
// Each transition gets at most three attempts, separated by worker ticks.
|
||||
func (e *Engine) Deliver(ctx context.Context) error {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
c, err := e.store.AlertSettings()
|
||||
if err != nil || !c.WebhookEnabled {
|
||||
return err
|
||||
}
|
||||
items, err := e.store.Alerts()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, a := range items {
|
||||
if a.DeliveryStatus != "pending" {
|
||||
continue
|
||||
}
|
||||
if err = ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
body, _ := json.Marshal(a)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.WebhookURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if c.WebhookSecret != "" {
|
||||
mac := hmac.New(sha256.New, []byte(c.WebhookSecret))
|
||||
mac.Write(body)
|
||||
req.Header.Set("X-Serviceport-Signature", "sha256="+hex.EncodeToString(mac.Sum(nil)))
|
||||
}
|
||||
resp, sendErr := e.client.Do(req)
|
||||
status := "pending"
|
||||
if sendErr == nil {
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
status = "sent"
|
||||
}
|
||||
}
|
||||
if status != "sent" && a.DeliveryAttempts >= 2 {
|
||||
status = "failed"
|
||||
}
|
||||
if err = e.store.AlertDelivery(a.ID, a.UpdatedAt, status); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package alerts
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"serviceport/internal/gateway"
|
||||
"serviceport/internal/model"
|
||||
"serviceport/internal/security"
|
||||
"serviceport/internal/store"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type fixtureSource struct {
|
||||
health []gateway.HealthResult
|
||||
failures int64
|
||||
certs map[string]time.Time
|
||||
}
|
||||
|
||||
func (s *fixtureSource) HealthSnapshot() (int, []gateway.HealthResult) { return 30, s.health }
|
||||
func (s *fixtureSource) Metrics(string) gateway.Metrics {
|
||||
return gateway.Metrics{AuthenticationFailed: s.failures}
|
||||
}
|
||||
func (s *fixtureSource) CertificateExpirations() map[string]time.Time { return s.certs }
|
||||
func fixtureStore(t *testing.T) *store.Store {
|
||||
t.Helper()
|
||||
v, err := security.NewVault(base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{3}, 32)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err := store.Open(filepath.Join(t.TempDir(), "alerts.sqlite"), v)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { s.Close() })
|
||||
return s
|
||||
}
|
||||
|
||||
func TestConditionsDeduplicateAcknowledgeAndRecover(t *testing.T) {
|
||||
s := fixtureStore(t)
|
||||
now := time.Now().UTC()
|
||||
p := model.Defaults()
|
||||
p.ID, p.TargetHost = "gateway", ""
|
||||
p.GatewayAccounts = []model.GatewayAccount{{ID: "client", Username: "client", Password: "secret", ExpiresAt: now.Add(time.Hour).Format(time.RFC3339)}}
|
||||
if err := s.Save(p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target := model.Target{ID: "target", Accounts: []model.TargetAccount{{ID: "upstream", Username: "upstream", Password: "secret", ExpiresAt: now.Add(-time.Hour).Format(time.RFC3339)}}}
|
||||
if err := s.SaveTarget(target); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := &fixtureSource{health: []gateway.HealthResult{{TargetID: "target", AccountID: "upstream", Healthy: false}}, certs: map[string]time.Time{"gateway": now.Add(time.Hour)}}
|
||||
e := New(s, source)
|
||||
check := func(at time.Time) {
|
||||
t.Helper()
|
||||
if err := e.check(context.Background(), at); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
check(now)
|
||||
source.failures = 10
|
||||
check(now.Add(30 * time.Second))
|
||||
items, err := s.Alerts()
|
||||
if err != nil || len(items) != 5 {
|
||||
t.Fatalf("alerts=%+v err=%v", items, err)
|
||||
}
|
||||
for _, a := range items {
|
||||
if err := s.AcknowledgeAlert(a.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
check(now.Add(31 * time.Second))
|
||||
items, _ = s.Alerts()
|
||||
for _, a := range items {
|
||||
if a.AcknowledgedAt == "" || a.DeliveryStatus != "disabled" {
|
||||
t.Fatalf("dedupe lost state: %+v", a)
|
||||
}
|
||||
}
|
||||
source.health[0].Healthy = true
|
||||
source.certs = nil
|
||||
p.GatewayAccounts[0].ExpiresAt = ""
|
||||
target.Accounts[0].ExpiresAt = ""
|
||||
if err := s.Save(p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.SaveTarget(target); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
check(now.Add(2 * time.Minute))
|
||||
items, _ = s.Alerts()
|
||||
for _, a := range items {
|
||||
if a.Status != "resolved" {
|
||||
t.Fatalf("condition did not recover: %+v", a)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookSignatureRetriesAndDisabledDelivery(t *testing.T) {
|
||||
s := fixtureStore(t)
|
||||
calls := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
mac := hmac.New(sha256.New, []byte("fixture-signing-key"))
|
||||
mac.Write(body)
|
||||
if r.Header.Get("X-Serviceport-Signature") != "sha256="+hex.EncodeToString(mac.Sum(nil)) {
|
||||
t.Error("invalid webhook signature")
|
||||
}
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer server.Close()
|
||||
c := store.DefaultAlertSettings()
|
||||
c.WebhookURL = server.URL
|
||||
c.WebhookSecret = "fixture-signing-key"
|
||||
if err := s.SaveAlertSettings(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.ReconcileAlert(store.Alert{Key: "fixture", Kind: "target_unavailable"}, true, true, time.Now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e := New(s, &fixtureSource{})
|
||||
if err := e.Deliver(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls != 0 {
|
||||
t.Fatal("disabled webhook sent a request")
|
||||
}
|
||||
c.WebhookEnabled = true
|
||||
if err := s.SaveAlertSettings(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 4; i++ {
|
||||
if err := e.Deliver(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
items, _ := s.Alerts()
|
||||
if calls != 3 || len(items) != 1 || items[0].DeliveryStatus != "failed" || items[0].DeliveryAttempts != 3 {
|
||||
t.Fatalf("calls=%d alerts=%+v", calls, items)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (s *Server) alertRoutes(w http.ResponseWriter, r *http.Request, path string) {
|
||||
if path == "alerts" && r.Method == "GET" {
|
||||
a, e := s.store.Alerts()
|
||||
if e != nil {
|
||||
fail(w, 500, "cannot read alerts")
|
||||
return
|
||||
}
|
||||
respond(w, 200, a)
|
||||
return
|
||||
}
|
||||
if path == "alerts/check" && r.Method == "POST" {
|
||||
if e := s.alerts.Check(r.Context()); e != nil {
|
||||
fail(w, 500, "alert check failed")
|
||||
return
|
||||
}
|
||||
respond(w, 200, map[string]bool{"ok": true})
|
||||
return
|
||||
}
|
||||
if path == "alerts/settings" && (r.Method == "GET" || r.Method == "PUT") {
|
||||
c, e := s.store.AlertSettings()
|
||||
if e != nil {
|
||||
fail(w, 500, "cannot read alert settings")
|
||||
return
|
||||
}
|
||||
if r.Method == "PUT" {
|
||||
var body struct {
|
||||
WebhookEnabled *bool `json:"webhookEnabled"`
|
||||
WebhookURL string `json:"webhookURL"`
|
||||
WebhookSecret string `json:"webhookSecret"`
|
||||
ClearWebhook bool `json:"clearWebhook"`
|
||||
AuthFailureThreshold *int `json:"authFailureThreshold"`
|
||||
AuthFailureWindowSeconds *int `json:"authFailureWindowSeconds"`
|
||||
CertificateReminderDays *int `json:"certificateReminderDays"`
|
||||
HasWebhookURL bool `json:"hasWebhookURL"`
|
||||
HasWebhookSecret bool `json:"hasWebhookSecret"`
|
||||
}
|
||||
if !decode(w, r, &body) {
|
||||
return
|
||||
}
|
||||
if body.WebhookEnabled != nil {
|
||||
c.WebhookEnabled = *body.WebhookEnabled
|
||||
}
|
||||
if body.WebhookURL != "" {
|
||||
c.WebhookURL = body.WebhookURL
|
||||
}
|
||||
if body.WebhookSecret != "" {
|
||||
c.WebhookSecret = body.WebhookSecret
|
||||
}
|
||||
if body.ClearWebhook {
|
||||
c.WebhookURL = ""
|
||||
c.WebhookSecret = ""
|
||||
c.WebhookEnabled = false
|
||||
}
|
||||
if body.AuthFailureThreshold != nil {
|
||||
c.AuthFailureThreshold = *body.AuthFailureThreshold
|
||||
}
|
||||
if body.AuthFailureWindowSeconds != nil {
|
||||
c.AuthFailureWindowSeconds = *body.AuthFailureWindowSeconds
|
||||
}
|
||||
if body.CertificateReminderDays != nil {
|
||||
c.CertificateReminderDays = *body.CertificateReminderDays
|
||||
}
|
||||
if e = c.Validate(); e != nil {
|
||||
fail(w, 400, e.Error())
|
||||
return
|
||||
}
|
||||
if e = s.store.SaveAlertSettings(c); e != nil {
|
||||
fail(w, 500, "cannot save alert settings")
|
||||
return
|
||||
}
|
||||
s.store.Event("", source(r), "alert_settings_updated", "")
|
||||
}
|
||||
respond(w, 200, map[string]any{"webhookEnabled": c.WebhookEnabled, "webhookURL": "", "webhookSecret": "", "hasWebhookURL": c.WebhookURL != "", "hasWebhookSecret": c.WebhookSecret != "", "authFailureThreshold": c.AuthFailureThreshold, "authFailureWindowSeconds": c.AuthFailureWindowSeconds, "certificateReminderDays": c.CertificateReminderDays})
|
||||
return
|
||||
}
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) == 3 && parts[0] == "alerts" && parts[2] == "ack" && r.Method == "POST" {
|
||||
id, e := strconv.ParseInt(parts[1], 10, 64)
|
||||
if e != nil || id < 1 {
|
||||
fail(w, 400, "invalid alert ID")
|
||||
return
|
||||
}
|
||||
e = s.store.AcknowledgeAlert(id)
|
||||
if e == sql.ErrNoRows {
|
||||
fail(w, 404, "alert not found")
|
||||
return
|
||||
}
|
||||
if e != nil {
|
||||
fail(w, 500, "cannot acknowledge alert")
|
||||
return
|
||||
}
|
||||
s.store.Event("", source(r), "alert_acknowledged", parts[1])
|
||||
respond(w, 200, map[string]bool{"ok": true})
|
||||
return
|
||||
}
|
||||
fail(w, 405, "method not allowed")
|
||||
}
|
||||
+11
-3
@@ -1,9 +1,9 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"serviceport/internal/model"
|
||||
"serviceport/internal/security"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -43,7 +43,7 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
token := security.Token()
|
||||
sess := session{b.Username, security.Token(), rev, time.Now().Add(12 * time.Hour)}
|
||||
sess := session{User: b.Username, CSRF: security.Token(), Revision: rev, Expires: time.Now().Add(12 * time.Hour)}
|
||||
s.mu.Lock()
|
||||
for k, v := range s.sessions {
|
||||
if time.Now().After(v.Expires) {
|
||||
@@ -53,7 +53,12 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
||||
s.sessions[token] = sess
|
||||
s.mu.Unlock()
|
||||
http.SetCookie(w, &http.Cookie{Name: "serviceport_session", Value: token, Path: "/", HttpOnly: true, Secure: s.secure, SameSite: http.SameSiteStrictMode, MaxAge: 43200})
|
||||
respond(w, 200, map[string]string{"username": sess.User, "csrfToken": sess.CSRF})
|
||||
identity, _, err := s.store.Identity(sess.User)
|
||||
if err != nil {
|
||||
fail(w, 500, "cannot load user identity")
|
||||
return
|
||||
}
|
||||
respond(w, 200, map[string]string{"username": sess.User, "csrfToken": sess.CSRF, "role": identity.Role})
|
||||
}
|
||||
|
||||
func (s *Server) reveal(w http.ResponseWriter, r *http.Request, p model.Proxy, sess session, token string, allowedFields ...string) {
|
||||
@@ -84,6 +89,9 @@ func (s *Server) reveal(w http.ResponseWriter, r *http.Request, p model.Proxy, s
|
||||
}
|
||||
}
|
||||
secrets := map[string]string{"targetPassword": p.TargetPassword, "gatewayPassword": p.GatewayPassword, "socksPassword": p.Outbound.Password, "sshPassphrase": p.SSH.Passphrase}
|
||||
for _, account := range p.GatewayAccounts {
|
||||
secrets["gatewayAccount:"+account.ID+":password"] = account.Password
|
||||
}
|
||||
value, exists := secrets[body.Field]
|
||||
if !exists {
|
||||
fail(w, 400, "unsupported secret field")
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"serviceport/internal/model"
|
||||
"serviceport/internal/store"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAPITokenScopesMetricsAndRevocation(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
f.login()
|
||||
w := f.request("POST", "/api/v1/tokens", map[string]any{"name": "metrics-ci", "scopes": []string{"metrics"}, "expiresAt": time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339)}, true)
|
||||
requireStatus(t, w, 201)
|
||||
var created struct {
|
||||
Token struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"token"`
|
||||
Secret string `json:"secret"`
|
||||
}
|
||||
if json.Unmarshal(w.Body.Bytes(), &created) != nil || created.Secret == "" {
|
||||
t.Fatal("token secret missing")
|
||||
}
|
||||
req := httptest.NewRequest("GET", "https://serviceport.test/api/v1/metrics", bytes.NewReader(nil))
|
||||
req.Header.Set("Authorization", "Bearer "+created.Secret)
|
||||
req.RemoteAddr = "127.0.0.1:12345"
|
||||
rw := httptest.NewRecorder()
|
||||
f.handler.ServeHTTP(rw, req)
|
||||
requireStatus(t, rw, 200)
|
||||
if !strings.Contains(rw.Body.String(), "serviceport_") {
|
||||
t.Fatal("metrics output missing")
|
||||
}
|
||||
if err := f.store.RevokeToken(created.Token.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rw = httptest.NewRecorder()
|
||||
f.handler.ServeHTTP(rw, req)
|
||||
requireStatus(t, rw, 401)
|
||||
}
|
||||
|
||||
func TestAutomationScopesAndCredentialIsolation(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
f.login()
|
||||
if err := f.store.SaveOutbound(model.OutboundProfile{ID: "kube", Name: "kube", Outbound: model.Outbound{Mode: "k8s", KubeConfig: "private-kube-secret"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
template, err := f.store.SaveTemplate("private", store.Bundle{Version: 1, Gateways: []model.Proxy{{ID: "g", GatewayPassword: "private-gateway-secret", SSH: model.SSHConfig{HostKey: "private-key-secret"}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
call := func(secret, method, path string) *httptest.ResponseRecorder {
|
||||
r := httptest.NewRequest(method, "https://serviceport.test/api/v1/"+path, nil)
|
||||
r.Header.Set("Authorization", "Bearer "+secret)
|
||||
w := httptest.NewRecorder()
|
||||
f.handler.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
for _, scope := range []string{"read", "write", "audit", "metrics"} {
|
||||
_, secret, err := f.store.CreateToken(store.APIToken{Name: scope, Owner: "admin", Scopes: []string{scope}, ExpiresAt: time.Now().Add(time.Hour).UTC().Format(time.RFC3339)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range []string{"users", "tokens", "alerts/settings"} {
|
||||
requireStatus(t, call(secret, "GET", path), 403)
|
||||
}
|
||||
for _, path := range []string{"configuration/backup", "tokens", "proxies/g/reveal-password"} {
|
||||
requireStatus(t, call(secret, "POST", path), 403)
|
||||
}
|
||||
if scope == "read" {
|
||||
for _, path := range []string{"outbounds", "outbounds/kube", "templates/" + template.ID} {
|
||||
w := call(secret, "GET", path)
|
||||
requireStatus(t, w, 200)
|
||||
if strings.Contains(w.Body.String(), "private-") {
|
||||
t.Fatal("token leaked credentials", path)
|
||||
}
|
||||
}
|
||||
requireStatus(t, call(secret, "POST", "proxies"), 403)
|
||||
} else {
|
||||
requireStatus(t, call(secret, "GET", "outbounds"), 403)
|
||||
}
|
||||
}
|
||||
_, secret, err := f.store.CreateToken(store.APIToken{Name: "revoked-on-change", Owner: "admin", Scopes: []string{"read"}, ExpiresAt: time.Now().Add(time.Hour).UTC().Format(time.RFC3339)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = f.store.ResetPassword("replacement-admin-password"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requireStatus(t, call(secret, "GET", "outbounds"), 401)
|
||||
}
|
||||
|
||||
func TestTokenWriteResponseHidesRetainedCredentials(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
f.login()
|
||||
profile := model.OutboundProfile{ID: "kube-write", Name: "before", Outbound: model.Outbound{Mode: "k8s", Resource: "pod/database", Namespace: "default", KubeConfig: "private-kubeconfig-path"}}
|
||||
if err := f.store.SaveOutbound(profile); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, secret, err := f.store.CreateToken(store.APIToken{Name: "writer", Owner: "admin", Scopes: []string{"write"}, ExpiresAt: time.Now().Add(time.Hour).UTC().Format(time.RFC3339)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest("PUT", "https://serviceport.test/api/v1/outbounds/kube-write", strings.NewReader(`{"name":"after"}`))
|
||||
req.Header.Set("Authorization", "Bearer "+secret)
|
||||
w := httptest.NewRecorder()
|
||||
f.handler.ServeHTTP(w, req)
|
||||
requireStatus(t, w, 200)
|
||||
if strings.Contains(w.Body.String(), "private-kubeconfig-path") {
|
||||
t.Fatal("write response exposed retained credential")
|
||||
}
|
||||
stored, err := f.store.GetOutbound(profile.ID)
|
||||
if err != nil || stored.Name != "after" || stored.Outbound.KubeConfig != profile.Outbound.KubeConfig {
|
||||
t.Fatal("redaction changed stored configuration", err)
|
||||
}
|
||||
var response struct {
|
||||
SecretStatus map[string]bool `json:"secretStatus"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
||||
t.Fatal("credential presence flags changed type", err)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"serviceport/internal/store"
|
||||
"encoding/csv"
|
||||
"net/http"
|
||||
"os"
|
||||
"serviceport/internal/store"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"serviceport/internal/model"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"serviceport/internal/model"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"serviceport/internal/store"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (s *Server) configurationStopped(w http.ResponseWriter) bool {
|
||||
ps, e := s.store.List()
|
||||
if e != nil {
|
||||
fail(w, 500, "cannot load gateways")
|
||||
return false
|
||||
}
|
||||
for _, p := range ps {
|
||||
if s.manager.Status(p.ID).Running {
|
||||
fail(w, 409, "stop all gateways before importing or restoring configuration")
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
func (s *Server) configuration(w http.ResponseWriter, r *http.Request, path string) {
|
||||
action := strings.TrimPrefix(path, "configuration/")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
switch action {
|
||||
case "diff":
|
||||
if r.Method != "GET" {
|
||||
fail(w, 405, "method not allowed")
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
revision, err := strconv.ParseInt(q.Get("revision"), 10, 64)
|
||||
if err != nil || revision < 1 {
|
||||
fail(w, 400, "invalid revision")
|
||||
return
|
||||
}
|
||||
changes, err := s.store.ConfigurationDiff(q.Get("kind"), q.Get("id"), revision)
|
||||
if err != nil {
|
||||
fail(w, 400, "cannot compare configuration revision")
|
||||
return
|
||||
}
|
||||
respond(w, 200, map[string]any{"revision": revision, "changes": changes})
|
||||
case "export":
|
||||
if r.Method != "GET" {
|
||||
fail(w, 405, "method not allowed")
|
||||
return
|
||||
}
|
||||
b, e := s.store.ExportBundle()
|
||||
if e != nil {
|
||||
fail(w, 500, "configuration export failed")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="serviceport-config.json"`)
|
||||
respond(w, 200, b)
|
||||
case "backup":
|
||||
if r.Method != "POST" {
|
||||
fail(w, 405, "method not allowed")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Passphrase string `json:"passphrase"`
|
||||
}
|
||||
if !decode(w, r, &body) {
|
||||
return
|
||||
}
|
||||
b, e := s.store.Backup(body.Passphrase)
|
||||
if e != nil {
|
||||
fail(w, 400, "backup failed; passphrase must contain 12-1024 bytes")
|
||||
return
|
||||
}
|
||||
s.store.Event("", source(r), "configuration_backup", "")
|
||||
respond(w, 200, b)
|
||||
case "import":
|
||||
if r.Method != "POST" {
|
||||
fail(w, 405, "method not allowed")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
ExpectedRevision string `json:"expectedRevision"`
|
||||
Bundle *store.Bundle `json:"bundle"`
|
||||
Backup *store.EncryptedBackup `json:"backup"`
|
||||
Passphrase string `json:"passphrase"`
|
||||
}
|
||||
if !decodeLimit(w, r, &body, 24<<20) {
|
||||
return
|
||||
}
|
||||
if (body.Bundle == nil) == (body.Backup == nil) {
|
||||
fail(w, 400, "provide exactly one bundle or encrypted backup")
|
||||
return
|
||||
}
|
||||
var b store.Bundle
|
||||
if body.Backup != nil {
|
||||
var e error
|
||||
b, e = store.OpenBackup(*body.Backup, body.Passphrase)
|
||||
if e != nil {
|
||||
fail(w, 400, "incorrect passphrase or invalid backup")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
b = *body.Bundle
|
||||
}
|
||||
if !s.configurationStopped(w) {
|
||||
return
|
||||
}
|
||||
if e := s.store.RestoreBundleChecked(b, body.ExpectedRevision); e != nil {
|
||||
if errors.Is(e, store.ErrConfigurationChanged) {
|
||||
fail(w, 409, e.Error())
|
||||
return
|
||||
}
|
||||
fail(w, 400, "configuration import failed: "+e.Error())
|
||||
return
|
||||
}
|
||||
s.store.Event("", source(r), "configuration_imported", "")
|
||||
respond(w, 200, map[string]bool{"ok": true})
|
||||
case "import/preview":
|
||||
if r.Method != "POST" {
|
||||
fail(w, 405, "method not allowed")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Bundle *store.Bundle `json:"bundle"`
|
||||
Backup *store.EncryptedBackup `json:"backup"`
|
||||
Passphrase string `json:"passphrase"`
|
||||
}
|
||||
if !decodeLimit(w, r, &body, 24<<20) {
|
||||
return
|
||||
}
|
||||
if (body.Bundle == nil) == (body.Backup == nil) {
|
||||
fail(w, 400, "provide exactly one bundle or encrypted backup")
|
||||
return
|
||||
}
|
||||
var b store.Bundle
|
||||
var err error
|
||||
if body.Bundle != nil {
|
||||
b = *body.Bundle
|
||||
} else {
|
||||
b, err = store.OpenBackup(*body.Backup, body.Passphrase)
|
||||
}
|
||||
if err != nil {
|
||||
fail(w, 400, "invalid backup")
|
||||
return
|
||||
}
|
||||
preview, err := s.store.PreviewBundle(b)
|
||||
if err != nil {
|
||||
fail(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
respond(w, 200, preview)
|
||||
case "history":
|
||||
if r.Method != "GET" {
|
||||
fail(w, 405, "method not allowed")
|
||||
return
|
||||
}
|
||||
h, e := s.store.History(r.URL.Query().Get("kind"), r.URL.Query().Get("id"))
|
||||
if e != nil {
|
||||
fail(w, 400, "cannot load configuration history")
|
||||
return
|
||||
}
|
||||
respond(w, 200, h)
|
||||
case "restore":
|
||||
if r.Method != "POST" {
|
||||
fail(w, 405, "method not allowed")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Kind string `json:"kind"`
|
||||
ID string `json:"id"`
|
||||
Revision int64 `json:"revision"`
|
||||
}
|
||||
if !decode(w, r, &body) {
|
||||
return
|
||||
}
|
||||
if !s.configurationStopped(w) {
|
||||
return
|
||||
}
|
||||
if e := s.store.RestoreRevision(body.Kind, body.ID, body.Revision); e != nil {
|
||||
fail(w, 400, "configuration revision cannot be restored")
|
||||
return
|
||||
}
|
||||
s.store.Event(body.ID, source(r), "configuration_restored", body.Kind)
|
||||
respond(w, 200, map[string]bool{"ok": true})
|
||||
default:
|
||||
fail(w, 404, "not found")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"serviceport/internal/model"
|
||||
"serviceport/internal/store"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConfigurationBackupHistoryAndStoppedRestore(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
f.login()
|
||||
v := f.create("mysql")
|
||||
w := f.request("GET", "/api/v1/configuration/export", nil, false)
|
||||
requireStatus(t, w, 200)
|
||||
if bytes.Contains(w.Body.Bytes(), []byte("gateway-secret-unique")) {
|
||||
t.Fatal("export leaked password")
|
||||
}
|
||||
requireStatus(t, f.request("POST", "/api/v1/configuration/backup", map[string]string{"passphrase": "long-backup-passphrase"}, false), 403)
|
||||
w = f.request("POST", "/api/v1/configuration/backup", map[string]string{"passphrase": "long-backup-passphrase"}, true)
|
||||
requireStatus(t, w, 200)
|
||||
var backup store.EncryptedBackup
|
||||
if e := json.Unmarshal(w.Body.Bytes(), &backup); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
requireStatus(t, f.request("POST", "/api/v1/configuration/import", map[string]any{"backup": backup, "passphrase": "wrong-long-passphrase"}, true), 400)
|
||||
requireStatus(t, f.request("POST", "/api/v1/proxies/"+v.Config.ID+"/start", nil, true), 200)
|
||||
requireStatus(t, f.request("POST", "/api/v1/configuration/import", map[string]any{"backup": backup, "passphrase": "long-backup-passphrase"}, true), 409)
|
||||
requireStatus(t, f.request("POST", "/api/v1/proxies/"+v.Config.ID+"/stop", nil, true), 200)
|
||||
requireStatus(t, f.request("POST", "/api/v1/configuration/import", map[string]any{"backup": backup, "passphrase": "long-backup-passphrase"}, true), 200)
|
||||
w = f.request("GET", "/api/v1/configuration/history?kind=gateways&id="+v.Config.ID, nil, false)
|
||||
requireStatus(t, w, 200)
|
||||
if bytes.Contains(w.Body.Bytes(), []byte("gateway-secret-unique")) {
|
||||
t.Fatal("history leaked password")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigurationImportLargerThanOrdinaryRequestLimit(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
f.login()
|
||||
target := model.TargetDefaults()
|
||||
target.ID, target.Name, target.Type, target.TargetHost, target.TargetPort = "large-target", "large target", "mysql", "localhost", 3306
|
||||
target.Accounts = []model.TargetAccount{{ID: "account", Name: "account", Username: "alice", Password: strings.Repeat("x", 2<<20)}}
|
||||
bundle := store.Bundle{Version: 1, Targets: []model.Target{target}}
|
||||
requireStatus(t, f.request("POST", "/api/v1/configuration/import", map[string]any{"bundle": bundle}, true), 200)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (s *Server) connectionRoutes(w http.ResponseWriter, r *http.Request, path string) {
|
||||
if path == "connections" && r.Method == "GET" {
|
||||
all := s.manager.Connections()
|
||||
result := all[:0]
|
||||
q := r.URL.Query()
|
||||
for _, c := range all {
|
||||
if id := q.Get("gatewayId"); id != "" && c.GatewayID != id {
|
||||
continue
|
||||
}
|
||||
if username := q.Get("username"); username != "" && c.Username != username {
|
||||
continue
|
||||
}
|
||||
if source := q.Get("source"); source != "" && c.Source != source {
|
||||
continue
|
||||
}
|
||||
result = append(result, c)
|
||||
}
|
||||
respond(w, 200, result)
|
||||
return
|
||||
}
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) != 3 || parts[2] != "disconnect" {
|
||||
fail(w, 404, "connection action not found")
|
||||
return
|
||||
}
|
||||
if r.Method != "POST" {
|
||||
fail(w, 405, "method not allowed")
|
||||
return
|
||||
}
|
||||
connection, ok := s.manager.Disconnect(parts[1])
|
||||
if !ok {
|
||||
fail(w, 404, "connection no longer active")
|
||||
return
|
||||
}
|
||||
s.store.Event(connection.GatewayID, source(r), "connection_disconnected_by_operator", connection.ID)
|
||||
respond(w, 200, map[string]bool{"ok": true})
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"serviceport/internal/store"
|
||||
"encoding/csv"
|
||||
"net/http"
|
||||
"os"
|
||||
"serviceport/internal/store"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUnboundGatewayLifecycle(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
f.login()
|
||||
w := f.request("POST", "/api/v1/proxies", map[string]any{
|
||||
"name": "Unbound", "type": "mysql", "listenHost": "127.0.0.1", "listenPort": freePort(t),
|
||||
"gatewayAccounts": []any{map[string]any{"id": "client", "username": "client", "password": "client-secret"}},
|
||||
}, true)
|
||||
requireStatus(t, w, 201)
|
||||
v := readView(t, w)
|
||||
path := "/api/v1/proxies/" + v.Config.ID
|
||||
requireStatus(t, f.request("GET", path, nil, false), 200)
|
||||
list := f.request("GET", "/api/v1/proxies", nil, false)
|
||||
if !strings.Contains(list.Body.String(), v.Config.ID) {
|
||||
t.Fatal("unbound gateway missing from list")
|
||||
}
|
||||
requireStatus(t, f.request("POST", path+"/start", nil, true), 409)
|
||||
bound := f.create("mysql")
|
||||
requireStatus(t, f.request("PUT", path, map[string]any{"targetId": bound.Config.TargetID, "targetAccountId": bound.Config.TargetAccountID}, true), 200)
|
||||
requireStatus(t, f.request("POST", path+"/start", nil, true), 200)
|
||||
requireStatus(t, f.request("PUT", path, map[string]any{"targetId": ""}, true), 409)
|
||||
requireStatus(t, f.request("POST", path+"/stop", nil, true), 200)
|
||||
w = f.request("PUT", path, map[string]any{"targetId": ""}, true)
|
||||
requireStatus(t, w, 200)
|
||||
v = readView(t, w)
|
||||
if v.Config.TargetID != "" || v.Config.TargetAccountID != "" || v.Config.TargetUser != "" {
|
||||
t.Fatal("detaching retained upstream identity")
|
||||
}
|
||||
requireStatus(t, f.request("POST", path+"/start", nil, true), 409)
|
||||
requireStatus(t, f.request("DELETE", path, nil, true), 200)
|
||||
}
|
||||
|
||||
func TestGatewayAccountsSecretsAndValidation(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
f.login()
|
||||
v := f.create("mysql")
|
||||
path := "/api/v1/proxies/" + v.Config.ID
|
||||
w := f.request("PUT", path, map[string]any{"gatewayAccounts": []any{
|
||||
map[string]any{"id": "first", "username": "first", "password": "first-private-secret"},
|
||||
map[string]any{"id": "second", "username": "second", "password": "second-private-secret", "disabled": true},
|
||||
}}, true)
|
||||
requireStatus(t, w, 200)
|
||||
if strings.Contains(w.Body.String(), "private-secret") {
|
||||
t.Fatal("account password leaked")
|
||||
}
|
||||
v = readView(t, w)
|
||||
if !v.Flags["gatewayAccount:first:password"] || !v.Flags["gatewayAccount:second:password"] {
|
||||
t.Fatal("missing account secret flags")
|
||||
}
|
||||
w = f.request("PUT", path, map[string]any{"gatewayAccounts": []any{
|
||||
map[string]any{"id": "second", "username": "renamed", "disabled": false},
|
||||
map[string]any{"id": "first", "username": "first", "accessMode": "readonly"},
|
||||
}}, true)
|
||||
requireStatus(t, w, 200)
|
||||
p, err := f.store.Get(v.Config.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(p.GatewayAccounts) != 2 || p.GatewayAccounts[0].Password != "second-private-secret" || p.GatewayAccounts[1].Password != "first-private-secret" {
|
||||
t.Fatal("password retention did not follow account ID")
|
||||
}
|
||||
w = f.request("POST", path+"/reveal-password", map[string]any{"field": "gatewayAccount:first:password", "password": adminPassword}, true)
|
||||
requireStatus(t, w, 200)
|
||||
var secret map[string]string
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &secret); err != nil || secret["value"] != "first-private-secret" {
|
||||
t.Fatal("cannot reveal selected account password")
|
||||
}
|
||||
for _, accounts := range []any{
|
||||
[]any{map[string]any{"id": "a", "username": " ", "password": "password"}},
|
||||
[]any{map[string]any{"id": "a", "username": "same", "password": "password"}, map[string]any{"id": "b", "username": "same", "password": "password"}},
|
||||
[]any{map[string]any{"id": "first", "username": "first", "password": ""}},
|
||||
[]any{map[string]any{"id": "first", "username": "first", "Password": "alias"}},
|
||||
} {
|
||||
requireStatus(t, f.request("PUT", path, map[string]any{"gatewayAccounts": accounts}, true), 400)
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,11 @@ func source(r *http.Request) string {
|
||||
}
|
||||
|
||||
func decode(w http.ResponseWriter, r *http.Request, v any) bool {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
||||
return decodeLimit(w, r, v, 1<<20)
|
||||
}
|
||||
|
||||
func decodeLimit(w http.ResponseWriter, r *http.Request, v any, limit int64) bool {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, limit)
|
||||
d := json.NewDecoder(r.Body)
|
||||
d.DisallowUnknownFields()
|
||||
if err := d.Decode(v); err != nil {
|
||||
@@ -32,6 +36,20 @@ func decode(w http.ResponseWriter, r *http.Request, v any) bool {
|
||||
}
|
||||
|
||||
func respond(w http.ResponseWriter, status int, v any) {
|
||||
if _, restricted := w.(tokenResponseWriter); restricted {
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
http.Error(w, "response encoding failed", 500)
|
||||
return
|
||||
}
|
||||
var value any
|
||||
if err = json.Unmarshal(raw, &value); err != nil {
|
||||
http.Error(w, "response encoding failed", 500)
|
||||
return
|
||||
}
|
||||
redactTokenResponse(value)
|
||||
v = value
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// monitoring handles read-only gateway metrics and explicit target probes.
|
||||
// Register it from Server.serve for the `monitoring` path.
|
||||
func (s *Server) monitoring(w http.ResponseWriter, r *http.Request, path string) {
|
||||
if path == "monitoring" && r.Method == "GET" {
|
||||
proxies, err := s.store.List()
|
||||
if err != nil {
|
||||
fail(w, 500, "cannot load gateways")
|
||||
return
|
||||
}
|
||||
out := make([]any, 0, len(proxies))
|
||||
interval, health := s.manager.HealthSnapshot()
|
||||
for _, p := range proxies {
|
||||
metrics := s.manager.Metrics(p.ID)
|
||||
var latest any
|
||||
for _, h := range health {
|
||||
if h.TargetID == p.TargetID && h.AccountID == p.TargetAccountID {
|
||||
latest = h
|
||||
}
|
||||
}
|
||||
out = append(out, map[string]any{"id": p.ID, "metrics": metrics, "health": latest})
|
||||
}
|
||||
respond(w, 200, map[string]any{"gateways": out, "health": health, "intervalSeconds": interval})
|
||||
return
|
||||
}
|
||||
if r.Method == "POST" && path == "monitoring/settings" {
|
||||
var body struct {
|
||||
IntervalSeconds int `json:"intervalSeconds"`
|
||||
}
|
||||
if !decode(w, r, &body) {
|
||||
return
|
||||
}
|
||||
if err := s.manager.SetHealthInterval(body.IntervalSeconds); err != nil {
|
||||
fail(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
respond(w, 200, map[string]int{"intervalSeconds": body.IntervalSeconds})
|
||||
return
|
||||
}
|
||||
if path != "monitoring/check" {
|
||||
fail(w, 404, "not found")
|
||||
return
|
||||
}
|
||||
if r.Method != "POST" {
|
||||
fail(w, 405, "method not allowed")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
TargetID string `json:"targetId"`
|
||||
AccountID string `json:"accountId"`
|
||||
Attempts int `json:"attempts"`
|
||||
}
|
||||
if !decode(w, r, &body) {
|
||||
return
|
||||
}
|
||||
target, err := s.store.GetTarget(body.TargetID)
|
||||
if err != nil {
|
||||
fail(w, 404, "target not found")
|
||||
return
|
||||
}
|
||||
p := target.Runtime()
|
||||
p.TargetAccountID = body.AccountID
|
||||
p, err = target.Resolve(p)
|
||||
if err != nil {
|
||||
fail(w, 400, "target account not found")
|
||||
return
|
||||
}
|
||||
if body.Attempts == 0 {
|
||||
body.Attempts = 1
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 25*time.Second)
|
||||
defer cancel()
|
||||
result, err := s.manager.CheckHealth(ctx, p, body.Attempts)
|
||||
if err != nil {
|
||||
fail(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
respond(w, 200, result)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"serviceport/internal/gateway"
|
||||
"serviceport/internal/model"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMonitoringTargetAccountAndSettings(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
f.login()
|
||||
target := model.Target{ID: "health-target", Name: "health", Type: "mysql", TargetHost: "127.0.0.1", TargetPort: 3306, Accounts: []model.TargetAccount{{ID: "first", Username: "probe", Password: "never-expose-this"}}}
|
||||
if err := f.store.SaveTarget(target); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := f.request("POST", "/api/v1/monitoring/check", map[string]any{"targetId": target.ID, "accountId": "first", "attempts": 2}, true)
|
||||
requireStatus(t, w, 200)
|
||||
var result gateway.HealthResult
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Healthy || result.Attempts != 1 || result.AccountID != "first" || result.TargetID != target.ID {
|
||||
t.Fatal(result)
|
||||
}
|
||||
if strings.Contains(w.Body.String(), "never-expose-this") {
|
||||
t.Fatal("health response leaked credentials")
|
||||
}
|
||||
requireStatus(t, f.request("POST", "/api/v1/monitoring/check", map[string]any{"targetId": target.ID, "accountId": "missing"}, true), 400)
|
||||
requireStatus(t, f.request("POST", "/api/v1/monitoring/check", map[string]any{"targetId": target.ID, "accountId": "first", "attempts": 4}, true), 400)
|
||||
requireStatus(t, f.request("POST", "/api/v1/monitoring/settings", map[string]int{"intervalSeconds": 1}, true), 400)
|
||||
requireStatus(t, f.request("POST", "/api/v1/monitoring/settings", map[string]int{"intervalSeconds": 10}, true), 200)
|
||||
w = f.request("GET", "/api/v1/monitoring", nil, false)
|
||||
requireStatus(t, w, 200)
|
||||
var snapshot struct {
|
||||
IntervalSeconds int `json:"intervalSeconds"`
|
||||
Health []gateway.HealthResult `json:"health"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &snapshot); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if snapshot.IntervalSeconds != 10 || len(snapshot.Health) != 1 {
|
||||
t.Fatal(snapshot)
|
||||
}
|
||||
requireStatus(t, f.request("POST", "/api/v1/monitoring/settings", map[string]int{"intervalSeconds": 0}, true), 200)
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"serviceport/internal/gateway"
|
||||
"serviceport/internal/model"
|
||||
"serviceport/internal/outbound"
|
||||
"serviceport/internal/security"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -28,7 +28,7 @@ func (s *Server) outbounds(w http.ResponseWriter, r *http.Request, path string,
|
||||
}
|
||||
out := []any{}
|
||||
for _, p := range profiles {
|
||||
out = append(out, s.outboundView(p))
|
||||
out = append(out, roleView(s.outboundView(p), sess.Role))
|
||||
}
|
||||
respond(w, 200, out)
|
||||
case "POST":
|
||||
@@ -68,7 +68,7 @@ func (s *Server) outbounds(w http.ResponseWriter, r *http.Request, path string,
|
||||
}
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
respond(w, 200, s.outboundView(p))
|
||||
respond(w, 200, roleView(s.outboundView(p), sess.Role))
|
||||
case "PUT":
|
||||
refs, e := s.store.OutboundReferences(p.ID)
|
||||
if e != nil {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"serviceport/internal/model"
|
||||
"encoding/json"
|
||||
"serviceport/internal/model"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"serviceport/internal/store"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestConfigurationDiffHidesChangedAndAddedSecrets(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
f.login()
|
||||
p := f.create("mysql")
|
||||
path := "/api/v1/proxies/" + p.Config.ID
|
||||
requireStatus(t, f.request("PUT", path, map[string]any{"name": "changed", "gatewayAccounts": []any{map[string]any{"id": "first", "username": "client", "password": "diff-private-secret"}}}, true), 200)
|
||||
h, err := f.store.History("gateways", p.Config.ID)
|
||||
if err != nil || len(h) == 0 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := f.request("GET", "/api/v1/configuration/diff?kind=gateways&id="+p.Config.ID+"&revision=1", nil, false)
|
||||
requireStatus(t, w, 200)
|
||||
if strings.Contains(w.Body.String(), "private-secret") || strings.Contains(w.Body.String(), "gateway-secret-unique") {
|
||||
t.Fatal("diff leaked secret")
|
||||
}
|
||||
var result struct {
|
||||
Changes []store.ConfigChange `json:"changes"`
|
||||
}
|
||||
json.Unmarshal(w.Body.Bytes(), &result)
|
||||
secret, name := false, false
|
||||
for _, c := range result.Changes {
|
||||
secret = secret || c.SecretChanged
|
||||
name = name || c.Path == "/name"
|
||||
}
|
||||
if !secret || !name {
|
||||
t.Fatal("diff missing actual changes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMappedAccountsAndExpiryAlerts(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
f.login()
|
||||
p := f.create("mysql")
|
||||
target, _ := f.store.GetTarget(p.Config.TargetID)
|
||||
accounts := []any{map[string]any{"id": "one", "name": "One", "username": "up-one", "password": "up-one-secret"}, map[string]any{"id": "two", "name": "Two", "username": "up-two", "password": "up-two-secret"}}
|
||||
// Preserve the original referenced target account while adding alternatives.
|
||||
a := target.Accounts[0]
|
||||
accounts = append(accounts, map[string]any{"id": a.ID, "name": a.Name, "username": a.Username, "password": a.Password})
|
||||
requireStatus(t, f.request("PUT", "/api/v1/targets/"+target.ID, map[string]any{"accounts": accounts}, true), 200)
|
||||
expiry := time.Now().Add(-time.Hour).UTC().Format(time.RFC3339)
|
||||
requireStatus(t, f.request("PUT", "/api/v1/proxies/"+p.Config.ID, map[string]any{"targetAccountId": "", "gatewayAccounts": []any{map[string]any{"id": "first", "username": "first", "password": "client-one", "targetAccountId": "one"}, map[string]any{"id": "second", "username": "second", "password": "client-two", "targetAccountId": "two", "expiresAt": expiry}}}, true), 200)
|
||||
resolved, err := f.store.Get(p.Config.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, ok := resolved.GatewayAccount("first")
|
||||
if !ok || first.Apply(resolved).TargetUser != "up-one" {
|
||||
t.Fatal("incorrect mapping")
|
||||
}
|
||||
if _, ok := resolved.GatewayAccount("second"); ok {
|
||||
t.Fatal("expired gateway account accepted")
|
||||
}
|
||||
requireStatus(t, f.request("POST", "/api/v1/proxies/"+p.Config.ID+"/start", nil, true), 200)
|
||||
requireStatus(t, f.request("POST", "/api/v1/proxies/"+p.Config.ID+"/stop", nil, true), 200)
|
||||
requireStatus(t, f.request("PUT", "/api/v1/targets/"+target.ID, map[string]any{"accounts": accounts[1:]}, true), 409)
|
||||
requireStatus(t, f.request("POST", "/api/v1/alerts/check", nil, true), 200)
|
||||
w := f.request("GET", "/api/v1/alerts", nil, false)
|
||||
requireStatus(t, w, 200)
|
||||
if !strings.Contains(w.Body.String(), "credential_expired") || strings.Contains(w.Body.String(), "client-two") {
|
||||
t.Fatal(w.Body.String())
|
||||
}
|
||||
requireStatus(t, f.request("POST", "/api/v1/alerts/check", nil, true), 200)
|
||||
list, _ := f.store.Alerts()
|
||||
if len(list) != 1 {
|
||||
t.Fatal("alert not deduplicated", len(list))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (s *Server) prometheus(w http.ResponseWriter, r *http.Request) {
|
||||
ps, err := s.store.List()
|
||||
if err != nil {
|
||||
fail(w, 500, "cannot load metrics")
|
||||
return
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("# HELP serviceport_gateway_running Gateway listener state.\n# TYPE serviceport_gateway_running gauge\n")
|
||||
for _, p := range ps {
|
||||
v := 0
|
||||
if s.manager.Status(p.ID).Running {
|
||||
v = 1
|
||||
}
|
||||
fmt.Fprintf(&b, "serviceport_gateway_running{gateway_id=%s,protocol=%s} %d\n", strconv.Quote(p.ID), strconv.Quote(p.Type), v)
|
||||
}
|
||||
fields := []struct {
|
||||
name, help, kind string
|
||||
value func(string) float64
|
||||
}{
|
||||
{"active_connections", "Accepted active connections.", "gauge", func(id string) float64 { return float64(s.manager.Metrics(id).ActiveConnections) }},
|
||||
{"connection_attempts_total", "TCP connection attempts.", "counter", func(id string) float64 { return float64(s.manager.Metrics(id).ConnectionAttempts) }},
|
||||
{"authentication_failures_total", "Gateway authentication failures.", "counter", func(id string) float64 { return float64(s.manager.Metrics(id).AuthenticationFailed) }},
|
||||
{"authentication_successes_total", "Gateway authentication successes.", "counter", func(id string) float64 { return float64(s.manager.Metrics(id).AuthenticationSucceeded) }},
|
||||
{"authentication_duration_seconds_sum", "Total authentication duration.", "counter", func(id string) float64 { return s.manager.Metrics(id).AuthenticationLatencyTotalMS / 1000 }},
|
||||
}
|
||||
for _, f := range fields {
|
||||
fmt.Fprintf(&b, "# HELP serviceport_%s %s\n# TYPE serviceport_%s %s\n", f.name, f.help, f.name, f.kind)
|
||||
for _, p := range ps {
|
||||
fmt.Fprintf(&b, "serviceport_%s{gateway_id=%s,protocol=%s} %g\n", f.name, strconv.Quote(p.ID), strconv.Quote(p.Type), f.value(p.ID))
|
||||
}
|
||||
}
|
||||
_, health := s.manager.HealthSnapshot()
|
||||
b.WriteString("# HELP serviceport_target_healthy Last target health result.\n# TYPE serviceport_target_healthy gauge\n")
|
||||
for _, h := range health {
|
||||
v := 0
|
||||
if h.Healthy {
|
||||
v = 1
|
||||
}
|
||||
fmt.Fprintf(&b, "serviceport_target_healthy{target_id=%s,account_id=%s} %d\n", strconv.Quote(h.TargetID), strconv.Quote(h.AccountID), v)
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
|
||||
fmt.Fprint(w, b.String())
|
||||
}
|
||||
+24
-45
@@ -1,17 +1,17 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"golang.org/x/crypto/ssh"
|
||||
"net/http"
|
||||
"serviceport/internal/model"
|
||||
"serviceport/internal/protocol/sshproxy"
|
||||
"serviceport/internal/security"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func (s *Server) view(p model.Proxy) any {
|
||||
redacted, flags := p.Redacted()
|
||||
hostFingerprint := ""
|
||||
if p.Type == "ssh" && p.SSH.HostKey != "" {
|
||||
if model.IsSSH(p.Type) && p.SSH.HostKey != "" {
|
||||
if key, e := ssh.ParsePrivateKey([]byte(p.SSH.HostKey)); e == nil {
|
||||
hostFingerprint = ssh.FingerprintSHA256(key.PublicKey())
|
||||
}
|
||||
@@ -28,22 +28,19 @@ func (s *Server) create(w http.ResponseWriter, r *http.Request) {
|
||||
fail(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
if patch["targetId"] == nil || patch["targetId"] == "" || patch["targetAccountId"] == nil || patch["targetAccountId"] == "" {
|
||||
fail(w, 400, "target and target account must be selected")
|
||||
return
|
||||
}
|
||||
base := model.Defaults()
|
||||
switch patch["type"] {
|
||||
case "sqlserver":
|
||||
base.AuthSource = "master"
|
||||
case "pgsql":
|
||||
base.AuthSource = "postgres"
|
||||
case "rabbitmq":
|
||||
base.AuthSource = "/"
|
||||
base.TargetTLS = false
|
||||
case "redis":
|
||||
base.TargetUser = "default"
|
||||
case "redis", "http":
|
||||
base.TargetTLS = false
|
||||
}
|
||||
if patch["type"] == "ssh" {
|
||||
if patch["type"] == "ssh" || patch["type"] == "sftp" {
|
||||
base.AccessMode = ""
|
||||
}
|
||||
p, err := model.Merge(base, patch)
|
||||
@@ -53,35 +50,16 @@ func (s *Server) create(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
p.ID = security.Token()[:16]
|
||||
p.Enabled = false
|
||||
if p.TargetID == "" && p.OutboundID != "" {
|
||||
if _, inline := patch["outbound"]; inline {
|
||||
fail(w, 400, "choose outboundId or inline outbound")
|
||||
return
|
||||
}
|
||||
profile, e := s.store.GetOutbound(p.OutboundID)
|
||||
if e != nil {
|
||||
fail(w, 400, "outbound not found")
|
||||
return
|
||||
}
|
||||
p.Outbound = profile.Outbound
|
||||
if err = gatewayFields(patch); err != nil {
|
||||
fail(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
if p.TargetID != "" {
|
||||
if err = gatewayFields(patch); err != nil {
|
||||
fail(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
p, err = s.resolveTarget(p)
|
||||
if err != nil {
|
||||
fail(w, 400, "target not found")
|
||||
return
|
||||
}
|
||||
t, _ := s.store.GetTarget(p.TargetID)
|
||||
if t.Type != p.Type {
|
||||
fail(w, 400, "gateway and target protocols must match")
|
||||
return
|
||||
}
|
||||
p, err = s.resolveTarget(p)
|
||||
if err != nil {
|
||||
fail(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
if err = p.Validate(); err != nil {
|
||||
if err = validateGateway(&p); err != nil {
|
||||
fail(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -89,7 +67,7 @@ func (s *Server) create(w http.ResponseWriter, r *http.Request) {
|
||||
fail(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
if p.Type == "ssh" {
|
||||
if model.IsSSH(p.Type) {
|
||||
p.SSH.HostKey, err = sshproxy.GenerateHostKey()
|
||||
if err != nil {
|
||||
fail(w, 500, "host key generation failed")
|
||||
@@ -131,17 +109,18 @@ func (s *Server) update(w http.ResponseWriter, r *http.Request, p model.Proxy) {
|
||||
fail(w, 400, "proxy type cannot change")
|
||||
return
|
||||
}
|
||||
if id, present := patch["targetId"]; present && id == "" {
|
||||
next.TargetAccountID = ""
|
||||
for i := range next.GatewayAccounts {
|
||||
next.GatewayAccounts[i].TargetAccountID = ""
|
||||
}
|
||||
}
|
||||
next, err = s.resolveTarget(next)
|
||||
if err != nil {
|
||||
fail(w, 400, "target not found")
|
||||
fail(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
target, _ := s.store.GetTarget(next.TargetID)
|
||||
if target.Type != next.Type {
|
||||
fail(w, 400, "gateway and target protocols must match")
|
||||
return
|
||||
}
|
||||
if err = next.Validate(); err != nil {
|
||||
if err = validateGateway(&next); err != nil {
|
||||
fail(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package api
|
||||
|
||||
import "net/http"
|
||||
|
||||
// readiness reports listener readiness, not upstream connectivity or authentication.
|
||||
func (s *Server) readiness(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
s.ops.Lock()
|
||||
defer s.ops.Unlock()
|
||||
proxies, err := s.store.List()
|
||||
ready := err == nil
|
||||
bound := 0
|
||||
for _, p := range proxies {
|
||||
if p.TargetID == "" {
|
||||
continue
|
||||
}
|
||||
bound++
|
||||
if !s.manager.Status(p.ID).Running {
|
||||
ready = false
|
||||
}
|
||||
}
|
||||
if !ready || bound == 0 {
|
||||
respond(w, http.StatusServiceUnavailable, map[string]string{"status": "not_ready"})
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusOK, map[string]string{"status": "ready"})
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Uses an isolated database, loopback listeners and the inert protocol fixture;
|
||||
// it does not authenticate to a real upstream or exercise a live HA cluster.
|
||||
func TestReadinessListenerLifecycle(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
check := func(code int, status string) {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
f.handler.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/readyz", nil))
|
||||
requireStatus(t, w, code)
|
||||
var body map[string]string
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil || len(body) != 1 || body["status"] != status {
|
||||
t.Fatalf("unexpected readiness response: %s", w.Body.String())
|
||||
}
|
||||
if w.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Fatal("readiness must not be cached")
|
||||
}
|
||||
}
|
||||
check(503, "not_ready")
|
||||
f.login()
|
||||
w := f.request("POST", "/api/v1/proxies", map[string]any{
|
||||
"name": "draft", "type": "mysql", "listenHost": "127.0.0.1", "listenPort": freePort(t),
|
||||
"gatewayAccounts": []any{map[string]any{"id": "client", "username": "client", "password": "client-secret"}},
|
||||
}, true)
|
||||
requireStatus(t, w, 201)
|
||||
check(503, "not_ready")
|
||||
first := f.create("mysql")
|
||||
check(503, "not_ready")
|
||||
requireStatus(t, f.request("POST", "/api/v1/proxies/"+first.Config.ID+"/start", nil, true), 200)
|
||||
check(200, "ready")
|
||||
second := f.create("mysql")
|
||||
check(503, "not_ready")
|
||||
requireStatus(t, f.request("POST", "/api/v1/proxies/"+second.Config.ID+"/start", nil, true), 200)
|
||||
check(200, "ready")
|
||||
requireStatus(t, f.request("POST", "/api/v1/proxies/"+first.Config.ID+"/stop", nil, true), 200)
|
||||
check(503, "not_ready")
|
||||
requireStatus(t, f.request("POST", "/api/v1/proxies/"+first.Config.ID+"/start", nil, true), 200)
|
||||
check(200, "ready")
|
||||
if err := f.store.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
check(503, "not_ready")
|
||||
}
|
||||
+82
-12
@@ -1,16 +1,17 @@
|
||||
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"
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -19,6 +20,7 @@ import (
|
||||
type session struct {
|
||||
User, CSRF, Revision string
|
||||
Expires time.Time
|
||||
Role string
|
||||
}
|
||||
type attempt struct {
|
||||
Count int
|
||||
@@ -31,6 +33,7 @@ type probe struct {
|
||||
Session string
|
||||
}
|
||||
type Server struct {
|
||||
alerts *alerts.Engine
|
||||
store *store.Store
|
||||
manager *gateway.Manager
|
||||
mu sync.Mutex
|
||||
@@ -48,10 +51,14 @@ func New(s *store.Store, m *gateway.Manager, secure bool, authentication ...bool
|
||||
if len(authentication) > 0 {
|
||||
enabled = authentication[0]
|
||||
}
|
||||
return &Server{store: s, manager: m, sessions: map[string]session{}, attempts: map[string]attempt{}, probes: map[string]probe{}, secure: secure, authEnabled: enabled, devCSRF: security.Token()}
|
||||
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)
|
||||
@@ -84,8 +91,25 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
var sess session
|
||||
role := "admin"
|
||||
cookie := &http.Cookie{Value: "development"}
|
||||
if !s.authEnabled {
|
||||
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 {
|
||||
@@ -112,18 +136,24 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request) {
|
||||
ok = false
|
||||
}
|
||||
s.mu.Unlock()
|
||||
rev, err := s.store.Revision()
|
||||
if !ok || err != nil || rev != sess.Revision {
|
||||
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 r.Method != "GET" && r.Header.Get("X-CSRF-Token") != sess.CSRF {
|
||||
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})
|
||||
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" {
|
||||
@@ -135,6 +165,14 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
@@ -151,6 +189,22 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request) {
|
||||
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 {
|
||||
@@ -159,13 +213,25 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
items := make([]any, 0, len(ps))
|
||||
for _, p := range ps {
|
||||
items = append(items, s.view(p))
|
||||
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
|
||||
@@ -191,7 +257,7 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request) {
|
||||
if len(parts) == 2 {
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
respond(w, 200, s.view(p))
|
||||
respond(w, 200, roleView(s.view(p), role))
|
||||
case "PUT":
|
||||
s.update(w, r, p)
|
||||
case "DELETE":
|
||||
@@ -238,6 +304,10 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
|
||||
@@ -16,12 +16,12 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
"serviceport/internal/gateway"
|
||||
"serviceport/internal/model"
|
||||
"serviceport/internal/protocol/sshproxy"
|
||||
"serviceport/internal/security"
|
||||
"serviceport/internal/store"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
const adminPassword = "integration-admin-password"
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"serviceport/internal/model"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTargetAccountsGatewaySelection(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
f.login()
|
||||
body := map[string]any{"name": "server", "type": "ssh", "targetHost": "127.0.0.1", "targetPort": 22, "ssh": map[string]any{"ignoreHostKey": true}, "accounts": []any{map[string]any{"id": "normal", "name": "Normal", "username": "normal", "password": "normal-secret", "authMethod": "password"}, map[string]any{"id": "root", "name": "Administrator", "username": "root", "password": "root-secret", "authMethod": "password"}}}
|
||||
w := f.request("POST", "/api/v1/targets", body, true)
|
||||
requireStatus(t, w, 201)
|
||||
if strings.Contains(w.Body.String(), "root-secret") || strings.Contains(w.Body.String(), "normal-secret") {
|
||||
t.Fatal("target response exposed credentials")
|
||||
}
|
||||
var view struct {
|
||||
Config model.Target `json:"config"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &view); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := "/api/v1/targets/" + view.Config.ID
|
||||
ids := []string{}
|
||||
for _, accountID := range []string{"normal", "root"} {
|
||||
gateway := map[string]any{"name": "gateway", "type": "ssh", "targetId": view.Config.ID, "targetAccountId": accountID, "listenHost": "127.0.0.1", "listenPort": freePort(t), "gatewayUser": "client", "gatewayPassword": "gateway-secret"}
|
||||
w = f.request("POST", "/api/v1/proxies", gateway, true)
|
||||
requireStatus(t, w, 201)
|
||||
if strings.Contains(w.Body.String(), "root-secret") {
|
||||
t.Fatal("gateway response exposed credentials")
|
||||
}
|
||||
id := readView(t, w).Config.ID
|
||||
ids = append(ids, id)
|
||||
p, err := f.store.Get(id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if accountID == "root" && (p.TargetUser != "root" || p.TargetPassword != "root-secret") {
|
||||
t.Fatal("root selection not resolved")
|
||||
}
|
||||
if accountID == "normal" && (p.TargetUser != "normal" || p.TargetPassword != "normal-secret") {
|
||||
t.Fatal("default credentials changed")
|
||||
}
|
||||
persisted := p.GatewayOnly()
|
||||
if persisted.TargetAccountID != accountID || persisted.TargetPassword != "" || persisted.SSH.PrivateKey != "" {
|
||||
t.Fatal("gateway persistence contains target credentials")
|
||||
}
|
||||
}
|
||||
rootPath := "/api/v1/proxies/" + ids[1]
|
||||
requireStatus(t, f.request("PUT", rootPath, map[string]any{"targetAccountId": "missing"}, true), 400)
|
||||
requireStatus(t, f.request("PUT", path, map[string]any{"accounts": []any{map[string]any{"id": "normal", "name": "Normal", "username": "normal", "authMethod": "password"}}}, true), 409)
|
||||
// Public account updates retain credentials by ID.
|
||||
patch := map[string]any{"accounts": []any{map[string]any{"id": "root", "name": "Renamed", "username": "root", "authMethod": "password"}, map[string]any{"id": "normal", "name": "Normal", "username": "normal", "authMethod": "password"}}}
|
||||
requireStatus(t, f.request("PUT", path, patch, true), 200)
|
||||
p, _ := f.store.Get(ids[1])
|
||||
if p.TargetPassword != "root-secret" {
|
||||
t.Fatal("omitted password was lost")
|
||||
}
|
||||
requireStatus(t, f.request("POST", rootPath+"/start", nil, true), 200)
|
||||
requireStatus(t, f.request("PUT", rootPath, map[string]any{"targetAccountId": ""}, true), 409)
|
||||
patch["accounts"].([]any)[0].(map[string]any)["password"] = "replacement"
|
||||
requireStatus(t, f.request("PUT", path, patch, true), 409)
|
||||
requireStatus(t, f.request("POST", rootPath+"/stop", nil, true), 200)
|
||||
requireStatus(t, f.request("PUT", path, patch, true), 200)
|
||||
p, _ = f.store.Get(ids[1])
|
||||
if p.TargetPassword != "replacement" {
|
||||
t.Fatal("new credential not resolved")
|
||||
}
|
||||
requireStatus(t, f.request("PUT", rootPath, map[string]any{"targetAccountId": ""}, true), 400)
|
||||
requireStatus(t, f.request("PUT", rootPath, map[string]any{"targetAccountId": "normal"}, true), 200)
|
||||
requireStatus(t, f.request("PUT", path, map[string]any{"accounts": []any{map[string]any{"id": "normal", "name": "Normal", "username": "normal", "authMethod": "password"}}}, true), 200)
|
||||
requireStatus(t, f.request("POST", path+"/test?accountId=missing", nil, true), 400)
|
||||
}
|
||||
|
||||
func TestTargetAccountValidation(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
f.login()
|
||||
for _, accounts := range []any{
|
||||
[]any{map[string]any{"id": "same", "name": "one", "username": "a"}, map[string]any{"id": "same", "name": "two", "username": "b"}},
|
||||
[]any{map[string]any{"id": "root", "name": "root", "username": "root", "Password": "injected"}},
|
||||
[]any{map[string]any{"id": "root", "name": "root", "username": "root", "authMethod": "privatekey", "privateKey": "invalid"}},
|
||||
} {
|
||||
body := map[string]any{"name": "server", "type": "ssh", "targetHost": "127.0.0.1", "targetPort": 22, "accounts": accounts}
|
||||
requireStatus(t, f.request("POST", "/api/v1/targets", body, true), 400)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOldAccountDesignRejectedAndUnboundGatewayCanBeReconfigured(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
f.login()
|
||||
requireStatus(t, f.request("POST", "/api/v1/targets", map[string]any{"name": "old", "type": "mysql", "targetHost": "localhost", "targetPort": 3306, "targetUser": "old", "targetPassword": "old-secret"}, true), 400)
|
||||
target := model.TargetDefaults()
|
||||
target.ID, target.Name, target.Type, target.TargetHost, target.TargetPort = "accounts-target", "Server", "mysql", "localhost", 3306
|
||||
target.Accounts = []model.TargetAccount{{ID: "normal", Name: "Normal", Username: "user", Password: "secret"}}
|
||||
if err := f.store.SaveTarget(target); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := model.Defaults()
|
||||
p.ID, p.TargetID, p.Name, p.Type = "unbound", target.ID, "Old gateway", "mysql"
|
||||
p.GatewayUser, p.GatewayPassword, p.ListenPort = "client", "gateway-secret", freePort(t)
|
||||
if err := f.store.Save(p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := "/api/v1/proxies/unbound"
|
||||
requireStatus(t, f.request("POST", path+"/start", nil, true), 409)
|
||||
requireStatus(t, f.request("GET", "/api/v1/proxies", nil, true), 200)
|
||||
requireStatus(t, f.request("PUT", path, map[string]any{"targetAccountId": "normal"}, true), 200)
|
||||
requireStatus(t, f.request("POST", path+"/start", nil, true), 200)
|
||||
requireStatus(t, f.request("POST", path+"/stop", nil, true), 200)
|
||||
}
|
||||
+29
-11
@@ -1,15 +1,15 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"serviceport/internal/gateway"
|
||||
"serviceport/internal/model"
|
||||
"serviceport/internal/outbound"
|
||||
"serviceport/internal/protocol/sshproxy"
|
||||
"serviceport/internal/security"
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -45,7 +45,7 @@ func (s *Server) targets(w http.ResponseWriter, r *http.Request, path string, se
|
||||
}
|
||||
out := []any{}
|
||||
for _, t := range ts {
|
||||
out = append(out, s.targetView(t))
|
||||
out = append(out, roleView(s.targetView(t), sess.Role))
|
||||
}
|
||||
respond(w, 200, out)
|
||||
case "POST":
|
||||
@@ -63,7 +63,7 @@ func (s *Server) targets(w http.ResponseWriter, r *http.Request, path string, se
|
||||
if len(parts) == 2 {
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
respond(w, 200, s.targetView(t))
|
||||
respond(w, 200, roleView(s.targetView(t), sess.Role))
|
||||
case "PUT":
|
||||
s.saveTarget(w, r, t)
|
||||
case "DELETE":
|
||||
@@ -118,7 +118,7 @@ func (s *Server) targets(w http.ResponseWriter, r *http.Request, path string, se
|
||||
}
|
||||
s.reveal(w, r, p, sess, token)
|
||||
case "probe":
|
||||
if t.Type != "ssh" {
|
||||
if !model.IsSSH(t.Type) {
|
||||
fail(w, 400, "SSH only")
|
||||
return
|
||||
}
|
||||
@@ -134,7 +134,7 @@ func (s *Server) targets(w http.ResponseWriter, r *http.Request, path string, se
|
||||
s.mu.Unlock()
|
||||
respond(w, 200, result)
|
||||
case "confirm-host-key":
|
||||
if t.Type != "ssh" {
|
||||
if !model.IsSSH(t.Type) {
|
||||
fail(w, 400, "SSH only")
|
||||
return
|
||||
}
|
||||
@@ -182,12 +182,14 @@ func (s *Server) saveTarget(w http.ResponseWriter, r *http.Request, current mode
|
||||
if creating {
|
||||
base = model.TargetDefaults()
|
||||
switch patch["type"] {
|
||||
case "sqlserver":
|
||||
base.AuthSource = "master"
|
||||
case "pgsql":
|
||||
base.AuthSource = "postgres"
|
||||
case "rabbitmq":
|
||||
base.AuthSource = "/"
|
||||
base.TargetTLS = false
|
||||
case "redis":
|
||||
case "redis", "http":
|
||||
base.TargetTLS = false
|
||||
}
|
||||
}
|
||||
@@ -245,7 +247,7 @@ func (s *Server) saveTarget(w http.ResponseWriter, r *http.Request, current mode
|
||||
fail(w, 500, "cannot load gateway")
|
||||
return
|
||||
}
|
||||
if p.TargetAccountID != "" && !t.HasAccount(p.TargetAccountID) {
|
||||
if t.ValidateAccountReferences(p) != nil {
|
||||
fail(w, 409, "target account is referenced by a gateway")
|
||||
return
|
||||
}
|
||||
@@ -285,11 +287,27 @@ func (s *Server) saveTarget(w http.ResponseWriter, r *http.Request, current mode
|
||||
}
|
||||
|
||||
func (s *Server) resolveTarget(p model.Proxy) (model.Proxy, error) {
|
||||
if p.TargetID == "" {
|
||||
if (model.Target{}).ValidateAccountReferences(p) != nil {
|
||||
return p, errors.New("select a target before selecting its account")
|
||||
}
|
||||
return p.GatewayOnly(), nil
|
||||
}
|
||||
t, err := s.store.GetTarget(p.TargetID)
|
||||
if err != nil {
|
||||
return p, errors.New("target not found")
|
||||
}
|
||||
if t.Type != p.Type {
|
||||
return p, errors.New("gateway and target protocols must match")
|
||||
}
|
||||
if err := t.ValidateAccountReferences(p); err != nil {
|
||||
return p, err
|
||||
}
|
||||
return t.Resolve(p)
|
||||
p = t.Apply(p)
|
||||
if err := p.ValidateTargetSelection(); err != nil {
|
||||
return p, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
func gatewayFields(patch map[string]any) error {
|
||||
for _, k := range []string{"group", "targetHost", "targetPort", "targetUser", "targetPassword", "targetTLS", "targetCA", "targetCompression", "authSource", "outbound", "outboundId"} {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"serviceport/internal/model"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"serviceport/internal/model"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"serviceport/internal/store"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (s *Server) templates(w http.ResponseWriter, r *http.Request, path string) {
|
||||
if path == "templates" && r.Method == "GET" {
|
||||
v, e := s.store.Templates()
|
||||
if e != nil {
|
||||
fail(w, 500, "cannot load templates")
|
||||
return
|
||||
}
|
||||
respond(w, 200, v)
|
||||
return
|
||||
}
|
||||
if path == "templates" && r.Method == "POST" {
|
||||
var b struct {
|
||||
Name string `json:"name"`
|
||||
Bundle store.Bundle `json:"bundle"`
|
||||
}
|
||||
if !decodeLimit(w, r, &b, 24<<20) {
|
||||
return
|
||||
}
|
||||
t, e := s.store.SaveTemplate(b.Name, b.Bundle)
|
||||
if e != nil {
|
||||
fail(w, 400, e.Error())
|
||||
return
|
||||
}
|
||||
s.store.Event("", source(r), "template_created", t.ID)
|
||||
respond(w, 201, t)
|
||||
return
|
||||
}
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) == 2 && r.Method == "GET" {
|
||||
t, e := s.store.Template(parts[1])
|
||||
if e != nil {
|
||||
fail(w, 404, "template not found")
|
||||
return
|
||||
}
|
||||
respond(w, 200, t)
|
||||
return
|
||||
}
|
||||
if len(parts) == 2 && r.Method == "DELETE" {
|
||||
if e := s.store.DeleteTemplate(parts[1]); e != nil {
|
||||
fail(w, 404, "template not found")
|
||||
return
|
||||
}
|
||||
s.store.Event("", source(r), "template_deleted", parts[1])
|
||||
respond(w, 200, map[string]bool{"ok": true})
|
||||
return
|
||||
}
|
||||
fail(w, 405, "method not allowed")
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Apply to every JSON response, including writes that return configuration views.
|
||||
type tokenResponseWriter struct{ http.ResponseWriter }
|
||||
|
||||
func redactTokenResponse(v any) {
|
||||
switch value := v.(type) {
|
||||
case map[string]any:
|
||||
for k, child := range value {
|
||||
switch strings.ToLower(k) {
|
||||
case "password", "targetpassword", "gatewaypassword", "privatekey", "passphrase", "hostkey", "kubeconfig", "webhookurl", "webhooksecret", "secret":
|
||||
// Presence flags in secretStatus are safe and retain their boolean type.
|
||||
if _, flag := child.(bool); !flag {
|
||||
value[k] = ""
|
||||
}
|
||||
default:
|
||||
redactTokenResponse(child)
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, child := range value {
|
||||
redactTokenResponse(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"serviceport/internal/store"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func tokenPermits(t store.APIToken, path, method string) bool {
|
||||
// Automation credentials cannot create credentials or reveal stored secrets.
|
||||
if path == "session" || path == "logout" || path == "users" || strings.HasPrefix(path, "users/") || path == "tokens" || strings.HasPrefix(path, "tokens/") || strings.Contains(path, "reveal-password") || path == "configuration/backup" || path == "alerts/settings" {
|
||||
return false
|
||||
}
|
||||
scope := "read"
|
||||
if method != "GET" {
|
||||
scope = "write"
|
||||
} else if path == "commands" || path == "events" {
|
||||
scope = "audit"
|
||||
} else if path == "metrics" {
|
||||
scope = "metrics"
|
||||
}
|
||||
for _, v := range t.Scopes {
|
||||
if v == scope {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func (s *Server) tokens(w http.ResponseWriter, r *http.Request, path string, sess session) {
|
||||
if path == "tokens" && r.Method == "GET" {
|
||||
list, err := s.store.Tokens()
|
||||
if err != nil {
|
||||
fail(w, 500, "cannot load tokens")
|
||||
return
|
||||
}
|
||||
respond(w, 200, list)
|
||||
return
|
||||
}
|
||||
if path == "tokens" && r.Method == "POST" {
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
Scopes []string `json:"scopes"`
|
||||
ExpiresAt string `json:"expiresAt"`
|
||||
}
|
||||
if !decode(w, r, &body) {
|
||||
return
|
||||
}
|
||||
t, secret, err := s.store.CreateToken(store.APIToken{Name: body.Name, Scopes: body.Scopes, ExpiresAt: body.ExpiresAt, Owner: sess.User})
|
||||
if err != nil {
|
||||
fail(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
s.store.Event("", source(r), "api_token_created", t.ID)
|
||||
respond(w, 201, map[string]any{"token": t, "secret": secret})
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(path, "tokens/") && r.Method == "DELETE" {
|
||||
id := strings.TrimPrefix(path, "tokens/")
|
||||
if err := s.store.RevokeToken(id); err != nil {
|
||||
fail(w, 404, "token not found")
|
||||
return
|
||||
}
|
||||
s.store.Event("", source(r), "api_token_revoked", id)
|
||||
respond(w, 200, map[string]bool{"ok": true})
|
||||
return
|
||||
}
|
||||
fail(w, 405, "method not allowed")
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"serviceport/internal/model"
|
||||
"serviceport/internal/store"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Kubernetes configuration embeds credentials and is only editable by admins.
|
||||
func roleView(value any, role string) any {
|
||||
if role == "admin" {
|
||||
return value
|
||||
}
|
||||
view, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return value
|
||||
}
|
||||
switch config := view["config"].(type) {
|
||||
case model.Proxy:
|
||||
config.Outbound.KubeConfig = ""
|
||||
view["config"] = config
|
||||
case model.Target:
|
||||
config.Outbound.KubeConfig = ""
|
||||
view["config"] = config
|
||||
case model.OutboundProfile:
|
||||
config.Outbound.KubeConfig = ""
|
||||
view["config"] = config
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func allowedRole(role, path, method string) bool {
|
||||
if role == "admin" {
|
||||
return true
|
||||
}
|
||||
if path == "session" || path == "logout" {
|
||||
return true
|
||||
}
|
||||
if method != "GET" {
|
||||
return false
|
||||
}
|
||||
if path == "templates" || strings.HasPrefix(path, "templates/") || path == "tokens" || strings.HasPrefix(path, "tokens/") || path == "users" || strings.HasPrefix(path, "users/") || strings.HasPrefix(path, "configuration/") {
|
||||
return false
|
||||
}
|
||||
if path == "commands" || path == "events" {
|
||||
return role == "auditor"
|
||||
}
|
||||
return role == "viewer" || role == "auditor"
|
||||
}
|
||||
|
||||
func (s *Server) users(w http.ResponseWriter, r *http.Request, path string, sess session) {
|
||||
if path == "users" && r.Method == "GET" {
|
||||
users, err := s.store.Users()
|
||||
if err != nil {
|
||||
fail(w, 500, "cannot load users")
|
||||
return
|
||||
}
|
||||
respond(w, 200, users)
|
||||
return
|
||||
}
|
||||
username := strings.TrimPrefix(path, "users/")
|
||||
if path != "users" && r.Method == "DELETE" {
|
||||
if username == sess.User {
|
||||
fail(w, 400, "cannot delete current user")
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteUser(username); err != nil {
|
||||
fail(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
s.store.Event("", source(r), "user_deleted", username)
|
||||
respond(w, 200, map[string]bool{"ok": true})
|
||||
return
|
||||
}
|
||||
creating := path == "users" && r.Method == "POST"
|
||||
if !creating && (path == "users" || r.Method != "PUT") {
|
||||
fail(w, 405, "method not allowed")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Username string `json:"username"`
|
||||
Password *string `json:"password"`
|
||||
Role *string `json:"role"`
|
||||
Disabled *bool `json:"disabled"`
|
||||
}
|
||||
if !decode(w, r, &body) {
|
||||
return
|
||||
}
|
||||
u := store.User{Username: body.Username, Role: "viewer"}
|
||||
if !creating {
|
||||
var err error
|
||||
u, _, err = s.store.Identity(username)
|
||||
if err != nil {
|
||||
fail(w, 404, "user not found")
|
||||
return
|
||||
}
|
||||
if body.Username != "" && body.Username != username {
|
||||
fail(w, 400, "username cannot change")
|
||||
return
|
||||
}
|
||||
}
|
||||
if body.Role != nil {
|
||||
u.Role = *body.Role
|
||||
}
|
||||
if body.Disabled != nil {
|
||||
u.Disabled = *body.Disabled
|
||||
}
|
||||
if !creating && username == sess.User && (u.Disabled || u.Role != "admin") {
|
||||
fail(w, 400, "cannot disable or demote current user")
|
||||
return
|
||||
}
|
||||
if err := s.store.SaveUser(u, body.Password, creating); err != nil {
|
||||
fail(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
s.store.Event("", source(r), "user_updated", u.Username)
|
||||
status := 200
|
||||
if creating {
|
||||
status = 201
|
||||
}
|
||||
respond(w, status, u)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"serviceport/internal/model"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestManagementRolesAndSessionRevocation(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
f.login()
|
||||
adminCookie, adminCSRF := f.cookie, f.csrf
|
||||
if err := f.store.SaveOutbound(model.OutboundProfile{ID: "kube", Name: "kube", Outbound: model.Outbound{Mode: "k8s", KubeConfig: "private-kube-credential"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, role := range []string{"viewer", "auditor", "admin"} {
|
||||
requireStatus(t, f.request("POST", "/api/v1/users", map[string]any{"username": role + "-user", "role": role, "password": "test-management-password"}, true), 201)
|
||||
}
|
||||
login := func(user string) {
|
||||
w := f.request("POST", "/api/v1/login", map[string]string{"username": user, "password": "test-management-password"}, false)
|
||||
requireStatus(t, w, 200)
|
||||
f.cookie = w.Result().Cookies()[0]
|
||||
var body map[string]string
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.csrf = body["csrfToken"]
|
||||
}
|
||||
for _, role := range []string{"viewer", "auditor"} {
|
||||
login(role + "-user")
|
||||
for _, path := range []string{"/api/v1/templates", "/api/v1/templates/known"} {
|
||||
requireStatus(t, f.request("GET", path, nil, false), 403)
|
||||
}
|
||||
for _, path := range []string{"/api/v1/outbounds", "/api/v1/outbounds/kube"} {
|
||||
w := f.request("GET", path, nil, false)
|
||||
requireStatus(t, w, 200)
|
||||
if strings.Contains(w.Body.String(), "private-kube-credential") {
|
||||
t.Fatal("read-only user received Kubernetes credentials")
|
||||
}
|
||||
}
|
||||
requireStatus(t, f.request("GET", "/api/v1/proxies", nil, false), 200)
|
||||
requireStatus(t, f.request("POST", "/api/v1/proxies", map[string]any{}, true), 403)
|
||||
requireStatus(t, f.request("GET", "/api/v1/users", nil, false), 403)
|
||||
requireStatus(t, f.request("GET", "/api/v1/configuration/export", nil, false), 403)
|
||||
status := 403
|
||||
if role == "auditor" {
|
||||
status = 200
|
||||
}
|
||||
requireStatus(t, f.request("GET", "/api/v1/events", nil, false), status)
|
||||
requireStatus(t, f.request("GET", "/api/v1/commands", nil, false), status)
|
||||
}
|
||||
login("admin-user")
|
||||
requireStatus(t, f.request("GET", "/api/v1/users", nil, false), 200)
|
||||
secondaryCookie, secondaryCSRF := f.cookie, f.csrf
|
||||
f.cookie, f.csrf = adminCookie, adminCSRF
|
||||
requireStatus(t, f.request("PUT", "/api/v1/users/admin-user", map[string]any{"disabled": true}, true), 200)
|
||||
f.cookie, f.csrf = secondaryCookie, secondaryCSRF
|
||||
requireStatus(t, f.request("GET", "/api/v1/session", nil, false), 401)
|
||||
f.cookie, f.csrf = adminCookie, adminCSRF
|
||||
requireStatus(t, f.request("DELETE", "/api/v1/users/admin", nil, true), 400)
|
||||
requireStatus(t, f.request("PUT", "/api/v1/users/admin", map[string]any{"role": "viewer"}, true), 400)
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"serviceport/internal/model"
|
||||
"errors"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"serviceport/internal/model"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -24,10 +24,14 @@ func editable(p map[string]any) error {
|
||||
}
|
||||
|
||||
func validateKeys(p model.Proxy) error {
|
||||
if p.Type != "ssh" {
|
||||
if !model.IsSSH(p.Type) {
|
||||
return nil
|
||||
}
|
||||
for _, key := range p.SSH.AuthorizedKeys {
|
||||
keys := append([]string(nil), p.SSH.AuthorizedKeys...)
|
||||
for _, account := range p.GatewayAccounts {
|
||||
keys = append(keys, account.AuthorizedKeys...)
|
||||
}
|
||||
for _, key := range keys {
|
||||
pub, _, opts, rest, e := ssh.ParseAuthorizedKey([]byte(key))
|
||||
if e != nil || len(opts) > 0 || len(strings.TrimSpace(string(rest))) > 0 || !supportedKey(pub) {
|
||||
return errors.New("each authorized key must be a single valid SSH public key")
|
||||
@@ -48,6 +52,13 @@ func validateKeys(p model.Proxy) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateGateway(p *model.Proxy) error {
|
||||
if p.TargetID == "" || p.TargetAccountID == "" {
|
||||
return p.ValidateGateway()
|
||||
}
|
||||
return p.Validate()
|
||||
}
|
||||
|
||||
func supportedKey(k ssh.PublicKey) bool {
|
||||
return k != nil && (k.Type() == ssh.KeyAlgoED25519 || k.Type() == ssh.KeyAlgoRSA)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"serviceport/internal/buildinfo"
|
||||
"encoding/json"
|
||||
"serviceport/internal/buildinfo"
|
||||
"testing"
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"net"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ConnectionInfo struct {
|
||||
ID string `json:"id"`
|
||||
GatewayID string `json:"gatewayId"`
|
||||
Protocol string `json:"protocol"`
|
||||
Username string `json:"username"`
|
||||
Source string `json:"source"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
DurationSeconds float64 `json:"durationSeconds"`
|
||||
Authenticated bool `json:"authenticated"`
|
||||
TargetAccountID string `json:"targetAccountId"`
|
||||
Disconnecting bool `json:"disconnecting"`
|
||||
}
|
||||
|
||||
type connectionState struct {
|
||||
ConnectionInfo
|
||||
conn net.Conn
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func (m *Manager) Connections() []ConnectionInfo {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
result := make([]ConnectionInfo, 0, len(m.connections))
|
||||
for _, state := range m.connections {
|
||||
info := state.ConnectionInfo
|
||||
info.DurationSeconds = time.Since(info.StartedAt).Seconds()
|
||||
result = append(result, info)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool { return result[i].StartedAt.Before(result[j].StartedAt) })
|
||||
return result
|
||||
}
|
||||
|
||||
// Disconnect closes only the selected connection, cancelling its upstream work.
|
||||
func (m *Manager) Disconnect(id string) (ConnectionInfo, bool) {
|
||||
m.mu.Lock()
|
||||
state, ok := m.connections[id]
|
||||
if !ok {
|
||||
m.mu.Unlock()
|
||||
return ConnectionInfo{}, false
|
||||
}
|
||||
state.Disconnecting = true
|
||||
info := state.ConnectionInfo
|
||||
m.mu.Unlock()
|
||||
state.cancel()
|
||||
state.conn.Close()
|
||||
return info, true
|
||||
}
|
||||
|
||||
func (m *Manager) OnShutdown(hook func()) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.shutdownHooks = append(m.shutdownHooks, hook)
|
||||
}
|
||||
|
||||
// CertificateExpirations covers certificates configured on the gateway listener.
|
||||
func (m *Manager) CertificateExpirations() map[string]time.Time {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
result := map[string]time.Time{}
|
||||
if m.tls == nil {
|
||||
return result
|
||||
}
|
||||
for i, cert := range m.tls.Certificates {
|
||||
leaf := cert.Leaf
|
||||
if leaf == nil && len(cert.Certificate) > 0 {
|
||||
leaf, _ = x509.ParseCertificate(cert.Certificate[0])
|
||||
}
|
||||
if leaf != nil {
|
||||
result[fmt.Sprintf("gateway-tls-%d", i)] = leaf.NotAfter
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDisconnectOnlySelectedConnection(t *testing.T) {
|
||||
m, p, entered := testManager(t)
|
||||
p.MaxConnections = 2
|
||||
if err := m.Start(p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a, err := net.Dial("tcp", address(m, p.ID))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer a.Close()
|
||||
<-entered
|
||||
b, err := net.Dial("tcp", address(m, p.ID))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer b.Close()
|
||||
<-entered
|
||||
connections := m.Connections()
|
||||
if len(connections) != 2 {
|
||||
t.Fatal(connections)
|
||||
}
|
||||
if _, ok := m.Disconnect(connections[0].ID); !ok {
|
||||
t.Fatal("missing connection")
|
||||
}
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for len(m.Connections()) != 1 && time.Now().Before(deadline) {
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
if len(m.Connections()) != 1 || !m.Status(p.ID).Running {
|
||||
t.Fatal("disconnect affected listener or other connection")
|
||||
}
|
||||
if _, ok := m.Disconnect("missing"); ok {
|
||||
t.Fatal("unknown connection accepted")
|
||||
}
|
||||
m.Stop(p.ID)
|
||||
if len(m.Connections()) != 0 {
|
||||
t.Fatal("stale connection state")
|
||||
}
|
||||
}
|
||||
+82
-19
@@ -1,14 +1,16 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"serviceport/internal/model"
|
||||
"serviceport/internal/outbound"
|
||||
"serviceport/internal/store"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net"
|
||||
"net/netip"
|
||||
"serviceport/internal/model"
|
||||
"serviceport/internal/outbound"
|
||||
"serviceport/internal/security"
|
||||
"serviceport/internal/store"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -29,20 +31,32 @@ type instance struct {
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
type Manager struct {
|
||||
mu sync.Mutex
|
||||
instances map[string]*instance
|
||||
failures map[string]string
|
||||
handlers map[string]model.Handler
|
||||
tls *tls.Config
|
||||
store *store.Store
|
||||
mu sync.Mutex
|
||||
instances map[string]*instance
|
||||
failures map[string]string
|
||||
handlers map[string]model.Handler
|
||||
tls *tls.Config
|
||||
store *store.Store
|
||||
metrics map[string]*Metrics
|
||||
health map[string]HealthResult
|
||||
healthBusy map[string]bool
|
||||
healthMu sync.Mutex
|
||||
healthCancel context.CancelFunc
|
||||
healthDone chan struct{}
|
||||
healthInterval int
|
||||
connections map[string]*connectionState
|
||||
shutdownHooks []func()
|
||||
}
|
||||
|
||||
func New(s *store.Store, t *tls.Config, h map[string]model.Handler) *Manager {
|
||||
return &Manager{instances: map[string]*instance{}, failures: map[string]string{}, handlers: h, tls: t, store: s}
|
||||
return &Manager{instances: map[string]*instance{}, failures: map[string]string{}, handlers: h, tls: t, store: s, metrics: map[string]*Metrics{}, health: map[string]HealthResult{}, healthBusy: map[string]bool{}, connections: map[string]*connectionState{}}
|
||||
}
|
||||
func (m *Manager) Start(p model.Proxy) error {
|
||||
if p.TargetID != "" && (p.TargetAccountID == "" || p.TargetUser == "") {
|
||||
return errors.New("select a target account before starting the gateway")
|
||||
if p.TargetID == "" {
|
||||
return errors.New("select a target before starting the gateway")
|
||||
}
|
||||
if err := p.ValidateTargetSelection(); err != nil {
|
||||
return err
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
@@ -53,7 +67,7 @@ func (m *Manager) Start(p model.Proxy) error {
|
||||
if h == nil {
|
||||
return errors.New("unsupported protocol")
|
||||
}
|
||||
if p.Type == "ssh" && !p.SSH.IgnoreHostKey && p.SSH.TargetHostKey == "" {
|
||||
if model.IsSSH(p.Type) && !p.SSH.IgnoreHostKey && p.SSH.TargetHostKey == "" {
|
||||
return errors.New("confirm SSH target host key first")
|
||||
}
|
||||
l, err := net.Listen("tcp", net.JoinHostPort(p.ListenHost, strconv.Itoa(p.ListenPort)))
|
||||
@@ -78,10 +92,14 @@ func (m *Manager) accept(ctx context.Context, p model.Proxy, i *instance, h mode
|
||||
}
|
||||
source, _, _ := net.SplitHostPort(c.RemoteAddr().String())
|
||||
m.mu.Lock()
|
||||
metric := m.metric(p.ID)
|
||||
metric.ConnectionAttempts++
|
||||
allowed := !i.stopping && Allowed(p.AllowCIDRs, source) && len(i.connections) < p.MaxConnections
|
||||
if allowed {
|
||||
i.connections[c] = struct{}{}
|
||||
i.wg.Add(1)
|
||||
} else {
|
||||
metric.ConnectionRejected++
|
||||
}
|
||||
m.mu.Unlock()
|
||||
if !allowed {
|
||||
@@ -89,23 +107,60 @@ func (m *Manager) accept(ctx context.Context, p model.Proxy, i *instance, h mode
|
||||
m.store.Event(p.ID, source, "connection_rejected", "source_or_limit")
|
||||
continue
|
||||
}
|
||||
connectionCtx, connectionCancel := context.WithCancel(ctx)
|
||||
connection := &connectionState{ConnectionInfo: ConnectionInfo{ID: security.Token(), GatewayID: p.ID, Protocol: p.Type, Source: source, StartedAt: time.Now().UTC(), TargetAccountID: p.TargetAccountID}, conn: c, cancel: connectionCancel}
|
||||
m.mu.Lock()
|
||||
m.connections[connection.ID] = connection
|
||||
m.mu.Unlock()
|
||||
go func() {
|
||||
defer connectionCancel()
|
||||
started := time.Now()
|
||||
authenticated := false
|
||||
defer i.wg.Done()
|
||||
defer c.Close()
|
||||
defer func() { m.mu.Lock(); delete(i.connections, c); m.mu.Unlock() }()
|
||||
defer func() {
|
||||
m.mu.Lock()
|
||||
delete(i.connections, c)
|
||||
delete(m.connections, connection.ID)
|
||||
if !authenticated && ctx.Err() == nil {
|
||||
metric.UnauthenticatedClosed++
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}()
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
m.store.Event(p.ID, source, "disconnected", "protocol_panic")
|
||||
}
|
||||
}()
|
||||
m.store.Event(p.ID, source, "connected", "")
|
||||
commandCtx := model.WithCommandAudit(ctx, func(command model.Command) error { return m.store.RecordCommand(p, source, command) })
|
||||
commandCtx := model.WithCommandAudit(connectionCtx, func(command model.Command) error { return m.store.RecordCommand(p, source, command) })
|
||||
err := h.Serve(commandCtx, c, p, outbound.Dialer(p), m.tls, func(category, rule string) {
|
||||
if category == model.EventReadOnlyRejected {
|
||||
m.mu.Lock()
|
||||
i.rejected++
|
||||
m.mu.Unlock()
|
||||
m.mu.Lock()
|
||||
switch category {
|
||||
case "authentication_succeeded":
|
||||
var identity struct {
|
||||
Username string `json:"username"`
|
||||
}
|
||||
if json.Unmarshal([]byte(rule), &identity) == nil {
|
||||
connection.Username = identity.Username
|
||||
if account, ok := p.GatewayAccount(identity.Username); ok {
|
||||
connection.TargetAccountID = account.Apply(p).TargetAccountID
|
||||
}
|
||||
}
|
||||
connection.Authenticated = true
|
||||
metric.AuthenticationSucceeded++
|
||||
if !authenticated {
|
||||
authenticated = true
|
||||
metric.AuthenticatedConnections++
|
||||
metric.AuthenticationLatencyTotalMS += float64(time.Since(started).Microseconds()) / 1000
|
||||
}
|
||||
case "authentication_failed":
|
||||
metric.AuthenticationFailed++
|
||||
}
|
||||
if category == model.EventReadOnlyRejected {
|
||||
i.rejected++
|
||||
}
|
||||
m.mu.Unlock()
|
||||
m.store.Event(p.ID, source, category, rule)
|
||||
})
|
||||
detail := "closed"
|
||||
@@ -143,6 +198,14 @@ func (m *Manager) Shutdown() {
|
||||
m.shutdown(30 * time.Second)
|
||||
}
|
||||
func (m *Manager) shutdown(grace time.Duration) {
|
||||
m.mu.Lock()
|
||||
hooks := m.shutdownHooks
|
||||
m.shutdownHooks = nil
|
||||
m.mu.Unlock()
|
||||
for _, hook := range hooks {
|
||||
hook()
|
||||
}
|
||||
m.setHealthInterval(0, false)
|
||||
m.mu.Lock()
|
||||
ids := make([]string, 0, len(m.instances))
|
||||
instances := make([]*instance, 0, len(m.instances))
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"serviceport/internal/model"
|
||||
"serviceport/internal/security"
|
||||
"serviceport/internal/store"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
@@ -11,6 +8,9 @@ import (
|
||||
"io"
|
||||
"net"
|
||||
"path/filepath"
|
||||
"serviceport/internal/model"
|
||||
"serviceport/internal/security"
|
||||
"serviceport/internal/store"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -61,7 +61,7 @@ func testManager(t *testing.T) (*Manager, model.Proxy, chan struct{}) {
|
||||
entered := make(chan struct{}, 10)
|
||||
m := New(s, &tls.Config{MinVersion: tls.VersionTLS12}, map[string]model.Handler{"mysql": heldHandler{entered}})
|
||||
t.Cleanup(m.Shutdown)
|
||||
return m, model.Proxy{ID: "test", Type: "mysql", ListenHost: "127.0.0.1", ListenPort: 0, AllowCIDRs: []string{"127.0.0.1/32"}, MaxConnections: 1}, entered
|
||||
return m, model.Proxy{ID: "test", TargetID: "fixture-target", TargetAccountID: "fixture-account", TargetAccounts: []model.TargetAccount{{ID: "fixture-account", Username: "fixture"}}, TargetUser: "fixture", Type: "mysql", ListenHost: "127.0.0.1", ListenPort: 0, AllowCIDRs: []string{"127.0.0.1/32"}, MaxConnections: 1}, entered
|
||||
}
|
||||
func address(m *Manager, id string) string {
|
||||
m.mu.Lock()
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"serviceport/internal/model"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Metrics count TCP acceptance separately from protocol authentication.
|
||||
type Metrics struct {
|
||||
ConnectionAttempts int64 `json:"connectionAttempts"`
|
||||
ConnectionRejected int64 `json:"connectionRejected"`
|
||||
UnauthenticatedClosed int64 `json:"unauthenticatedClosed"`
|
||||
AuthenticatedConnections int64 `json:"authenticatedConnections"`
|
||||
AuthenticationSucceeded int64 `json:"authenticationSucceeded"`
|
||||
AuthenticationFailed int64 `json:"authenticationFailed"`
|
||||
ActiveConnections int `json:"activeConnections"`
|
||||
ConnectionFailureRate float64 `json:"connectionFailureRate"`
|
||||
AuthenticationFailureRate float64 `json:"authenticationFailureRate"`
|
||||
MeanAuthenticationLatencyMS float64 `json:"meanAuthenticationLatencyMs"`
|
||||
AuthenticationLatencyTotalMS float64 `json:"-"`
|
||||
}
|
||||
|
||||
type HealthResult struct {
|
||||
TargetID string `json:"targetId"`
|
||||
AccountID string `json:"accountId"`
|
||||
Protocol string `json:"protocol"`
|
||||
Check string `json:"check"`
|
||||
CheckedAt time.Time `json:"checkedAt"`
|
||||
Healthy bool `json:"healthy"`
|
||||
Attempts int `json:"attempts"`
|
||||
LatencyMS float64 `json:"latencyMs"`
|
||||
Stage string `json:"stage,omitempty"`
|
||||
Result model.TestResult `json:"result"`
|
||||
}
|
||||
|
||||
func (m *Manager) metric(id string) *Metrics {
|
||||
v := m.metrics[id]
|
||||
if v == nil {
|
||||
v = &Metrics{}
|
||||
m.metrics[id] = v
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func (m *Manager) Metrics(id string) Metrics {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
v := *m.metric(id)
|
||||
if instance := m.instances[id]; instance != nil {
|
||||
v.ActiveConnections = len(instance.connections)
|
||||
}
|
||||
if v.ConnectionAttempts > 0 {
|
||||
v.ConnectionFailureRate = float64(v.ConnectionRejected+v.UnauthenticatedClosed) / float64(v.ConnectionAttempts)
|
||||
}
|
||||
if total := v.AuthenticationSucceeded + v.AuthenticationFailed; total > 0 {
|
||||
v.AuthenticationFailureRate = float64(v.AuthenticationFailed) / float64(total)
|
||||
}
|
||||
if v.AuthenticatedConnections > 0 {
|
||||
v.MeanAuthenticationLatencyMS = v.AuthenticationLatencyTotalMS / float64(v.AuthenticatedConnections)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func (m *Manager) HealthSnapshot() (int, []HealthResult) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
results := make([]HealthResult, 0, len(m.health))
|
||||
for _, result := range m.health {
|
||||
results = append(results, result)
|
||||
}
|
||||
return m.healthInterval, results
|
||||
}
|
||||
|
||||
// CheckHealth retries only the protocol's fixed health probe, never live sessions.
|
||||
func (m *Manager) CheckHealth(ctx context.Context, p model.Proxy, attempts int) (HealthResult, error) {
|
||||
if attempts < 1 || attempts > 3 {
|
||||
return HealthResult{}, errors.New("attempts must be between 1 and 3")
|
||||
}
|
||||
key := p.TargetID + "/" + p.TargetAccountID
|
||||
m.mu.Lock()
|
||||
if m.healthBusy[key] {
|
||||
m.mu.Unlock()
|
||||
return HealthResult{}, errors.New("health check already running for this account")
|
||||
}
|
||||
m.healthBusy[key] = true
|
||||
m.mu.Unlock()
|
||||
defer func() { m.mu.Lock(); delete(m.healthBusy, key); m.mu.Unlock() }()
|
||||
ctx, cancel := context.WithTimeout(ctx, 25*time.Second)
|
||||
defer cancel()
|
||||
started := time.Now()
|
||||
result := HealthResult{TargetID: p.TargetID, AccountID: p.TargetAccountID}
|
||||
result.Protocol = p.Type
|
||||
result.Check = protocolCheck(p.Type)
|
||||
for attempt := 1; attempt <= attempts; attempt++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
result.Stage = FailureCategory(err)
|
||||
break
|
||||
}
|
||||
result.Attempts = attempt
|
||||
probe, err := m.Test(ctx, p)
|
||||
if err == nil {
|
||||
result.Healthy = true
|
||||
result.Result = probe
|
||||
result.Stage = "ok"
|
||||
break
|
||||
}
|
||||
result.Stage = FailureCategory(err)
|
||||
if attempt == attempts || (result.Stage != "connection_timeout" && result.Stage != "target_connection_or_protocol" && result.Stage != "socks5_connection" && result.Stage != "ssh_forward_connection") {
|
||||
break
|
||||
}
|
||||
timer := time.NewTimer(time.Duration(attempt) * 100 * time.Millisecond)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
result.CheckedAt = time.Now().UTC()
|
||||
result.LatencyMS = float64(time.Since(started).Microseconds()) / 1000
|
||||
m.mu.Lock()
|
||||
m.health[key] = result
|
||||
m.mu.Unlock()
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func protocolCheck(protocol string) string {
|
||||
switch protocol {
|
||||
case "mysql":
|
||||
return "MySQL handshake + ping"
|
||||
case "pgsql":
|
||||
return "PostgreSQL startup + SELECT 1"
|
||||
case "mongodb":
|
||||
return "MongoDB hello/buildInfo"
|
||||
case "redis":
|
||||
return "Redis AUTH + PING"
|
||||
case "rabbitmq":
|
||||
return "AMQP connection + virtual host"
|
||||
case "sqlserver":
|
||||
return "TDS prelogin + LOGIN7"
|
||||
case "http", "https":
|
||||
return "HTTP HEAD + Basic Auth"
|
||||
case "ssh":
|
||||
return "SSH handshake + authentication"
|
||||
case "sftp":
|
||||
return "SSH + SFTP subsystem + working directory"
|
||||
default:
|
||||
return "protocol health probe"
|
||||
}
|
||||
}
|
||||
|
||||
// SetHealthInterval persists operator settings. Shutdown only stops the worker.
|
||||
func (m *Manager) SetHealthInterval(seconds int) error {
|
||||
return m.setHealthInterval(seconds, true)
|
||||
}
|
||||
func (m *Manager) ResumeHealthChecks() error {
|
||||
seconds, err := m.store.HealthInterval()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return m.setHealthInterval(seconds, false)
|
||||
}
|
||||
func (m *Manager) setHealthInterval(seconds int, persist bool) error {
|
||||
if seconds != 0 && (seconds < 10 || seconds > 86400) {
|
||||
return errors.New("intervalSeconds must be 0 or between 10 and 86400")
|
||||
}
|
||||
m.healthMu.Lock()
|
||||
defer m.healthMu.Unlock()
|
||||
if persist {
|
||||
if err := m.store.SaveHealthInterval(seconds); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if m.healthCancel != nil {
|
||||
m.healthCancel()
|
||||
<-m.healthDone
|
||||
m.healthCancel = nil
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.healthInterval = seconds
|
||||
m.mu.Unlock()
|
||||
if seconds == 0 {
|
||||
return nil
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
m.healthCancel = cancel
|
||||
m.healthDone = make(chan struct{})
|
||||
done := m.healthDone
|
||||
go func() {
|
||||
defer close(done)
|
||||
ticker := time.NewTicker(time.Duration(seconds) * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
m.checkAllAccounts(ctx)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) checkAllAccounts(ctx context.Context) {
|
||||
targets, err := m.store.Targets()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
active := map[string]bool{}
|
||||
for _, target := range targets {
|
||||
for _, account := range target.Accounts {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
p := target.Runtime()
|
||||
p.TargetAccountID = account.ID
|
||||
p = target.Apply(p)
|
||||
active[target.ID+"/"+account.ID] = true
|
||||
m.CheckHealth(ctx, p, 1)
|
||||
}
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for key := range m.health {
|
||||
if !active[key] {
|
||||
delete(m.health, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"net"
|
||||
"serviceport/internal/model"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type healthFixture struct {
|
||||
calls atomic.Int32
|
||||
fail error
|
||||
succeedAfter int32
|
||||
block bool
|
||||
}
|
||||
|
||||
func (h *healthFixture) Serve(ctx context.Context, c net.Conn, p model.Proxy, d model.DialFunc, tc *tls.Config, event model.EventFunc) error {
|
||||
event("authentication_failed", `{"username":"wrong"}`)
|
||||
event("authentication_succeeded", `{"username":"correct"}`)
|
||||
<-ctx.Done()
|
||||
return ctx.Err()
|
||||
}
|
||||
func (h *healthFixture) Test(ctx context.Context, p model.Proxy, d model.DialFunc) (model.TestResult, error) {
|
||||
n := h.calls.Add(1)
|
||||
if h.block {
|
||||
<-ctx.Done()
|
||||
return model.TestResult{}, ctx.Err()
|
||||
}
|
||||
if h.succeedAfter > 0 && n >= h.succeedAfter {
|
||||
return model.TestResult{Version: "fixture"}, nil
|
||||
}
|
||||
return model.TestResult{}, h.fail
|
||||
}
|
||||
|
||||
func TestHealthRetryAndCancellation(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
failure error
|
||||
after int32
|
||||
want int
|
||||
healthy bool
|
||||
}{
|
||||
{"transient", errors.New("connection refused"), 2, 2, true},
|
||||
{"exhausted", errors.New("connection refused"), 0, 3, false},
|
||||
{"credentials", errors.New("authentication failed with secret"), 0, 1, false},
|
||||
{"certificate", errors.New("x509 unknown authority"), 0, 1, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
m, p, _ := testManager(t)
|
||||
h := &healthFixture{fail: tc.failure, succeedAfter: tc.after}
|
||||
m.handlers[p.Type] = h
|
||||
r, err := m.CheckHealth(context.Background(), p, 3)
|
||||
if err != nil || r.Attempts != tc.want || r.Healthy != tc.healthy || r.CheckedAt.IsZero() || r.LatencyMS < 0 {
|
||||
t.Fatalf("result=%+v err=%v", r, err)
|
||||
}
|
||||
_, snap := m.HealthSnapshot()
|
||||
if len(snap) != 1 || snap[0].TargetID != p.TargetID {
|
||||
t.Fatal(snap)
|
||||
}
|
||||
})
|
||||
}
|
||||
m, p, _ := testManager(t)
|
||||
m.handlers[p.Type] = &healthFixture{block: true}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
|
||||
defer cancel()
|
||||
r, err := m.CheckHealth(ctx, p, 3)
|
||||
if err != nil || r.Healthy || r.Attempts != 1 || r.Stage != "connection_timeout" {
|
||||
t.Fatalf("%+v %v", r, err)
|
||||
}
|
||||
for _, n := range []int{0, 4} {
|
||||
if _, err := m.CheckHealth(context.Background(), p, n); err == nil {
|
||||
t.Fatal("invalid attempts accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetricsSeparateSocketsAndAuthentication(t *testing.T) {
|
||||
m, p, _ := testManager(t)
|
||||
m.handlers[p.Type] = &healthFixture{}
|
||||
if err := m.Start(p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := net.Dial("tcp", address(m, p.ID))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for m.Metrics(p.ID).AuthenticationSucceeded == 0 && time.Now().Before(deadline) {
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
r := m.Metrics(p.ID)
|
||||
if r.ConnectionAttempts != 1 || r.ActiveConnections != 1 || r.AuthenticationFailed != 1 || r.AuthenticationSucceeded != 1 || r.AuthenticatedConnections != 1 || r.AuthenticationFailureRate != 0.5 || r.ConnectionFailureRate != 0 {
|
||||
t.Fatalf("metrics %+v", r)
|
||||
}
|
||||
m.Stop(p.ID)
|
||||
r = m.Metrics(p.ID)
|
||||
if r.ActiveConnections != 0 || r.UnauthenticatedClosed != 0 || r.ConnectionAttempts != 1 {
|
||||
t.Fatalf("after stop %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthWorkerLifecycle(t *testing.T) {
|
||||
m, _, _ := testManager(t)
|
||||
if n, _ := m.HealthSnapshot(); n != 0 {
|
||||
t.Fatal("background checks should default to disabled")
|
||||
}
|
||||
for _, n := range []int{-1, 1, 86401} {
|
||||
if err := m.SetHealthInterval(n); err == nil {
|
||||
t.Fatal("invalid interval accepted")
|
||||
}
|
||||
}
|
||||
if err := m.SetHealthInterval(10); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
done := m.healthDone
|
||||
if err := m.SetHealthInterval(0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
default:
|
||||
t.Fatal("old worker not joined")
|
||||
}
|
||||
if n, _ := m.HealthSnapshot(); n != 0 {
|
||||
t.Fatal(n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthSettingsSurviveShutdownAndCoverEveryAccount(t *testing.T) {
|
||||
m, p, _ := testManager(t)
|
||||
target := model.Target{ID: "target", Type: p.Type, Accounts: []model.TargetAccount{{ID: "one", Username: "one"}, {ID: "two", Username: "two"}}}
|
||||
if err := m.store.SaveTarget(target); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := &healthFixture{}
|
||||
m.handlers[p.Type] = h
|
||||
m.checkAllAccounts(context.Background())
|
||||
_, health := m.HealthSnapshot()
|
||||
if h.calls.Load() != 2 || len(health) != 2 {
|
||||
t.Fatal("not all accounts checked", health)
|
||||
}
|
||||
if err := m.SetHealthInterval(60); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.Shutdown()
|
||||
next := New(m.store, nil, map[string]model.Handler{p.Type: h})
|
||||
defer next.Shutdown()
|
||||
if err := next.ResumeHealthChecks(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n, _ := next.HealthSnapshot(); n != 60 {
|
||||
t.Fatal("interval lost", n)
|
||||
}
|
||||
if err := next.SetHealthInterval(0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n, err := m.store.HealthInterval(); err != nil || n != 0 {
|
||||
t.Fatal("disabled setting not saved", n, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrCredentialUnavailable = errors.New("credential expired or unavailable")
|
||||
|
||||
// CredentialDue uses seven days when reminderDays is zero. Empty expiry never
|
||||
// expires; malformed timestamps fail closed, including for imported snapshots.
|
||||
func CredentialDue(expiresAt string, reminderDays int, now time.Time) (due, expired bool) {
|
||||
if expiresAt == "" {
|
||||
return false, false
|
||||
}
|
||||
expiry, err := time.Parse(time.RFC3339, expiresAt)
|
||||
if err != nil {
|
||||
return true, true
|
||||
}
|
||||
if reminderDays == 0 {
|
||||
reminderDays = 7
|
||||
}
|
||||
return !now.Before(expiry.Add(-time.Duration(reminderDays) * 24 * time.Hour)), !now.Before(expiry)
|
||||
}
|
||||
func credentialExpired(expiresAt string, now time.Time) bool {
|
||||
_, expired := CredentialDue(expiresAt, 0, now)
|
||||
return expired
|
||||
}
|
||||
func validateCredentialExpiry(expiresAt string, days int) error {
|
||||
if days < 0 || days > 365 {
|
||||
return errors.New("rotationReminderDays must be between 0 and 365 (0 defaults to 7)")
|
||||
}
|
||||
if expiresAt != "" {
|
||||
if _, err := time.Parse(time.RFC3339, expiresAt); err != nil {
|
||||
return errors.New("expiresAt must be RFC3339")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateAccountReferences permits incomplete, stopped gateways, but never
|
||||
// dangling account references, even on disabled gateway accounts.
|
||||
func (t Target) ValidateAccountReferences(p Proxy) error {
|
||||
if p.TargetAccountID != "" && !t.HasAccount(p.TargetAccountID) {
|
||||
return errors.New("target account not found")
|
||||
}
|
||||
for _, a := range p.GatewayAccounts {
|
||||
if a.TargetAccountID != "" && !t.HasAccount(a.TargetAccountID) {
|
||||
return errors.New("gateway target account not found")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p Proxy) selectTargetAccount(override string) Proxy {
|
||||
if override != "" {
|
||||
p.TargetAccountID = override
|
||||
}
|
||||
if len(p.TargetAccounts) == 0 && override == "" {
|
||||
return p
|
||||
} // Legacy fixture/runtime.
|
||||
for _, a := range p.TargetAccounts {
|
||||
if a.ID == p.TargetAccountID {
|
||||
return a.Apply(p)
|
||||
}
|
||||
}
|
||||
p.TargetUser, p.TargetPassword = "", ""
|
||||
p.SSH.PrivateKey, p.SSH.Passphrase = "", ""
|
||||
p.TargetCredentialError = ErrCredentialUnavailable
|
||||
return p
|
||||
}
|
||||
|
||||
func (p Proxy) CheckTargetCredential() error {
|
||||
if p.TargetCredentialError != nil {
|
||||
return p.TargetCredentialError
|
||||
}
|
||||
if credentialExpired(p.TargetCredentialExpiresAt, time.Now()) {
|
||||
return ErrCredentialUnavailable
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateTargetSelection checks completeness without rejecting a whole gateway
|
||||
// when one account is expired. Each authentication independently enforces expiry.
|
||||
func (p Proxy) ValidateTargetSelection() error {
|
||||
t := Target{Accounts: p.TargetAccounts}
|
||||
if err := t.ValidateAccountReferences(p); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(p.GatewayAccounts) == 0 {
|
||||
if !t.HasAccount(p.TargetAccountID) {
|
||||
return errors.New("select a target account")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, a := range p.GatewayAccounts {
|
||||
if !a.Disabled && a.TargetAccountID == "" && !t.HasAccount(p.TargetAccountID) {
|
||||
return errors.New("select a target account for each enabled gateway account")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (p Proxy) validateGatewayAccounts() error {
|
||||
if len(p.GatewayAccounts) > 32 {
|
||||
return errors.New("at most 32 gateway accounts")
|
||||
}
|
||||
ids, users := map[string]bool{}, map[string]bool{}
|
||||
for _, a := range p.GatewayAccounts {
|
||||
if err := validateCredentialExpiry(a.ExpiresAt, a.RotationReminderDays); err != nil {
|
||||
return err
|
||||
}
|
||||
if !accountIDPattern.MatchString(a.ID) || ids[a.ID] {
|
||||
return errors.New("invalid or duplicate gateway account id")
|
||||
}
|
||||
ids[a.ID] = true
|
||||
if strings.TrimSpace(a.Username) == "" || len(a.Username) > 128 || strings.ContainsAny(a.Username, "\r\n\x00") || users[a.Username] {
|
||||
return errors.New("invalid or duplicate gateway username")
|
||||
}
|
||||
users[a.Username] = true
|
||||
if a.Password == "" && (!IsSSH(p.Type) || len(a.AuthorizedKeys) == 0) {
|
||||
return errors.New("gateway account password is required")
|
||||
}
|
||||
if a.AccessMode != "" && a.AccessMode != "readonly" && a.AccessMode != "readwrite" {
|
||||
return errors.New("invalid gateway account accessMode")
|
||||
}
|
||||
if a.AccessMode == "readonly" && p.Type != "mysql" && p.Type != "mongodb" {
|
||||
return errors.New("readonly is currently supported only for MySQL and MongoDB")
|
||||
}
|
||||
if IsSSH(p.Type) && a.AccessMode != "" {
|
||||
return errors.New("SSH has no accessMode")
|
||||
}
|
||||
for _, cidr := range a.AllowCIDRs {
|
||||
if _, e := netip.ParsePrefix(cidr); e != nil {
|
||||
if _, e = netip.ParseAddr(cidr); e != nil {
|
||||
return errors.New("invalid account source IP/CIDR")
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, key := range a.AuthorizedKeys {
|
||||
if _, _, _, _, e := ssh.ParseAuthorizedKey([]byte(key)); e != nil {
|
||||
return errors.New("invalid gateway authorized key")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func mergeGatewaySecrets(current Proxy, patch map[string]any, out *Proxy) error {
|
||||
raw, present := patch["gatewayAccounts"]
|
||||
if !present {
|
||||
return nil
|
||||
}
|
||||
entries, ok := raw.([]any)
|
||||
if !ok || len(entries) == 0 {
|
||||
return errors.New("gatewayAccounts must contain at least one account")
|
||||
}
|
||||
byID := map[string]GatewayAccount{}
|
||||
for _, a := range current.GatewayAccounts {
|
||||
byID[a.ID] = a
|
||||
}
|
||||
if len(current.GatewayAccounts) == 0 {
|
||||
byID["legacy"] = GatewayAccount{Password: current.GatewayPassword}
|
||||
}
|
||||
for i, entry := range entries {
|
||||
fields, ok := entry.(map[string]any)
|
||||
if !ok {
|
||||
return errors.New("invalid gateway account")
|
||||
}
|
||||
if err := exactFields(fields, reflect.TypeOf(GatewayAccount{})); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, ok := fields["password"]; !ok {
|
||||
out.GatewayAccounts[i].Password = byID[out.GatewayAccounts[i].ID].Password
|
||||
}
|
||||
}
|
||||
out.GatewayUser, out.GatewayPassword = "", ""
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package model
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestGatewayAccountSelectionAndMerge(t *testing.T) {
|
||||
p := Defaults()
|
||||
p.Name = "gateway"
|
||||
p.Type = "mysql"
|
||||
p.ListenPort = 3306
|
||||
p.GatewayUser = "old"
|
||||
p.GatewayPassword = "old-secret"
|
||||
patch := map[string]any{"gatewayAccounts": []any{map[string]any{"id": "a", "username": "one", "password": "first"}, map[string]any{"id": "b", "username": "two", "password": "second", "disabled": true}}}
|
||||
p, err := Merge(p, patch)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = p.ValidateGateway(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := p.GatewayAccount("old"); ok {
|
||||
t.Fatal("legacy account still authenticates")
|
||||
}
|
||||
if _, ok := p.GatewayAccount("two"); ok {
|
||||
t.Fatal("disabled account authenticates")
|
||||
}
|
||||
patch["gatewayAccounts"] = []any{map[string]any{"id": "b", "username": "two", "disabled": true}, map[string]any{"id": "a", "username": "renamed"}}
|
||||
p, err = Merge(p, patch)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.GatewayAccounts[1].Password != "first" {
|
||||
t.Fatal("password was not retained by ID")
|
||||
}
|
||||
redacted, flags := p.Redacted()
|
||||
if redacted.GatewayAccounts[1].Password != "" || !flags["gatewayAccount:a:password"] || p.GatewayAccounts[1].Password != "first" {
|
||||
t.Fatal("redaction mutated snapshot or exposed password")
|
||||
}
|
||||
p.AccessMode = "readonly"
|
||||
a := GatewayAccount{AccessMode: "readwrite"}
|
||||
if a.Apply(p).AccessMode != "readonly" {
|
||||
t.Fatal("account elevated gateway permissions")
|
||||
}
|
||||
}
|
||||
func TestGatewayValidationAndRequiredTargetUser(t *testing.T) {
|
||||
p := Defaults()
|
||||
p.Name = "gateway"
|
||||
p.Type = "redis"
|
||||
p.ListenPort = 6379
|
||||
p.GatewayUser = "user"
|
||||
p.GatewayPassword = "pass"
|
||||
if err := p.ValidateGateway(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p.TargetHost = "localhost"
|
||||
p.TargetPort = 6379
|
||||
if err := p.Validate(); err == nil {
|
||||
t.Fatal("empty Redis username accepted")
|
||||
}
|
||||
p.TargetUser = " "
|
||||
if err := p.Validate(); err == nil {
|
||||
t.Fatal("blank target username accepted")
|
||||
}
|
||||
p.GatewayUser = " "
|
||||
if err := p.ValidateGateway(); err == nil {
|
||||
t.Fatal("blank gateway username accepted")
|
||||
}
|
||||
}
|
||||
+74
-25
@@ -4,38 +4,84 @@ import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Proxy is a resolved runtime snapshot of a gateway and its referenced target.
|
||||
// Only GatewayOnly is persisted in the gateway table. HTTP responses must use
|
||||
// Redacted rather than marshal a snapshot containing decrypted credentials.
|
||||
type Proxy struct {
|
||||
ID string `json:"id"`
|
||||
TargetID string `json:"targetId,omitempty"`
|
||||
TargetAccountID string `json:"targetAccountId,omitempty"`
|
||||
OutboundID string `json:"outboundId,omitempty"`
|
||||
TargetCompression string `json:"targetCompression,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Group string `json:"group,omitempty"`
|
||||
Type string `json:"type"`
|
||||
ListenHost string `json:"listenHost"`
|
||||
ListenPort int `json:"listenPort"`
|
||||
AllowCIDRs []string `json:"allowCIDRs"`
|
||||
TargetHost string `json:"targetHost"`
|
||||
TargetPort int `json:"targetPort"`
|
||||
TargetUser string `json:"targetUser"`
|
||||
TargetPassword string `json:"targetPassword,omitempty"`
|
||||
GatewayUser string `json:"gatewayUser"`
|
||||
GatewayPassword string `json:"gatewayPassword,omitempty"`
|
||||
AccessMode string `json:"accessMode,omitempty"`
|
||||
MaxConnections int `json:"maxConnections"`
|
||||
Enabled bool `json:"enabled"`
|
||||
TargetTLS bool `json:"targetTLS"`
|
||||
TargetCA string `json:"targetCA,omitempty"`
|
||||
AuthSource string `json:"authSource,omitempty"`
|
||||
Outbound Outbound `json:"outbound"`
|
||||
SSH SSHConfig `json:"ssh"`
|
||||
TargetAccounts []TargetAccount `json:"-"`
|
||||
TargetCredentialError error `json:"-"`
|
||||
TargetCredentialExpiresAt string `json:"-"`
|
||||
GatewayAccounts []GatewayAccount `json:"gatewayAccounts,omitempty"`
|
||||
ID string `json:"id"`
|
||||
TargetID string `json:"targetId,omitempty"`
|
||||
TargetAccountID string `json:"targetAccountId,omitempty"`
|
||||
OutboundID string `json:"outboundId,omitempty"`
|
||||
TargetCompression string `json:"targetCompression,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Group string `json:"group,omitempty"`
|
||||
Type string `json:"type"`
|
||||
ListenHost string `json:"listenHost"`
|
||||
ListenPort int `json:"listenPort"`
|
||||
AllowCIDRs []string `json:"allowCIDRs"`
|
||||
TargetHost string `json:"targetHost"`
|
||||
TargetPort int `json:"targetPort"`
|
||||
TargetUser string `json:"targetUser"`
|
||||
TargetPassword string `json:"targetPassword,omitempty"`
|
||||
GatewayUser string `json:"gatewayUser"`
|
||||
GatewayPassword string `json:"gatewayPassword,omitempty"`
|
||||
AccessMode string `json:"accessMode,omitempty"`
|
||||
MaxConnections int `json:"maxConnections"`
|
||||
Enabled bool `json:"enabled"`
|
||||
TargetTLS bool `json:"targetTLS"`
|
||||
TargetCA string `json:"targetCA,omitempty"`
|
||||
AuthSource string `json:"authSource,omitempty"`
|
||||
Outbound Outbound `json:"outbound"`
|
||||
SSH SSHConfig `json:"ssh"`
|
||||
}
|
||||
type GatewayAccount struct {
|
||||
TargetAccountID string `json:"targetAccountId,omitempty"`
|
||||
ExpiresAt string `json:"expiresAt,omitempty"`
|
||||
RotationReminderDays int `json:"rotationReminderDays,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password,omitempty"`
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
AccessMode string `json:"accessMode,omitempty"`
|
||||
AllowCIDRs []string `json:"allowCIDRs,omitempty"`
|
||||
AuthorizedKeys []string `json:"authorizedKeys,omitempty"`
|
||||
}
|
||||
|
||||
func (p Proxy) GatewayAccount(username string) (GatewayAccount, bool) {
|
||||
for _, a := range p.GatewayAccounts {
|
||||
if !a.Disabled && !credentialExpired(a.ExpiresAt, time.Now()) && a.Username == username {
|
||||
return a, true
|
||||
}
|
||||
}
|
||||
if len(p.GatewayAccounts) == 0 && username == p.GatewayUser {
|
||||
return GatewayAccount{ID: "legacy", Username: p.GatewayUser, Password: p.GatewayPassword, AccessMode: p.AccessMode, AllowCIDRs: p.AllowCIDRs, AuthorizedKeys: p.SSH.AuthorizedKeys}, p.GatewayUser != ""
|
||||
}
|
||||
return GatewayAccount{}, false
|
||||
}
|
||||
func (a GatewayAccount) Apply(p Proxy) Proxy {
|
||||
p = p.selectTargetAccount(a.TargetAccountID)
|
||||
if a.Disabled || credentialExpired(a.ExpiresAt, time.Now()) {
|
||||
p.TargetCredentialError = ErrCredentialUnavailable
|
||||
}
|
||||
readonly := p.AccessMode == "readonly"
|
||||
p.GatewayUser, p.GatewayPassword = a.Username, a.Password
|
||||
if a.AccessMode != "" {
|
||||
p.AccessMode = a.AccessMode
|
||||
}
|
||||
p.SSH.AuthorizedKeys = a.AuthorizedKeys
|
||||
if readonly {
|
||||
p.AccessMode = "readonly"
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
type Outbound struct {
|
||||
KubeConfig string `json:"kubeConfig,omitempty"`
|
||||
KubeContext string `json:"kubeContext,omitempty"`
|
||||
@@ -60,6 +106,9 @@ type SSHConfig struct {
|
||||
}
|
||||
|
||||
type DialFunc func(context.Context, string, string) (net.Conn, error)
|
||||
|
||||
func IsSSH(protocol string) bool { return protocol == "ssh" || protocol == "sftp" }
|
||||
|
||||
type EventFunc func(category, rule string)
|
||||
|
||||
const EventReadOnlyRejected = "readonly_rejected"
|
||||
|
||||
@@ -32,6 +32,9 @@ func TargetFromProxy(p Proxy) Target {
|
||||
return Target{OutboundID: p.OutboundID, TargetCompression: p.TargetCompression, ID: p.TargetID, Name: p.Name, Group: p.Group, Type: p.Type, TargetHost: p.TargetHost, TargetPort: p.TargetPort, TargetTLS: p.TargetTLS, TargetCA: p.TargetCA, AuthSource: p.AuthSource, Outbound: p.Outbound, SSH: TargetSSH{TargetHostKey: p.SSH.TargetHostKey, IgnoreHostKey: p.SSH.IgnoreHostKey}}
|
||||
}
|
||||
func (t Target) Apply(p Proxy) Proxy {
|
||||
p.TargetAccounts = append([]TargetAccount(nil), t.Accounts...)
|
||||
p.TargetCredentialError = nil
|
||||
p.TargetCredentialExpiresAt = ""
|
||||
p.TargetCompression = t.TargetCompression
|
||||
p.TargetID = t.ID
|
||||
p.Group = t.Group
|
||||
@@ -74,7 +77,7 @@ func (t *Target) Validate() error {
|
||||
p.AllowCIDRs = []string{"127.0.0.1"}
|
||||
p.GatewayUser = "validation"
|
||||
p.GatewayPassword = "validation"
|
||||
if p.Type == "ssh" {
|
||||
if IsSSH(p.Type) {
|
||||
p.AccessMode = ""
|
||||
}
|
||||
if err := p.Validate(); err != nil {
|
||||
@@ -90,7 +93,7 @@ func (t *Target) Validate() error {
|
||||
func (t Target) Runtime() Proxy {
|
||||
p := t.Apply(Defaults())
|
||||
p.ID, p.Name, p.Type = t.ID, t.Name, t.Type
|
||||
if p.Type == "ssh" {
|
||||
if IsSSH(p.Type) {
|
||||
p.AccessMode = ""
|
||||
}
|
||||
return p
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Accounts share the target's endpoint, transport and host identity.
|
||||
type TargetAccount struct {
|
||||
ExpiresAt string `json:"expiresAt,omitempty"`
|
||||
RotationReminderDays int `json:"rotationReminderDays,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password,omitempty"`
|
||||
AuthMethod string `json:"authMethod,omitempty"`
|
||||
PrivateKey string `json:"privateKey,omitempty"`
|
||||
Passphrase string `json:"passphrase,omitempty"`
|
||||
}
|
||||
|
||||
func (a TargetAccount) Apply(p Proxy) Proxy {
|
||||
p.TargetCredentialExpiresAt = a.ExpiresAt
|
||||
p.TargetCredentialError = nil
|
||||
p.TargetUser, p.TargetPassword = a.Username, a.Password
|
||||
p.SSH.AuthMethod, p.SSH.PrivateKey, p.SSH.Passphrase = a.AuthMethod, a.PrivateKey, a.Passphrase
|
||||
if p.SSH.AuthMethod == "" {
|
||||
p.SSH.AuthMethod = "password"
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func (t Target) HasAccount(id string) bool {
|
||||
if id == "" {
|
||||
return false
|
||||
}
|
||||
for _, a := range t.Accounts {
|
||||
if a.ID == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (t Target) Resolve(p Proxy) (Proxy, error) {
|
||||
if !t.HasAccount(p.TargetAccountID) {
|
||||
return Proxy{}, errors.New("target account not found")
|
||||
}
|
||||
p = t.Apply(p)
|
||||
if err := p.CheckTargetCredential(); err != nil {
|
||||
return Proxy{}, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
var accountIDPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`)
|
||||
|
||||
func (t *Target) ValidateAccounts() error {
|
||||
if len(t.Accounts) > 32 {
|
||||
return errors.New("at most 32 target accounts")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for i := range t.Accounts {
|
||||
a := &t.Accounts[i]
|
||||
if err := validateCredentialExpiry(a.ExpiresAt, a.RotationReminderDays); err != nil {
|
||||
return err
|
||||
}
|
||||
a.Name = strings.TrimSpace(a.Name)
|
||||
if !accountIDPattern.MatchString(a.ID) || seen[a.ID] {
|
||||
return errors.New("invalid or duplicate target account id")
|
||||
}
|
||||
seen[a.ID] = true
|
||||
if a.Name == "" || len(a.Name) > 100 {
|
||||
return errors.New("target account name is required (max 100 bytes)")
|
||||
}
|
||||
p := a.Apply(t.Runtime())
|
||||
p.ListenPort, p.GatewayUser, p.GatewayPassword = 1, "validation", "validation"
|
||||
if err := p.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Omitted credentials retain their previous value by stable account ID; explicit
|
||||
// empty strings clear credentials. Never match credentials by array position.
|
||||
func mergeAccountSecrets(current []TargetAccount, patch map[string]any, out *Target) error {
|
||||
raw, present := patch["accounts"]
|
||||
if !present {
|
||||
return nil
|
||||
}
|
||||
entries, ok := raw.([]any)
|
||||
if !ok {
|
||||
return errors.New("accounts must be an array")
|
||||
}
|
||||
byID := map[string]TargetAccount{}
|
||||
for _, a := range current {
|
||||
byID[a.ID] = a
|
||||
}
|
||||
for i, entry := range entries {
|
||||
fields, ok := entry.(map[string]any)
|
||||
if !ok {
|
||||
return errors.New("invalid account")
|
||||
}
|
||||
if err := exactFields(fields, reflect.TypeOf(TargetAccount{})); err != nil {
|
||||
return err
|
||||
}
|
||||
old := byID[out.Accounts[i].ID]
|
||||
if _, exists := fields["password"]; !exists {
|
||||
out.Accounts[i].Password = old.Password
|
||||
}
|
||||
if _, exists := fields["privateKey"]; !exists {
|
||||
out.Accounts[i].PrivateKey = old.PrivateKey
|
||||
}
|
||||
if _, exists := fields["passphrase"]; !exists {
|
||||
out.Accounts[i].Passphrase = old.Passphrase
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAccountCredentialsMergeAndRedaction(t *testing.T) {
|
||||
target := Target{Accounts: []TargetAccount{
|
||||
{ID: "normal", Name: "Normal", Username: "user", Password: "normal-secret"},
|
||||
{ID: "root", Name: "Root", Username: "root", AuthMethod: "privatekey", PrivateKey: "private-secret", Passphrase: "phrase-secret"},
|
||||
}}
|
||||
updated, err := MergeTarget(target, map[string]any{"accounts": []any{
|
||||
map[string]any{"id": "root", "name": "Root", "username": "root", "authMethod": "privatekey", "passphrase": ""},
|
||||
map[string]any{"id": "normal", "name": "Normal", "username": "user"},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if updated.Accounts[0].PrivateKey != "private-secret" || updated.Accounts[0].Passphrase != "" || updated.Accounts[1].Password != "normal-secret" {
|
||||
t.Fatal("credentials did not merge by ID")
|
||||
}
|
||||
public, _ := updated.Redacted()
|
||||
raw, _ := json.Marshal(public)
|
||||
for _, secret := range []string{"normal-secret", "private-secret", "phrase-secret"} {
|
||||
if strings.Contains(string(raw), secret) {
|
||||
t.Fatal("account credential leaked")
|
||||
}
|
||||
}
|
||||
if updated.Accounts[0].PrivateKey != "private-secret" {
|
||||
t.Fatal("redaction mutated stored credentials")
|
||||
}
|
||||
p, err := updated.Resolve(Proxy{TargetAccountID: "root"})
|
||||
if err != nil || p.TargetUser != "root" || p.SSH.PrivateKey != "private-secret" || p.TargetPassword != "" {
|
||||
t.Fatal("private key account not applied")
|
||||
}
|
||||
if _, err = updated.Resolve(Proxy{TargetAccountID: "missing"}); err == nil {
|
||||
t.Fatal("missing account silently used default")
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,24 @@ func Defaults() Proxy {
|
||||
return Proxy{ListenHost: "0.0.0.0", MaxConnections: 100, TargetTLS: true, AccessMode: "readwrite", AuthSource: "admin", Outbound: Outbound{Mode: "direct", Auth: "none"}, SSH: SSHConfig{AuthMethod: "password"}}
|
||||
}
|
||||
func (p *Proxy) Validate() error {
|
||||
if len(p.GatewayAccounts) > 0 {
|
||||
if err := p.validateGatewayAccounts(); err != nil {
|
||||
return err
|
||||
}
|
||||
probe := *p
|
||||
probe.GatewayUser = p.GatewayAccounts[0].Username
|
||||
probe.GatewayPassword = p.GatewayAccounts[0].Password
|
||||
probe.SSH.AuthorizedKeys = p.GatewayAccounts[0].AuthorizedKeys
|
||||
probe.GatewayAccounts = nil
|
||||
if err := probe.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
p.AccessMode = probe.AccessMode
|
||||
p.TargetCompression = probe.TargetCompression
|
||||
p.AuthSource = probe.AuthSource
|
||||
p.Outbound = probe.Outbound
|
||||
return nil
|
||||
}
|
||||
if p.TargetCompression == "" {
|
||||
p.TargetCompression = "none"
|
||||
}
|
||||
@@ -29,7 +47,7 @@ func (p *Proxy) Validate() error {
|
||||
if len(p.Group) > 100 || strings.ContainsAny(p.Group, "\r\n\x00") {
|
||||
return errors.New("group must contain at most 100 bytes")
|
||||
}
|
||||
if p.Type != "mysql" && p.Type != "mongodb" && p.Type != "ssh" && p.Type != "redis" && p.Type != "rabbitmq" && p.Type != "pgsql" {
|
||||
if p.Type != "mysql" && p.Type != "mongodb" && !IsSSH(p.Type) && p.Type != "redis" && p.Type != "rabbitmq" && p.Type != "pgsql" && p.Type != "http" && p.Type != "https" && p.Type != "sqlserver" {
|
||||
return errors.New("unsupported proxy type")
|
||||
}
|
||||
if net.ParseIP(p.ListenHost) == nil {
|
||||
@@ -51,13 +69,10 @@ func (p *Proxy) Validate() error {
|
||||
if p.MaxConnections < 1 || p.MaxConnections > 10000 {
|
||||
return errors.New("maxConnections must be 1-10000")
|
||||
}
|
||||
if p.Type == "redis" && p.TargetUser == "" {
|
||||
p.TargetUser = "default"
|
||||
}
|
||||
if p.TargetUser == "" || p.GatewayUser == "" {
|
||||
if strings.TrimSpace(p.TargetUser) == "" || strings.TrimSpace(p.GatewayUser) == "" {
|
||||
return errors.New("target and gateway usernames are required")
|
||||
}
|
||||
if p.Type == "ssh" {
|
||||
if IsSSH(p.Type) {
|
||||
if p.AccessMode != "" {
|
||||
return errors.New("SSH has no accessMode")
|
||||
}
|
||||
@@ -125,12 +140,27 @@ func (p *Proxy) Validate() error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (p *Proxy) ValidateGateway() error {
|
||||
probe := *p
|
||||
probe.TargetHost, probe.TargetPort, probe.TargetUser = "validation", 1, "validation"
|
||||
probe.TargetCompression = "none"
|
||||
probe.Outbound = Outbound{Mode: "direct"}
|
||||
probe.SSH.AuthMethod = "password"
|
||||
if err := probe.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
p.AccessMode = probe.AccessMode
|
||||
return nil
|
||||
}
|
||||
func port(n int) bool { return n >= 1 && n <= 65535 }
|
||||
|
||||
func host(s string) bool {
|
||||
return len(s) > 0 && len(s) <= 253 && !strings.ContainsAny(s, " /\\\t\r\n\x00")
|
||||
}
|
||||
func (p Proxy) Redacted() (Proxy, map[string]bool) {
|
||||
p.TargetAccounts = nil
|
||||
p.TargetCredentialError = nil
|
||||
p.TargetCredentialExpiresAt = ""
|
||||
flags := map[string]bool{"targetPassword": p.TargetPassword != "", "gatewayPassword": p.GatewayPassword != "", "socksPassword": p.Outbound.Password != "", "sshPrivateKey": p.SSH.PrivateKey != "", "sshPassphrase": p.SSH.Passphrase != ""}
|
||||
p.TargetPassword = ""
|
||||
p.GatewayPassword = ""
|
||||
@@ -138,6 +168,12 @@ func (p Proxy) Redacted() (Proxy, map[string]bool) {
|
||||
p.SSH.PrivateKey = ""
|
||||
p.SSH.Passphrase = ""
|
||||
p.SSH.HostKey = ""
|
||||
p.GatewayAccounts = append([]GatewayAccount(nil), p.GatewayAccounts...)
|
||||
for i := range p.GatewayAccounts {
|
||||
a := &p.GatewayAccounts[i]
|
||||
flags["gatewayAccount:"+a.ID+":password"] = a.Password != ""
|
||||
a.Password = ""
|
||||
}
|
||||
return p, flags
|
||||
}
|
||||
func Merge(current Proxy, patch map[string]any) (Proxy, error) {
|
||||
@@ -153,6 +189,9 @@ func Merge(current Proxy, patch map[string]any) (Proxy, error) {
|
||||
dec := json.NewDecoder(strings.NewReader(string(raw)))
|
||||
dec.DisallowUnknownFields()
|
||||
err := dec.Decode(&out)
|
||||
if err == nil {
|
||||
err = mergeGatewaySecrets(current, patch, &out)
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
// Package httpproxy authenticates gateway HTTP requests and replaces Basic credentials.
|
||||
package httpproxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"serviceport/internal/model"
|
||||
"serviceport/internal/protocol/transport"
|
||||
)
|
||||
|
||||
type Handler struct{}
|
||||
|
||||
func New() *Handler { return &Handler{} }
|
||||
|
||||
func targetURL(p model.Proxy) *url.URL {
|
||||
scheme := "http"
|
||||
if p.TargetTLS {
|
||||
scheme = "https"
|
||||
}
|
||||
return &url.URL{Scheme: scheme, Host: net.JoinHostPort(p.TargetHost, strconv.Itoa(p.TargetPort))}
|
||||
}
|
||||
func clientTransport(p model.Proxy, dial model.DialFunc) (*http.Transport, error) {
|
||||
cfg, err := transport.TLSConfig(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &http.Transport{DialContext: dial, TLSClientConfig: cfg, ForceAttemptHTTP2: false,
|
||||
TLSHandshakeTimeout: 10 * time.Second, ResponseHeaderTimeout: 30 * time.Second,
|
||||
IdleConnTimeout: 30 * time.Second, MaxIdleConnsPerHost: 2, MaxConnsPerHost: 10}, nil
|
||||
}
|
||||
func (h *Handler) Test(ctx context.Context, p model.Proxy, dial model.DialFunc) (model.TestResult, error) {
|
||||
if err := p.CheckTargetCredential(); err != nil {
|
||||
return model.TestResult{}, err
|
||||
}
|
||||
tr, err := clientTransport(p, dial)
|
||||
if err != nil {
|
||||
return model.TestResult{}, err
|
||||
}
|
||||
defer tr.CloseIdleConnections()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodHead, targetURL(p).String(), nil)
|
||||
if err != nil {
|
||||
return model.TestResult{}, err
|
||||
}
|
||||
req.SetBasicAuth(p.TargetUser, p.TargetPassword)
|
||||
resp, err := tr.RoundTrip(req)
|
||||
if err != nil {
|
||||
return model.TestResult{}, errors.New("HTTP target connection failed")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == 401 || resp.StatusCode == 403 || resp.StatusCode >= 500 {
|
||||
return model.TestResult{}, errors.New("HTTP target rejected health check")
|
||||
}
|
||||
return model.TestResult{Version: resp.Proto, TLS: resp.TLS != nil}, nil
|
||||
}
|
||||
|
||||
type singleListener struct {
|
||||
conn net.Conn
|
||||
accepted bool
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func (l *singleListener) Accept() (net.Conn, error) {
|
||||
if !l.accepted {
|
||||
l.accepted = true
|
||||
return l.conn, nil
|
||||
}
|
||||
<-l.done
|
||||
return nil, net.ErrClosed
|
||||
}
|
||||
func (l *singleListener) Close() error { return l.conn.Close() }
|
||||
func (l *singleListener) Addr() net.Addr { return l.conn.LocalAddr() }
|
||||
|
||||
type trackedConn struct {
|
||||
net.Conn
|
||||
once sync.Once
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func (c *trackedConn) Close() error {
|
||||
err := c.Conn.Close()
|
||||
c.once.Do(func() { close(c.done) })
|
||||
return err
|
||||
}
|
||||
|
||||
func (h *Handler) Serve(ctx context.Context, raw net.Conn, p model.Proxy, dial model.DialFunc, cfg *tls.Config, event model.EventFunc) error {
|
||||
defer raw.Close()
|
||||
stop := context.AfterFunc(ctx, func() { raw.Close() })
|
||||
defer stop()
|
||||
var conn net.Conn = raw
|
||||
if p.Type == "https" {
|
||||
if cfg == nil {
|
||||
return errors.New("HTTPS gateway TLS certificate required")
|
||||
}
|
||||
secureCfg := cfg.Clone()
|
||||
secureCfg.NextProtos = []string{"http/1.1"}
|
||||
secureCfg.MinVersion = tls.VersionTLS12
|
||||
secure := tls.Server(raw, secureCfg)
|
||||
handshake, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
err := secure.HandshakeContext(handshake)
|
||||
cancel()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
conn = secure
|
||||
}
|
||||
tr, err := clientTransport(p, dial)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tr.CloseIdleConnections()
|
||||
identity := ""
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user, password, provided := r.BasicAuth()
|
||||
account, found := transport.Account(p, user, raw.RemoteAddr())
|
||||
a, b := sha256.Sum256([]byte(password)), sha256.Sum256([]byte(account.Password))
|
||||
if !provided || !found || account.Password == "" || subtle.ConstantTimeCompare(a[:], b[:]) != 1 || (identity != "" && identity != user) {
|
||||
transport.AuthenticationEvent(event, user, false)
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="serviceport", charset="UTF-8"`)
|
||||
w.Header().Set("Connection", "close")
|
||||
http.Error(w, "authentication required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
selected := account.Apply(p)
|
||||
if identity == "" {
|
||||
identity = user
|
||||
transport.AuthenticationEvent(event, user, true)
|
||||
}
|
||||
if r.Method == http.MethodConnect || r.URL.IsAbs() || r.Header.Get("Upgrade") != "" {
|
||||
http.Error(w, "unsupported HTTP request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := model.AuditCommand(r.Context(), r.Method, r.URL.EscapedPath(), "allowed"); err != nil {
|
||||
http.Error(w, "audit unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
proxy := httputil.ReverseProxy{Transport: tr, Rewrite: func(pr *httputil.ProxyRequest) {
|
||||
pr.SetURL(targetURL(selected))
|
||||
pr.Out.Host = targetURL(selected).Host
|
||||
pr.Out.Header.Del("Proxy-Authorization")
|
||||
pr.Out.Header.Del("Forwarded")
|
||||
pr.Out.SetBasicAuth(selected.TargetUser, selected.TargetPassword)
|
||||
}, ErrorHandler: func(w http.ResponseWriter, _ *http.Request, _ error) {
|
||||
http.Error(w, "upstream unavailable", http.StatusBadGateway)
|
||||
}}
|
||||
proxy.ServeHTTP(w, r)
|
||||
})
|
||||
done := make(chan struct{})
|
||||
tracked := &trackedConn{Conn: conn, done: done}
|
||||
listener := &singleListener{conn: tracked, done: done}
|
||||
server := &http.Server{Handler: handler, ReadHeaderTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 64 << 10, BaseContext: func(net.Listener) context.Context { return ctx }}
|
||||
closeOnCancel := context.AfterFunc(ctx, func() { server.Close(); tracked.Close() })
|
||||
defer closeOnCancel()
|
||||
err = server.Serve(listener)
|
||||
server.Close()
|
||||
if errors.Is(err, net.ErrClosed) || errors.Is(err, http.ErrServerClosed) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package httpproxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"serviceport/internal/model"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMappedHTTPRequests(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user, password, ok := r.BasicAuth()
|
||||
if !ok || user != "target" || password != "target-secret" {
|
||||
t.Error("incorrect upstream authentication")
|
||||
}
|
||||
if r.Header.Get("Proxy-Authorization") != "" || r.Header.Get("X-Forwarded-For") != "" {
|
||||
t.Error("forwarded untrusted headers")
|
||||
}
|
||||
io.WriteString(w, "fixture response")
|
||||
}))
|
||||
defer upstream.Close()
|
||||
host, port, _ := net.SplitHostPort(strings.TrimPrefix(upstream.URL, "http://"))
|
||||
number, _ := strconv.Atoi(port)
|
||||
p := model.Proxy{Type: "http", TargetHost: host, TargetPort: number,
|
||||
GatewayAccounts: []model.GatewayAccount{{ID: "client", Username: "client", Password: "client-secret", TargetAccountID: "selected"}},
|
||||
TargetAccounts: []model.TargetAccount{{ID: "selected", Username: "target", Password: "target-secret"}}}
|
||||
for _, password := range []string{"wrong", "client-secret"} {
|
||||
t.Run(password, func(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
c, e := listener.Accept()
|
||||
if e != nil {
|
||||
done <- e
|
||||
return
|
||||
}
|
||||
done <- New().Serve(ctx, c, p, (&net.Dialer{}).DialContext, nil, nil)
|
||||
}()
|
||||
client := &http.Client{Timeout: 3 * time.Second}
|
||||
req, _ := http.NewRequest("GET", "http://"+listener.Addr().String()+"/resource?secret=hidden", nil)
|
||||
req.SetBasicAuth("client", password)
|
||||
req.Header.Set("Proxy-Authorization", "secret")
|
||||
req.Header.Set("X-Forwarded-For", "spoofed")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if password == "wrong" {
|
||||
if resp.StatusCode != 401 {
|
||||
t.Fatal(resp.Status)
|
||||
}
|
||||
} else if resp.StatusCode != 200 || string(body) != "fixture response" {
|
||||
t.Fatal(resp.Status, string(body))
|
||||
}
|
||||
cancel()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("gateway failed to stop")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
package mongoproxy
|
||||
|
||||
import (
|
||||
"serviceport/internal/model"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"net"
|
||||
"serviceport/internal/model"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
package mongoproxy
|
||||
|
||||
import (
|
||||
"serviceport/internal/model"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
"net"
|
||||
"serviceport/internal/model"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
@@ -30,6 +30,8 @@ func TestOfficialDriver(t *testing.T) {
|
||||
return a, nil
|
||||
}
|
||||
p := model.Proxy{TargetHost: "target.invalid", TargetPort: 27017, TargetUser: "target", TargetPassword: "target-secret", GatewayUser: "gateway", GatewayPassword: "gateway-secret", AccessMode: mode}
|
||||
p.GatewayAccounts = []model.GatewayAccount{{ID: "other", Username: "other", Password: "other-secret"}, {ID: "gateway", Username: "gateway", Password: "gateway-secret", AccessMode: mode}}
|
||||
p.AccessMode = ""
|
||||
listener, e := net.Listen("tcp", "127.0.0.1:0")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
|
||||
@@ -14,9 +14,10 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"serviceport/internal/model"
|
||||
"github.com/xdg-go/scram"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"serviceport/internal/model"
|
||||
"serviceport/internal/protocol/transport"
|
||||
)
|
||||
|
||||
type Handler struct{}
|
||||
@@ -47,6 +48,14 @@ func exchange(c net.Conn, d bson.D) (bson.D, error) {
|
||||
return decode(m.doc)
|
||||
}
|
||||
func connect(ctx context.Context, p model.Proxy, dial model.DialFunc) (*upstream, error) {
|
||||
return connectUpstream(ctx, p, dial, true)
|
||||
}
|
||||
func connectUpstream(ctx context.Context, p model.Proxy, dial model.DialFunc, authenticate bool) (*upstream, error) {
|
||||
if authenticate {
|
||||
if err := p.CheckTargetCredential(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
dc, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
c, e := dial(dc, "tcp", net.JoinHostPort(p.TargetHost, strconv.Itoa(p.TargetPort)))
|
||||
cancel()
|
||||
@@ -106,6 +115,11 @@ func connect(ctx context.Context, p model.Proxy, dial model.DialFunc) (*upstream
|
||||
c = &compressedConn{Conn: c}
|
||||
}
|
||||
}
|
||||
if !authenticate {
|
||||
good = true
|
||||
c.SetDeadline(time.Time{})
|
||||
return &upstream{Conn: c, hello: hello, compression: compression}, nil
|
||||
}
|
||||
client, e := scram.SHA256.NewClient(p.TargetUser, p.TargetPassword, "")
|
||||
if e != nil {
|
||||
return nil, errors.New("invalid target SCRAM credentials")
|
||||
@@ -178,28 +192,36 @@ type authState struct {
|
||||
server *scram.Server
|
||||
conv *scram.ServerConversation
|
||||
authenticated bool
|
||||
username string
|
||||
}
|
||||
|
||||
func newAuth(user, password string) (*authState, error) {
|
||||
return newAccountAuth(func(candidate string) (string, bool) { return password, candidate == user })
|
||||
}
|
||||
|
||||
func newAccountAuth(credential func(string) (string, bool)) (*authState, error) {
|
||||
salt := make([]byte, 24)
|
||||
if _, e := rand.Read(salt); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
client, e := scram.SHA256.NewClient(user, password, "")
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
stored := client.GetStoredCredentials(scram.KeyFactors{Salt: string(salt), Iters: 15000})
|
||||
a := &authState{}
|
||||
server, e := scram.SHA256.NewServer(func(candidate string) (scram.StoredCredentials, error) {
|
||||
if candidate != user {
|
||||
a.username = candidate
|
||||
password, found := credential(candidate)
|
||||
if !found {
|
||||
return scram.StoredCredentials{}, errors.New("authentication failed")
|
||||
}
|
||||
return stored, nil
|
||||
client, err := scram.SHA256.NewClient(candidate, password, "")
|
||||
if err != nil {
|
||||
return scram.StoredCredentials{}, err
|
||||
}
|
||||
return client.GetStoredCredentials(scram.KeyFactors{Salt: string(salt), Iters: 15000}), nil
|
||||
})
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
return &authState{server: server}, nil
|
||||
a.server = server
|
||||
return a, nil
|
||||
}
|
||||
func (a *authState) step(d bson.D, speculative bool) bson.D {
|
||||
if len(d) == 0 {
|
||||
@@ -266,14 +288,19 @@ func (h *Handler) Serve(ctx context.Context, raw net.Conn, p model.Proxy, dial m
|
||||
}
|
||||
c = secure
|
||||
}
|
||||
u, e := connect(ctx, p, dial)
|
||||
u, e := connectUpstream(ctx, p, dial, false)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
defer u.Close()
|
||||
stopUp := context.AfterFunc(ctx, func() { u.Close() })
|
||||
defer func() { u.Close() }()
|
||||
initialUp := u
|
||||
stopUp := context.AfterFunc(ctx, func() { initialUp.Close() })
|
||||
defer stopUp()
|
||||
a, e := newAuth(p.GatewayUser, p.GatewayPassword)
|
||||
base := p
|
||||
a, e := newAccountAuth(func(username string) (string, bool) {
|
||||
account, found := transport.Account(base, username, raw.RemoteAddr())
|
||||
return account.Password, found
|
||||
})
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
@@ -354,9 +381,26 @@ func (h *Handler) Serve(ctx context.Context, raw net.Conn, p model.Proxy, dial m
|
||||
return e
|
||||
}
|
||||
if !ok(out) {
|
||||
transport.AuthenticationEvent(event, a.username, false)
|
||||
return errors.New("gateway authentication failed")
|
||||
}
|
||||
if a.authenticated {
|
||||
account, found := transport.Account(base, a.username, raw.RemoteAddr())
|
||||
if !found {
|
||||
return errors.New("gateway account unavailable")
|
||||
}
|
||||
p = account.Apply(base)
|
||||
if u != nil {
|
||||
u.Close()
|
||||
}
|
||||
authenticatedUp, err := connect(ctx, p, dial)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u = authenticatedUp
|
||||
stopAuthenticated := context.AfterFunc(ctx, func() { authenticatedUp.Close() })
|
||||
defer stopAuthenticated()
|
||||
transport.AuthenticationEvent(event, a.username, true)
|
||||
c.SetDeadline(time.Time{})
|
||||
}
|
||||
continue
|
||||
|
||||
@@ -16,8 +16,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"serviceport/internal/model"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"serviceport/internal/model"
|
||||
)
|
||||
|
||||
func testTLS(t *testing.T) (*tls.Config, *x509.CertPool) {
|
||||
@@ -131,7 +131,11 @@ func TestGatewayTLSAuthenticationForwardingAndReadonly(t *testing.T) {
|
||||
done := make(chan error, 1)
|
||||
var rejected atomic.Int32
|
||||
go func() {
|
||||
done <- New().Serve(auditCtx, server, p, targetDial, serverTLS, func(category, rule string) { rejected.Add(1) })
|
||||
done <- New().Serve(auditCtx, server, p, targetDial, serverTLS, func(category, rule string) {
|
||||
if category == model.EventReadOnlyRejected {
|
||||
rejected.Add(1)
|
||||
}
|
||||
})
|
||||
}()
|
||||
dialGateway := func(_ context.Context, network, address string) (net.Conn, error) { return client, nil }
|
||||
clientConfig := model.Proxy{TargetHost: "localhost", TargetPort: 27017, TargetUser: "gateway", TargetPassword: "gateway-secret", TargetTLS: true}
|
||||
|
||||
@@ -10,11 +10,11 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"serviceport/internal/model"
|
||||
"serviceport/internal/testutil"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
"serviceport/internal/model"
|
||||
"serviceport/internal/testutil"
|
||||
)
|
||||
|
||||
// Run against each version fixture with SERVICEPORT_MONGO_TEST_ADDR=host:port,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
package mysqlproxy
|
||||
|
||||
import (
|
||||
"serviceport/internal/model"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"github.com/go-mysql-org/go-mysql/mysql"
|
||||
"serviceport/internal/model"
|
||||
)
|
||||
|
||||
func auditCommand(ctx context.Context, data []byte, statements map[uint32]string, decision string) error {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package mysqlproxy
|
||||
|
||||
import (
|
||||
"serviceport/internal/model"
|
||||
"errors"
|
||||
"github.com/go-mysql-org/go-mysql/client"
|
||||
"serviceport/internal/model"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package mysqlproxy
|
||||
|
||||
import (
|
||||
"serviceport/internal/model"
|
||||
"bytes"
|
||||
"compress/zlib"
|
||||
"context"
|
||||
@@ -10,6 +9,7 @@ import (
|
||||
"github.com/go-mysql-org/go-mysql/server"
|
||||
"io"
|
||||
"net"
|
||||
"serviceport/internal/model"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
package mysqlproxy
|
||||
|
||||
import (
|
||||
"serviceport/internal/model"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
@@ -11,6 +10,8 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"serviceport/internal/model"
|
||||
"serviceport/internal/protocol/transport"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
@@ -34,6 +35,9 @@ type handshakeHandler struct {
|
||||
func (h *handshakeHandler) UseDB(s string) error { h.database = s; return nil }
|
||||
|
||||
func open(ctx context.Context, p model.Proxy, dial model.DialFunc, db string) (*client.Conn, error) {
|
||||
if err := p.CheckTargetCredential(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var config *tls.Config
|
||||
if p.TargetTLS {
|
||||
config = &tls.Config{MinVersion: tls.VersionTLS12, ServerName: p.TargetHost}
|
||||
@@ -106,6 +110,11 @@ func (h *Handler) Test(ctx context.Context, p model.Proxy, d model.DialFunc) (mo
|
||||
return model.TestResult{}, e
|
||||
}
|
||||
defer c.Close()
|
||||
stop := context.AfterFunc(ctx, func() { c.Close() })
|
||||
defer stop()
|
||||
if e = c.Ping(); e != nil {
|
||||
return model.TestResult{}, errors.New("MySQL health ping failed")
|
||||
}
|
||||
compression := "none"
|
||||
if c.Compression == mysql.MYSQL_COMPRESS_ZLIB {
|
||||
compression = "zlib"
|
||||
@@ -129,10 +138,14 @@ func (h *Handler) Serve(ctx context.Context, raw net.Conn, p model.Proxy, dial m
|
||||
_ = raw.SetDeadline(time.Now().Add(15 * time.Second))
|
||||
hh := &handshakeHandler{}
|
||||
s := server.NewServer("8.0.11-serviceport", 45, mysql.AUTH_NATIVE_PASSWORD, nil, tc)
|
||||
downstream, e := s.NewConn(&clientGreetingConn{Conn: raw}, p.GatewayUser, p.GatewayPassword, hh)
|
||||
credentials := &accountProvider{proxy: p, remote: raw.RemoteAddr()}
|
||||
downstream, e := s.NewCustomizedConn(&clientGreetingConn{Conn: raw}, credentials, hh)
|
||||
if e != nil {
|
||||
transport.AuthenticationEvent(event, credentials.username, false)
|
||||
return e
|
||||
}
|
||||
p = credentials.account.Apply(p)
|
||||
transport.AuthenticationEvent(event, credentials.username, true)
|
||||
// Client capability flags describe what the client supports, not what was
|
||||
// negotiated. NewServer does not advertise these optional extensions; native
|
||||
// clients can still declare them and use the common (legacy) wire format.
|
||||
@@ -433,6 +446,25 @@ type clientGreetingConn struct {
|
||||
sent bool
|
||||
}
|
||||
|
||||
type accountProvider struct {
|
||||
proxy model.Proxy
|
||||
remote net.Addr
|
||||
username string
|
||||
account model.GatewayAccount
|
||||
}
|
||||
|
||||
func (p *accountProvider) CheckUsername(username string) (bool, error) {
|
||||
_, found, err := p.GetCredential(username)
|
||||
return found, err
|
||||
}
|
||||
|
||||
func (p *accountProvider) GetCredential(username string) (string, bool, error) {
|
||||
p.username = username
|
||||
a, found := transport.Account(p.proxy, username, p.remote)
|
||||
p.account = a
|
||||
return a.Password, found, nil
|
||||
}
|
||||
|
||||
func (c *clientGreetingConn) Write(p []byte) (int, error) {
|
||||
if !c.sent && len(p) > 5 {
|
||||
c.sent = true
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package mysqlproxy
|
||||
|
||||
import (
|
||||
"serviceport/internal/model"
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
@@ -15,6 +14,7 @@ import (
|
||||
"github.com/go-mysql-org/go-mysql/server"
|
||||
"math/big"
|
||||
"net"
|
||||
"serviceport/internal/model"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
@@ -117,6 +117,8 @@ func testGatewayTLS(t *testing.T, mode string, certificate bool, audit ...model.
|
||||
host, port, _ := net.SplitHostPort(backend.Addr().String())
|
||||
pn, _ := strconv.Atoi(port)
|
||||
p := model.Proxy{TargetHost: host, TargetPort: pn, TargetUser: "target", TargetPassword: "secret", GatewayUser: "gateway", GatewayPassword: "gateway-secret", AccessMode: mode}
|
||||
p.GatewayAccounts = []model.GatewayAccount{{ID: "other", Username: "other", Password: "other-secret"}, {ID: "gateway", Username: "gateway", Password: "gateway-secret", AccessMode: mode}}
|
||||
p.AccessMode = ""
|
||||
tc, cc := testTLS(t)
|
||||
if !certificate {
|
||||
tc = nil
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package mysqlproxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/go-mysql-org/go-mysql/mysql"
|
||||
"github.com/go-mysql-org/go-mysql/server"
|
||||
"net"
|
||||
"serviceport/internal/model"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestHealthRequiresPingResponse(t *testing.T) {
|
||||
for _, respond := range []bool{true, false} {
|
||||
t.Run(strconv.FormatBool(respond), func(t *testing.T) {
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer l.Close()
|
||||
received := make(chan byte, 1)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
raw, err := l.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer raw.Close()
|
||||
raw.SetDeadline(time.Now().Add(3 * time.Second))
|
||||
s := server.NewServer("8.0.11", 45, mysql.AUTH_NATIVE_PASSWORD, nil, nil)
|
||||
c, err := s.NewConn(raw, "target", "secret", &fakeBackend{queries: &atomic.Int32{}})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
c.ResetSequence()
|
||||
b, err := c.ReadPacket()
|
||||
if err != nil || len(b) == 0 {
|
||||
return
|
||||
}
|
||||
received <- b[0]
|
||||
if respond {
|
||||
c.WritePacket([]byte{0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0})
|
||||
}
|
||||
}()
|
||||
host, port, _ := net.SplitHostPort(l.Addr().String())
|
||||
n, _ := strconv.Atoi(port)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
_, err = New().Test(ctx, model.Proxy{TargetHost: host, TargetPort: n, TargetUser: "target", TargetPassword: "secret"}, (&net.Dialer{}).DialContext)
|
||||
if (err == nil) != respond {
|
||||
t.Fatal("health did not require ping", err)
|
||||
}
|
||||
select {
|
||||
case cmd := <-received:
|
||||
if cmd != mysql.COM_PING {
|
||||
t.Fatal("not ping", cmd)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal("no ping")
|
||||
}
|
||||
<-done
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
package mysqlproxy
|
||||
|
||||
import (
|
||||
"serviceport/internal/model"
|
||||
"serviceport/internal/testutil"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"serviceport/internal/model"
|
||||
"serviceport/internal/testutil"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package pgproxy
|
||||
|
||||
import (
|
||||
"serviceport/internal/model"
|
||||
"serviceport/internal/protocol/transport"
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"crypto/tls"
|
||||
@@ -11,6 +9,8 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgproto3"
|
||||
"net"
|
||||
"serviceport/internal/model"
|
||||
"serviceport/internal/protocol/transport"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -19,6 +19,9 @@ type Handler struct{}
|
||||
|
||||
func New() *Handler { return &Handler{} }
|
||||
func open(ctx context.Context, p model.Proxy, dial model.DialFunc, database string) (*pgconn.PgConn, error) {
|
||||
if err := p.CheckTargetCredential(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg, err := pgconn.ParseConfig("postgres://localhost/postgres?sslmode=disable")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -113,9 +116,13 @@ func (h *Handler) Serve(ctx context.Context, raw net.Conn, p model.Proxy, dial m
|
||||
backend.Flush()
|
||||
return errors.New(message)
|
||||
}
|
||||
if startup.Parameters["user"] != p.GatewayUser {
|
||||
username := startup.Parameters["user"]
|
||||
account, exists := transport.Account(p, username, raw.RemoteAddr())
|
||||
if !exists {
|
||||
transport.AuthenticationEvent(event, username, false)
|
||||
return fail("gateway authentication failed")
|
||||
}
|
||||
p = account.Apply(p)
|
||||
backend.SetAuthType(pgproto3.AuthTypeCleartextPassword)
|
||||
backend.Send(&pgproto3.AuthenticationCleartextPassword{})
|
||||
if err := backend.Flush(); err != nil {
|
||||
@@ -127,8 +134,10 @@ func (h *Handler) Serve(ctx context.Context, raw net.Conn, p model.Proxy, dial m
|
||||
}
|
||||
password, ok := msg.(*pgproto3.PasswordMessage)
|
||||
if !ok || subtle.ConstantTimeCompare([]byte(password.Password), []byte(p.GatewayPassword)) != 1 {
|
||||
transport.AuthenticationEvent(event, username, false)
|
||||
return fail("gateway authentication failed")
|
||||
}
|
||||
transport.AuthenticationEvent(event, username, true)
|
||||
upstream, err := open(ctx, p, dial, startup.Parameters["database"])
|
||||
if err != nil {
|
||||
return fail("target connection or authentication failed")
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
package pgproxy
|
||||
|
||||
import (
|
||||
"serviceport/internal/model"
|
||||
"context"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgproto3"
|
||||
"net"
|
||||
"serviceport/internal/model"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -87,6 +87,7 @@ func TestPostgresAuthenticationSimpleExtendedAndAudit(t *testing.T) {
|
||||
}
|
||||
defer listener.Close()
|
||||
p := model.Proxy{TargetHost: "pg.internal", TargetPort: 5432, TargetUser: "target", TargetPassword: "target-secret", GatewayUser: "gateway", GatewayPassword: "gateway-secret", AuthSource: "postgres"}
|
||||
p.GatewayAccounts = []model.GatewayAccount{{ID: "other", Username: "other", Password: "other-secret"}, {ID: "gateway", Username: "gateway", Password: "gateway-secret"}}
|
||||
var mu sync.Mutex
|
||||
var records []model.Command
|
||||
auditCtx := model.WithCommandAudit(ctx, func(c model.Command) error { mu.Lock(); records = append(records, c); mu.Unlock(); return nil })
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package rabbitproxy
|
||||
|
||||
import (
|
||||
"serviceport/internal/model"
|
||||
"serviceport/internal/protocol/transport"
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"crypto/tls"
|
||||
@@ -11,6 +9,8 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"serviceport/internal/model"
|
||||
"serviceport/internal/protocol/transport"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -224,9 +224,13 @@ func (h *Handler) Serve(ctx context.Context, raw net.Conn, p model.Proxy, dial m
|
||||
return err
|
||||
}
|
||||
user, pass, err := credentials(auth)
|
||||
if err != nil || subtle.ConstantTimeCompare([]byte(user), []byte(p.GatewayUser)) != 1 || subtle.ConstantTimeCompare([]byte(pass), []byte(p.GatewayPassword)) != 1 {
|
||||
account, exists := transport.Account(p, user, raw.RemoteAddr())
|
||||
if err != nil || !exists || subtle.ConstantTimeCompare([]byte(pass), []byte(account.Password)) != 1 {
|
||||
transport.AuthenticationEvent(event, user, false)
|
||||
return errors.New("AMQP gateway authentication failed")
|
||||
}
|
||||
p = account.Apply(p)
|
||||
transport.AuthenticationEvent(event, user, true)
|
||||
if err = write(up, startOK(p.TargetUser, p.TargetPassword)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
package rabbitproxy
|
||||
|
||||
import (
|
||||
"serviceport/internal/model"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
"io"
|
||||
"net"
|
||||
"serviceport/internal/model"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -83,6 +83,7 @@ func TestAMQPClientAuthenticationPublishAndAudit(t *testing.T) {
|
||||
}
|
||||
defer listener.Close()
|
||||
p := model.Proxy{TargetHost: "broker.internal", TargetPort: 5672, TargetUser: "target", TargetPassword: "target-secret", GatewayUser: "gateway", GatewayPassword: "gateway-secret"}
|
||||
p.GatewayAccounts = []model.GatewayAccount{{ID: "other", Username: "other", Password: "other-secret"}, {ID: "gateway", Username: "gateway", Password: "gateway-secret"}}
|
||||
var mu sync.Mutex
|
||||
var audits []model.Command
|
||||
auditCtx := model.WithCommandAudit(ctx, func(c model.Command) error { mu.Lock(); defer mu.Unlock(); audits = append(audits, c); return nil })
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package redisproxy
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"reflect"
|
||||
"serviceport/internal/model"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMappedAccountAuthenticatesWithSelectedUpstreamCredentials(t *testing.T) {
|
||||
for _, id := range []string{"one", "two"} {
|
||||
t.Run(id, func(t *testing.T) {
|
||||
p := fixture
|
||||
p.TargetAccounts = []model.TargetAccount{{ID: "one", Username: "up-one", Password: "secret-one"}, {ID: "two", Username: "up-two", Password: "secret-two"}}
|
||||
p.GatewayAccounts = []model.GatewayAccount{{ID: id, Username: "client", Password: "client-secret", TargetAccountID: id}}
|
||||
requests := make(chan []string, 1)
|
||||
c, done := gateway(t, context.Background(), p, func(context.Context, string, string) (net.Conn, error) {
|
||||
client, server := net.Pipe()
|
||||
go func() {
|
||||
defer server.Close()
|
||||
server.SetDeadline(time.Now().Add(3 * time.Second))
|
||||
args, err := request(bufio.NewReader(server))
|
||||
if err == nil {
|
||||
requests <- args
|
||||
server.Write([]byte("+OK\r\n"))
|
||||
}
|
||||
}()
|
||||
return client, nil
|
||||
})
|
||||
writeCommand(t, c, "AUTH", "client", "client-secret")
|
||||
readBytes(t, c, "+OK\r\n")
|
||||
select {
|
||||
case args := <-requests:
|
||||
if !reflect.DeepEqual(args, []string{"AUTH", "up-" + id, "secret-" + id}) {
|
||||
t.Fatalf("wrong upstream credentials: %v", args)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("no upstream authentication")
|
||||
}
|
||||
c.Close()
|
||||
waitDone(t, done)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayMultipleAccounts(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
user, pass string
|
||||
allowed bool
|
||||
}{
|
||||
{"first", "one", true}, {"second", "two", true}, {"second", "one", false}, {"off", "three", false}, {"blocked", "four", false}, {"gateway", "gateway-secret", false},
|
||||
} {
|
||||
t.Run(tc.user+tc.pass, func(t *testing.T) {
|
||||
p := fixture
|
||||
p.GatewayAccounts = []model.GatewayAccount{{ID: "first", Username: "first", Password: "one"}, {ID: "second", Username: "second", Password: "two"}, {ID: "off", Username: "off", Password: "three", Disabled: true}, {ID: "blocked", Username: "blocked", Password: "four", AllowCIDRs: []string{"192.0.2.0/24"}}}
|
||||
dialed := make(chan struct{}, 1)
|
||||
c, done := gateway(t, context.Background(), p, func(context.Context, string, string) (net.Conn, error) {
|
||||
dialed <- struct{}{}
|
||||
return nil, errors.New("fixture target unavailable")
|
||||
})
|
||||
writeCommand(t, c, "AUTH", tc.user, tc.pass)
|
||||
if tc.allowed {
|
||||
readBytes(t, c, "-ERR target connection failed\r\n")
|
||||
} else {
|
||||
readBytes(t, c, "-WRONGPASS invalid username-password pair\r\n")
|
||||
}
|
||||
if waitDone(t, done) == nil {
|
||||
t.Fatal("expected connection termination")
|
||||
}
|
||||
if (len(dialed) > 0) != tc.allowed {
|
||||
t.Fatal("unexpected upstream dial")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
package redisproxy
|
||||
|
||||
import (
|
||||
"serviceport/internal/model"
|
||||
"serviceport/internal/protocol/transport"
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
@@ -12,6 +10,8 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"serviceport/internal/model"
|
||||
"serviceport/internal/protocol/transport"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -154,9 +154,9 @@ func (h *Handler) Test(ctx context.Context, p model.Proxy, dial model.DialFunc)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
func auth(args []string, p model.Proxy) (bool, bool) {
|
||||
func authCredentials(args []string, p model.Proxy) (bool, string, string) {
|
||||
if len(args) == 0 {
|
||||
return false, false
|
||||
return false, "", ""
|
||||
}
|
||||
name := strings.ToUpper(args[0])
|
||||
user, pass := "", ""
|
||||
@@ -165,6 +165,9 @@ func auth(args []string, p model.Proxy) (bool, bool) {
|
||||
found = true
|
||||
if len(args) == 2 {
|
||||
user, pass = p.GatewayUser, args[1]
|
||||
if len(p.GatewayAccounts) > 0 {
|
||||
user = "default"
|
||||
}
|
||||
} else if len(args) == 3 {
|
||||
user, pass = args[1], args[2]
|
||||
}
|
||||
@@ -174,22 +177,22 @@ func auth(args []string, p model.Proxy) (bool, bool) {
|
||||
switch strings.ToUpper(args[i]) {
|
||||
case "AUTH":
|
||||
if found || i+2 >= len(args) {
|
||||
return true, false
|
||||
return true, "", ""
|
||||
}
|
||||
found = true
|
||||
user, pass = args[i+1], args[i+2]
|
||||
i += 3
|
||||
case "SETNAME":
|
||||
if i+1 >= len(args) {
|
||||
return true, false
|
||||
return true, "", ""
|
||||
}
|
||||
i += 2
|
||||
default:
|
||||
return true, false
|
||||
return true, "", ""
|
||||
}
|
||||
}
|
||||
}
|
||||
return found, found && subtle.ConstantTimeCompare([]byte(user), []byte(p.GatewayUser)) == 1 && subtle.ConstantTimeCompare([]byte(pass), []byte(p.GatewayPassword)) == 1
|
||||
return found, user, pass
|
||||
}
|
||||
func (h *Handler) Serve(ctx context.Context, raw net.Conn, p model.Proxy, dial model.DialFunc, tc *tls.Config, event model.EventFunc) error {
|
||||
defer raw.Close()
|
||||
@@ -202,6 +205,7 @@ func (h *Handler) Serve(ctx context.Context, raw net.Conn, p model.Proxy, dial m
|
||||
reader := bufio.NewReader(c)
|
||||
out := &transport.LockedWriter{Writer: c}
|
||||
var upstream net.Conn
|
||||
base := p
|
||||
defer func() {
|
||||
if upstream != nil {
|
||||
upstream.Close()
|
||||
@@ -213,11 +217,18 @@ func (h *Handler) Serve(ctx context.Context, raw net.Conn, p model.Proxy, dial m
|
||||
return err
|
||||
}
|
||||
name := strings.ToUpper(args[0])
|
||||
found, valid := auth(args, p)
|
||||
found, username, password := authCredentials(args, base)
|
||||
account, exists := transport.Account(base, username, raw.RemoteAddr())
|
||||
valid := found && exists && subtle.ConstantTimeCompare([]byte(password), []byte(account.Password)) == 1
|
||||
if found && !valid {
|
||||
transport.AuthenticationEvent(event, username, false)
|
||||
transport.Write(out, []byte("-WRONGPASS invalid username-password pair\r\n"))
|
||||
return errors.New("Redis gateway authentication failed")
|
||||
}
|
||||
if valid {
|
||||
p = account.Apply(base)
|
||||
transport.AuthenticationEvent(event, username, true)
|
||||
}
|
||||
if upstream == nil {
|
||||
if !valid {
|
||||
transport.Write(out, []byte("-NOAUTH Authentication required\r\n"))
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package redisproxy
|
||||
|
||||
import (
|
||||
"serviceport/internal/model"
|
||||
"serviceport/internal/protocol/transport"
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
@@ -10,6 +8,8 @@ import (
|
||||
"io"
|
||||
"net"
|
||||
"reflect"
|
||||
"serviceport/internal/model"
|
||||
"serviceport/internal/protocol/transport"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
// Package sqlserverproxy forwards TDS 7.x SQL-password sessions.
|
||||
package sqlserverproxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"crypto/tls"
|
||||
"database/sql"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"github.com/microsoft/go-mssqldb"
|
||||
"github.com/microsoft/go-mssqldb/msdsn"
|
||||
"io"
|
||||
"net"
|
||||
"serviceport/internal/model"
|
||||
"serviceport/internal/protocol/transport"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Handler struct{}
|
||||
|
||||
func New() *Handler { return &Handler{} }
|
||||
|
||||
type fixedDialer struct {
|
||||
dial model.DialFunc
|
||||
address string
|
||||
}
|
||||
|
||||
func (d fixedDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
return d.dial(ctx, "tcp", d.address)
|
||||
}
|
||||
func (d fixedDialer) HostName() string { host, _, _ := net.SplitHostPort(d.address); return host }
|
||||
func (h *Handler) Test(ctx context.Context, p model.Proxy, dial model.DialFunc) (model.TestResult, error) {
|
||||
if err := p.CheckTargetCredential(); err != nil {
|
||||
return model.TestResult{}, err
|
||||
}
|
||||
tlsConfig, err := transport.TLSConfig(p)
|
||||
if err != nil {
|
||||
return model.TestResult{}, err
|
||||
}
|
||||
encryption := msdsn.Encryption(msdsn.EncryptionDisabled)
|
||||
if p.TargetTLS {
|
||||
encryption = msdsn.EncryptionRequired
|
||||
}
|
||||
c := mssql.NewConnectorConfig(msdsn.Config{Host: p.TargetHost, Port: uint64(p.TargetPort), User: p.TargetUser, Password: p.TargetPassword, Database: p.AuthSource, Encryption: encryption, TLSConfig: tlsConfig, Protocols: []string{"tcp"}, DisableRetry: true})
|
||||
c.Dialer = fixedDialer{dial, net.JoinHostPort(p.TargetHost, strconv.Itoa(p.TargetPort))}
|
||||
db := sql.OpenDB(c)
|
||||
defer db.Close()
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
return model.TestResult{}, errors.New("SQL Server authentication or connection failed")
|
||||
}
|
||||
return model.TestResult{Version: "SQL Server TDS", TLS: p.TargetTLS}, nil
|
||||
}
|
||||
func (h *Handler) Serve(ctx context.Context, raw net.Conn, p model.Proxy, dial model.DialFunc, cfg *tls.Config, event model.EventFunc) error {
|
||||
defer raw.Close()
|
||||
stop := context.AfterFunc(ctx, func() { raw.Close() })
|
||||
defer stop()
|
||||
raw.SetDeadline(time.Now().Add(15 * time.Second))
|
||||
kind, hello, err := readMessage(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if kind != 18 {
|
||||
return errors.New("TDS prelogin required")
|
||||
}
|
||||
enc, err := encryptionOption(hello)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var down net.Conn = raw
|
||||
if cfg != nil {
|
||||
if enc == 2 {
|
||||
return errors.New("SQL Server gateway requires TLS")
|
||||
}
|
||||
if err = writeMessage(raw, 4, prelogin(3)); err != nil {
|
||||
return err
|
||||
}
|
||||
framed := &handshakeConn{Conn: raw, framed: true}
|
||||
tlsCfg := cfg.Clone()
|
||||
tlsCfg.MaxVersion = tls.VersionTLS12
|
||||
tlsCfg.MinVersion = tls.VersionTLS12
|
||||
secure := tls.Server(framed, tlsCfg)
|
||||
if err = secure.HandshakeContext(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
framed.framed = false
|
||||
down = secure
|
||||
} else {
|
||||
if enc == 1 || enc == 3 {
|
||||
return errors.New("gateway TLS certificate required")
|
||||
}
|
||||
if err = writeMessage(raw, 4, prelogin(2)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
kind, login, err := readMessage(down)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if kind != 16 || len(login) < 94 || int(binary.LittleEndian.Uint32(login)) != len(login) || login[25]&0x80 != 0 || login[27]&1 != 0 {
|
||||
return errors.New("only SQL password LOGIN7 is supported")
|
||||
}
|
||||
user, err := loginField(login, 40, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
password, err := loginField(login, 44, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
account, found := transport.Account(p, user, raw.RemoteAddr())
|
||||
a, b := sha256.Sum256([]byte(password)), sha256.Sum256([]byte(account.Password))
|
||||
if !found || account.Password == "" || subtle.ConstantTimeCompare(a[:], b[:]) != 1 {
|
||||
transport.AuthenticationEvent(event, user, false)
|
||||
return errors.New("SQL Server gateway authentication failed")
|
||||
}
|
||||
p = account.Apply(p)
|
||||
transport.AuthenticationEvent(event, user, true)
|
||||
up, err := dial(ctx, "tcp", net.JoinHostPort(p.TargetHost, strconv.Itoa(p.TargetPort)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer up.Close()
|
||||
stopUp := context.AfterFunc(ctx, func() { up.Close() })
|
||||
defer stopUp()
|
||||
up.SetDeadline(time.Now().Add(15 * time.Second))
|
||||
requestEncryption := byte(2)
|
||||
if p.TargetTLS {
|
||||
requestEncryption = 1
|
||||
}
|
||||
if err = writeMessage(up, 18, prelogin(requestEncryption)); err != nil {
|
||||
return err
|
||||
}
|
||||
kind, response, err := readMessage(up)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if kind != 4 {
|
||||
return errors.New("invalid upstream prelogin")
|
||||
}
|
||||
enc, err = encryptionOption(response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var target net.Conn = up
|
||||
if p.TargetTLS || enc == 1 || enc == 3 {
|
||||
if enc != 1 && enc != 3 {
|
||||
return errors.New("upstream TLS required")
|
||||
}
|
||||
p.TargetTLS = true
|
||||
tlsCfg, e := transport.TLSConfig(p)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
tlsCfg.MaxVersion = tls.VersionTLS12
|
||||
framed := &handshakeConn{Conn: up, framed: true}
|
||||
secure := tls.Client(framed, tlsCfg)
|
||||
if err = secure.HandshakeContext(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
framed.framed = false
|
||||
target = secure
|
||||
} else if enc != 2 {
|
||||
return errors.New("unsupported upstream encryption negotiation")
|
||||
}
|
||||
login, err = upstreamLogin(login, p.TargetUser, p.TargetPassword, p.TargetHost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = writeMessage(target, 16, login); err != nil {
|
||||
return err
|
||||
}
|
||||
raw.SetDeadline(time.Time{})
|
||||
up.SetDeadline(time.Time{})
|
||||
done := make(chan error, 1)
|
||||
go func() { _, e := io.Copy(down, target); raw.Close(); done <- e }()
|
||||
for {
|
||||
var flags byte
|
||||
kind, payload, e := readWithFlags(down, &flags)
|
||||
if e != nil {
|
||||
err = e
|
||||
break
|
||||
}
|
||||
switch kind {
|
||||
case 1:
|
||||
if len(payload) < 4 {
|
||||
err = errors.New("invalid SQL batch")
|
||||
break
|
||||
}
|
||||
offset := int(binary.LittleEndian.Uint32(payload))
|
||||
if offset < 4 || offset > len(payload) {
|
||||
err = errors.New("invalid SQL batch headers")
|
||||
break
|
||||
}
|
||||
err = model.AuditCommand(ctx, "sql_batch", decodeText(payload[offset:]), "allowed")
|
||||
case 3:
|
||||
err = model.AuditCommand(ctx, "rpc", "SQL Server RPC request (parameters omitted)", "allowed")
|
||||
case 6, 7, 14:
|
||||
err = model.AuditCommand(ctx, "tds", "SQL Server protocol operation", "allowed")
|
||||
default:
|
||||
err = errors.New("unsupported TDS operation")
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if err = writeWithFlags(target, kind, payload, flags); err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
up.Close()
|
||||
raw.Close()
|
||||
<-done
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package sqlserverproxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/pem"
|
||||
"github.com/microsoft/go-mssqldb"
|
||||
"github.com/microsoft/go-mssqldb/msdsn"
|
||||
"net"
|
||||
"net/http/httptest"
|
||||
"serviceport/internal/model"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDriverThroughGateway(t *testing.T) {
|
||||
for _, encrypted := range []bool{false, true} {
|
||||
t.Run(strconv.FormatBool(encrypted), func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
certServer := httptest.NewTLSServer(nil)
|
||||
cert := certServer.TLS.Certificates[0]
|
||||
certServer.Close()
|
||||
up, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer up.Close()
|
||||
captured := make(chan []string, 1)
|
||||
upDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(upDone)
|
||||
raw, e := up.Accept()
|
||||
if e != nil {
|
||||
return
|
||||
}
|
||||
defer raw.Close()
|
||||
raw.SetDeadline(time.Now().Add(8 * time.Second))
|
||||
if _, _, e = readMessage(raw); e != nil {
|
||||
t.Error(e)
|
||||
return
|
||||
}
|
||||
var conn net.Conn = raw
|
||||
enc := byte(2)
|
||||
if encrypted {
|
||||
enc = 1
|
||||
}
|
||||
if e = writeMessage(raw, 4, prelogin(enc)); e != nil {
|
||||
t.Error(e)
|
||||
return
|
||||
}
|
||||
if encrypted {
|
||||
framed := &handshakeConn{Conn: raw, framed: true}
|
||||
secure := tls.Server(framed, &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12, MaxVersion: tls.VersionTLS12})
|
||||
if e = secure.HandshakeContext(ctx); e != nil {
|
||||
t.Error(e)
|
||||
return
|
||||
}
|
||||
framed.framed = false
|
||||
conn = secure
|
||||
}
|
||||
kind, login, e := readMessage(conn)
|
||||
if e != nil || kind != 16 {
|
||||
t.Errorf("login: %d %v", kind, e)
|
||||
return
|
||||
}
|
||||
user, e := loginField(login, 40, false)
|
||||
if e != nil {
|
||||
t.Error(e)
|
||||
return
|
||||
}
|
||||
password, e := loginField(login, 44, true)
|
||||
if e != nil {
|
||||
t.Error(e)
|
||||
return
|
||||
}
|
||||
captured <- []string{user, password}
|
||||
ack, _ := hex.DecodeString("ad0a000174000004000f000000fd000000000000000000000000")
|
||||
if e = writeMessage(conn, 4, ack); e != nil {
|
||||
t.Error(e)
|
||||
return
|
||||
}
|
||||
for {
|
||||
kind, _, e = readMessage(conn)
|
||||
if e != nil {
|
||||
return
|
||||
}
|
||||
if kind != 1 && kind != 3 {
|
||||
t.Errorf("unexpected request %d", kind)
|
||||
return
|
||||
}
|
||||
if e = writeMessage(conn, 4, []byte{253, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}); e != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
host, port, _ := net.SplitHostPort(up.Addr().String())
|
||||
number, _ := strconv.Atoi(port)
|
||||
p := model.Proxy{Type: "sqlserver", TargetHost: host, TargetPort: number, TargetTLS: encrypted, TargetCA: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Certificate[0]})), GatewayAccounts: []model.GatewayAccount{{ID: "client", Username: "client", Password: "client-secret", TargetAccountID: "mapped"}}, TargetAccounts: []model.TargetAccount{{ID: "mapped", Username: "upstream", Password: "upstream-secret"}}}
|
||||
gateway, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer gateway.Close()
|
||||
done := make(chan error, 1)
|
||||
var gatewayTLS *tls.Config
|
||||
if encrypted {
|
||||
gatewayTLS = &tls.Config{Certificates: []tls.Certificate{cert}}
|
||||
}
|
||||
go func() {
|
||||
raw, e := gateway.Accept()
|
||||
if e != nil {
|
||||
done <- e
|
||||
return
|
||||
}
|
||||
done <- New().Serve(ctx, raw, p, (&net.Dialer{}).DialContext, gatewayTLS, nil)
|
||||
}()
|
||||
_, gatewayPort, _ := net.SplitHostPort(gateway.Addr().String())
|
||||
n, _ := strconv.Atoi(gatewayPort)
|
||||
encryption := msdsn.Encryption(msdsn.EncryptionDisabled)
|
||||
if encrypted {
|
||||
encryption = msdsn.EncryptionRequired
|
||||
}
|
||||
connector := mssql.NewConnectorConfig(msdsn.Config{Host: "127.0.0.1", Port: uint64(n), User: "client", Password: "client-secret", Encryption: encryption, TLSConfig: &tls.Config{InsecureSkipVerify: true}, Protocols: []string{"tcp"}})
|
||||
db := sql.OpenDB(connector)
|
||||
defer db.Close()
|
||||
if err = db.PingContext(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case credentials := <-captured:
|
||||
if credentials[0] != "upstream" || credentials[1] != "upstream-secret" {
|
||||
t.Fatal("wrong mapped credentials")
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal(ctx.Err())
|
||||
}
|
||||
db.Close()
|
||||
cancel()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("gateway did not close")
|
||||
}
|
||||
select {
|
||||
case <-upDone:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("upstream did not close")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package sqlserverproxy
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"serviceport/internal/protocol/transport"
|
||||
"unicode/utf16"
|
||||
)
|
||||
|
||||
const maxMessage = 16 << 20
|
||||
|
||||
func readMessage(r io.Reader) (byte, []byte, error) {
|
||||
return readWithFlags(r, nil)
|
||||
}
|
||||
func readWithFlags(r io.Reader, flags *byte) (byte, []byte, error) {
|
||||
var result []byte
|
||||
var kind byte
|
||||
packets := 0
|
||||
for {
|
||||
var header [8]byte
|
||||
if _, err := io.ReadFull(r, header[:]); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
packets++
|
||||
if packets > 65536 {
|
||||
return 0, nil, errors.New("too many TDS fragments")
|
||||
}
|
||||
if flags != nil && packets == 1 {
|
||||
*flags = header[1] & 0x18
|
||||
}
|
||||
n := int(binary.BigEndian.Uint16(header[2:])) - 8
|
||||
if n < 0 || n > 32759 || len(result)+n > maxMessage || (len(result) > 0 && header[0] != kind) {
|
||||
return 0, nil, errors.New("invalid TDS message")
|
||||
}
|
||||
kind = header[0]
|
||||
start := len(result)
|
||||
result = append(result, make([]byte, n)...)
|
||||
if _, err := io.ReadFull(r, result[start:]); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
if header[1]&1 != 0 {
|
||||
return kind, result, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
func writeMessage(w io.Writer, kind byte, data []byte) error {
|
||||
return writeWithFlags(w, kind, data, 0)
|
||||
}
|
||||
func writeWithFlags(w io.Writer, kind byte, data []byte, flags byte) error {
|
||||
for id := byte(1); ; id++ {
|
||||
n := min(len(data), 4088)
|
||||
header := []byte{kind, 0, 0, 0, 0, 0, id, 0}
|
||||
if id == 1 {
|
||||
header[1] = flags
|
||||
}
|
||||
if n == len(data) {
|
||||
header[1] |= 1
|
||||
}
|
||||
binary.BigEndian.PutUint16(header[2:], uint16(n+8))
|
||||
if err := transport.Write(w, append(header, data[:n]...)); err != nil {
|
||||
return err
|
||||
}
|
||||
data = data[n:]
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
func prelogin(encryption byte) []byte {
|
||||
return []byte{0, 0, 16, 0, 6, 1, 0, 22, 0, 1, 4, 0, 23, 0, 1, 255, 15, 0, 0, 0, 0, 0, encryption, 0}
|
||||
}
|
||||
func encryptionOption(data []byte) (byte, error) {
|
||||
for pos := 0; pos < len(data); pos += 5 {
|
||||
if data[pos] == 255 {
|
||||
break
|
||||
}
|
||||
if pos+5 > len(data) {
|
||||
break
|
||||
}
|
||||
off, n := int(binary.BigEndian.Uint16(data[pos+1:])), int(binary.BigEndian.Uint16(data[pos+3:]))
|
||||
if off+n > len(data) {
|
||||
break
|
||||
}
|
||||
if data[pos] == 1 && n == 1 {
|
||||
return data[off], nil
|
||||
}
|
||||
}
|
||||
return 0, errors.New("invalid TDS prelogin")
|
||||
}
|
||||
|
||||
type handshakeConn struct {
|
||||
net.Conn
|
||||
framed bool
|
||||
pending []byte
|
||||
}
|
||||
|
||||
func (c *handshakeConn) Read(b []byte) (int, error) {
|
||||
if !c.framed && len(c.pending) == 0 {
|
||||
return c.Conn.Read(b)
|
||||
}
|
||||
if len(c.pending) == 0 {
|
||||
kind, data, err := readMessage(c.Conn)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if kind != 18 && kind != 4 {
|
||||
return 0, errors.New("invalid TDS TLS frame")
|
||||
}
|
||||
c.pending = data
|
||||
}
|
||||
n := copy(b, c.pending)
|
||||
c.pending = c.pending[n:]
|
||||
return n, nil
|
||||
}
|
||||
func (c *handshakeConn) Write(b []byte) (int, error) {
|
||||
if !c.framed {
|
||||
return c.Conn.Write(b)
|
||||
}
|
||||
if err := writeMessage(c.Conn, 18, b); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(b), nil
|
||||
}
|
||||
func textBytes(s string) []byte {
|
||||
units := utf16.Encode([]rune(s))
|
||||
out := make([]byte, len(units)*2)
|
||||
for i, v := range units {
|
||||
binary.LittleEndian.PutUint16(out[i*2:], v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
func decodeText(b []byte) string {
|
||||
units := make([]uint16, len(b)/2)
|
||||
for i := range units {
|
||||
units[i] = binary.LittleEndian.Uint16(b[i*2:])
|
||||
}
|
||||
return string(utf16.Decode(units))
|
||||
}
|
||||
func loginField(data []byte, pos int, password bool) (string, error) {
|
||||
if len(data) < 94 || pos+4 > len(data) {
|
||||
return "", errors.New("short TDS login")
|
||||
}
|
||||
off, n := int(binary.LittleEndian.Uint16(data[pos:])), int(binary.LittleEndian.Uint16(data[pos+2:]))*2
|
||||
if off < 94 && n != 0 || off+n > len(data) {
|
||||
return "", errors.New("invalid TDS login field")
|
||||
}
|
||||
b := append([]byte(nil), data[off:off+n]...)
|
||||
if password {
|
||||
for i, v := range b {
|
||||
v ^= 0xa5
|
||||
b[i] = v<<4 | v>>4
|
||||
}
|
||||
}
|
||||
return decodeText(b), nil
|
||||
}
|
||||
func upstreamLogin(original []byte, user, password, host string) ([]byte, error) {
|
||||
if len(original) < 94 {
|
||||
return nil, errors.New("short TDS login")
|
||||
}
|
||||
// Rebuild the supported SQL-password login fields; never forward client authentication extensions.
|
||||
out := append([]byte(nil), original[:94]...)
|
||||
out[25] &^= 0x80
|
||||
out[27] &^= 0x11
|
||||
for i := 36; i < 94; i++ {
|
||||
out[i] = 0
|
||||
}
|
||||
for _, pos := range []int{36, 40, 44, 48, 52, 60, 64, 68} {
|
||||
value, err := loginField(original, pos, pos == 44)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch pos {
|
||||
case 40:
|
||||
value = user
|
||||
case 44:
|
||||
value = password
|
||||
case 52:
|
||||
value = host
|
||||
}
|
||||
b := textBytes(value)
|
||||
if len(b) > 8192 || len(out)+len(b) > 65535 {
|
||||
return nil, errors.New("TDS login field too long")
|
||||
}
|
||||
binary.LittleEndian.PutUint16(out[pos:], uint16(len(out)))
|
||||
binary.LittleEndian.PutUint16(out[pos+2:], uint16(len(b)/2))
|
||||
if pos == 44 {
|
||||
for i, v := range b {
|
||||
b[i] = (v<<4 | v>>4) ^ 0xa5
|
||||
}
|
||||
}
|
||||
out = append(out, b...)
|
||||
}
|
||||
binary.LittleEndian.PutUint32(out, uint32(len(out)))
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package sshproxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"serviceport/internal/model"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSelectedAccountAuthentication(t *testing.T) {
|
||||
address, signer, _ := testTarget(t)
|
||||
gateway := testProxy(t, address, signer)
|
||||
target := model.TargetFromProxy(gateway)
|
||||
target.Accounts = []model.TargetAccount{{ID: "selected", Name: "Selected", Username: "target", Password: "secret", AuthMethod: "password"}}
|
||||
gateway.TargetAccountID = "selected"
|
||||
selected, err := target.Resolve(gateway)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = New().Test(context.Background(), selected, direct); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gateway.TargetAccountID = ""
|
||||
if _, err := target.Resolve(gateway); err == nil {
|
||||
t.Fatal("missing selection silently authenticated")
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
package sshproxy
|
||||
|
||||
import (
|
||||
"serviceport/internal/model"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"io"
|
||||
"serviceport/internal/model"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
package sshproxy
|
||||
|
||||
import (
|
||||
"serviceport/internal/model"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"io"
|
||||
"serviceport/internal/model"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package sshproxy
|
||||
|
||||
import (
|
||||
"serviceport/internal/model"
|
||||
"serviceport/internal/testutil"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
@@ -12,6 +10,8 @@ import (
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"serviceport/internal/model"
|
||||
"serviceport/internal/testutil"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -21,8 +21,10 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"serviceport/internal/model"
|
||||
"github.com/pkg/sftp"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"serviceport/internal/model"
|
||||
"serviceport/internal/protocol/transport"
|
||||
)
|
||||
|
||||
type Handler struct{}
|
||||
@@ -66,27 +68,32 @@ func serverConfig(p model.Proxy) (*ssh.ServerConfig, error) {
|
||||
}
|
||||
cfg := &ssh.ServerConfig{Config: algorithms(), MaxAuthTries: 5, ServerVersion: "SSH-2.0-serviceport", PublicKeyAuthAlgorithms: []string{ssh.KeyAlgoED25519, ssh.KeyAlgoRSASHA256, ssh.KeyAlgoRSASHA512}}
|
||||
cfg.AddHostKey(signer)
|
||||
if p.GatewayPassword != "" {
|
||||
if p.GatewayPassword != "" || len(p.GatewayAccounts) > 0 {
|
||||
cfg.PasswordCallback = func(c ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) {
|
||||
if equalSecret(c.User(), p.GatewayUser) && equalSecret(string(password), p.GatewayPassword) {
|
||||
a, found := transport.Account(p, c.User(), c.RemoteAddr())
|
||||
if found && a.Password != "" && equalSecret(string(password), a.Password) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.New("authentication failed")
|
||||
}
|
||||
}
|
||||
keys := make([]ssh.PublicKey, 0, len(p.SSH.AuthorizedKeys))
|
||||
for _, entry := range p.SSH.AuthorizedKeys {
|
||||
entries := p.SSH.AuthorizedKeys
|
||||
for _, a := range p.GatewayAccounts {
|
||||
entries = append(append([]string(nil), entries...), a.AuthorizedKeys...)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
key, _, options, rest, e := ssh.ParseAuthorizedKey([]byte(entry))
|
||||
if e != nil || len(options) > 0 || len(bytes.TrimSpace(rest)) > 0 || !supportedKey(key) {
|
||||
return nil, errors.New("invalid or unsupported authorized key")
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
if len(keys) > 0 {
|
||||
if len(entries) > 0 {
|
||||
cfg.PublicKeyCallback = func(c ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
|
||||
if c.User() == p.GatewayUser {
|
||||
for _, known := range keys {
|
||||
if bytes.Equal(known.Marshal(), key.Marshal()) {
|
||||
a, found := transport.Account(p, c.User(), c.RemoteAddr())
|
||||
if found {
|
||||
for _, entry := range a.AuthorizedKeys {
|
||||
known, _, _, _, err := ssh.ParseAuthorizedKey([]byte(entry))
|
||||
if err == nil && bytes.Equal(known.Marshal(), key.Marshal()) {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
@@ -100,6 +107,9 @@ func serverConfig(p model.Proxy) (*ssh.ServerConfig, error) {
|
||||
return cfg, nil
|
||||
}
|
||||
func upstreamConfig(p model.Proxy) (*ssh.ClientConfig, error) {
|
||||
if err := p.CheckTargetCredential(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var callback ssh.HostKeyCallback
|
||||
var err error
|
||||
if p.SSH.IgnoreHostKey {
|
||||
@@ -185,6 +195,16 @@ func (h *Handler) Test(ctx context.Context, p model.Proxy, dial model.DialFunc)
|
||||
}
|
||||
defer closeConn()
|
||||
defer c.Close()
|
||||
if p.Type == "sftp" {
|
||||
files, err := sftp.NewClient(c)
|
||||
if err != nil {
|
||||
return model.TestResult{}, errors.New("SFTP subsystem unavailable")
|
||||
}
|
||||
defer files.Close()
|
||||
if _, err := files.Getwd(); err != nil {
|
||||
return model.TestResult{}, errors.New("SFTP working directory unavailable")
|
||||
}
|
||||
}
|
||||
result := model.TestResult{Version: string(c.ServerVersion()), Fingerprint: fingerprint}
|
||||
return result, nil
|
||||
}
|
||||
@@ -219,9 +239,10 @@ func (h *Handler) Serve(ctx context.Context, raw net.Conn, p model.Proxy, dial m
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
upstreamCfg, err := upstreamConfig(p)
|
||||
if err != nil {
|
||||
return err
|
||||
cfg.AuthLogCallback = func(c ssh.ConnMetadata, method string, err error) {
|
||||
if method != "none" {
|
||||
transport.AuthenticationEvent(event, c.User(), err == nil)
|
||||
}
|
||||
}
|
||||
raw.SetDeadline(time.Now().Add(15 * time.Second))
|
||||
down, channels, requests, err := ssh.NewServerConn(raw, cfg)
|
||||
@@ -229,6 +250,15 @@ func (h *Handler) Serve(ctx context.Context, raw net.Conn, p model.Proxy, dial m
|
||||
return errors.New("SSH gateway authentication or handshake failed")
|
||||
}
|
||||
defer down.Close()
|
||||
account, found := transport.Account(p, down.User(), raw.RemoteAddr())
|
||||
if !found {
|
||||
return errors.New("SSH gateway account unavailable")
|
||||
}
|
||||
p = account.Apply(p)
|
||||
upstreamCfg, err := upstreamConfig(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw.SetDeadline(time.Time{})
|
||||
sessionCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
@@ -267,7 +297,7 @@ func (h *Handler) Serve(ctx context.Context, raw net.Conn, p model.Proxy, dial m
|
||||
go func(ch ssh.NewChannel) {
|
||||
defer sessions.Done()
|
||||
defer func() { <-slots }()
|
||||
bridgeSession(ctx, upstream, ch, event)
|
||||
bridgeSession(ctx, upstream, ch, event, p.Type == "sftp")
|
||||
}(incoming)
|
||||
}
|
||||
return nil
|
||||
@@ -287,7 +317,7 @@ func allowedRequest(req *ssh.Request) bool {
|
||||
return false
|
||||
}
|
||||
}
|
||||
func bridgeSession(ctx context.Context, up *ssh.Client, incoming ssh.NewChannel, event model.EventFunc) {
|
||||
func bridgeSession(ctx context.Context, up *ssh.Client, incoming ssh.NewChannel, event model.EventFunc, sftpOnly bool) {
|
||||
target, targetRequests, err := up.OpenChannel("session", incoming.ExtraData())
|
||||
if err != nil {
|
||||
incoming.Reject(ssh.ConnectionFailed, "target session unavailable")
|
||||
@@ -300,7 +330,11 @@ func bridgeSession(ctx context.Context, up *ssh.Client, incoming ssh.NewChannel,
|
||||
}
|
||||
defer client.Close()
|
||||
input := newInputAudit(ctx, client)
|
||||
if sftpOnly {
|
||||
input.mode.Store(2)
|
||||
}
|
||||
var workers sync.WaitGroup
|
||||
var requestMu sync.Mutex
|
||||
var output sync.WaitGroup
|
||||
output.Add(2)
|
||||
workers.Add(5)
|
||||
@@ -308,14 +342,19 @@ func bridgeSession(ctx context.Context, up *ssh.Client, incoming ssh.NewChannel,
|
||||
defer workers.Done()
|
||||
defer target.Close()
|
||||
for req := range clientRequests {
|
||||
requestMu.Lock()
|
||||
ok := false
|
||||
allowed := allowedRequest(req)
|
||||
if sftpOnly {
|
||||
allowed = allowed && req.Type == "subsystem"
|
||||
}
|
||||
decision := "blocked"
|
||||
if allowed {
|
||||
decision = "allowed"
|
||||
}
|
||||
if err := auditRequest(ctx, req, decision); err != nil {
|
||||
client.Close()
|
||||
requestMu.Unlock()
|
||||
return
|
||||
}
|
||||
if allowed {
|
||||
@@ -332,6 +371,7 @@ func bridgeSession(ctx context.Context, up *ssh.Client, incoming ssh.NewChannel,
|
||||
if req.WantReply {
|
||||
req.Reply(ok, nil)
|
||||
}
|
||||
requestMu.Unlock()
|
||||
}
|
||||
}()
|
||||
go func() { defer workers.Done(); io.Copy(target, input); target.CloseWrite() }()
|
||||
@@ -351,6 +391,10 @@ func bridgeSession(ctx context.Context, up *ssh.Client, incoming ssh.NewChannel,
|
||||
}
|
||||
target.Close()
|
||||
output.Wait()
|
||||
// Target channel closure may precede forwarding the reply to exec/subsystem.
|
||||
// Wait for that in-flight reply before closing the downstream channel.
|
||||
requestMu.Lock()
|
||||
client.Close()
|
||||
requestMu.Unlock()
|
||||
workers.Wait()
|
||||
}
|
||||
|
||||
@@ -13,8 +13,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"serviceport/internal/model"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"serviceport/internal/model"
|
||||
)
|
||||
|
||||
func testSigner(t *testing.T) (string, ssh.Signer) {
|
||||
@@ -29,7 +29,7 @@ func testSigner(t *testing.T) (string, ssh.Signer) {
|
||||
}
|
||||
return key, s
|
||||
}
|
||||
func testTarget(t *testing.T) (string, ssh.Signer, *atomic.Int32) {
|
||||
func testTarget(t *testing.T, lateReply ...bool) (string, ssh.Signer, *atomic.Int32) {
|
||||
t.Helper()
|
||||
_, signer := testSigner(t)
|
||||
calls := &atomic.Int32{}
|
||||
@@ -69,10 +69,15 @@ func testTarget(t *testing.T) (string, ssh.Signer, *atomic.Int32) {
|
||||
defer ch.Close()
|
||||
for r := range rs {
|
||||
if r.Type == "exec" {
|
||||
r.Reply(true, nil)
|
||||
if len(lateReply) == 0 {
|
||||
r.Reply(true, nil)
|
||||
}
|
||||
io.WriteString(ch, "hello\n")
|
||||
io.WriteString(ch.Stderr(), "warning\n")
|
||||
ch.SendRequest("exit-status", false, ssh.Marshal(struct{ Code uint32 }{7}))
|
||||
if len(lateReply) > 0 {
|
||||
r.Reply(true, nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
if r.Type == "subsystem" {
|
||||
@@ -89,6 +94,30 @@ func testTarget(t *testing.T) (string, ssh.Signer, *atomic.Int32) {
|
||||
}()
|
||||
return l.Addr().String(), signer, calls
|
||||
}
|
||||
|
||||
func TestExecReplyIsForwardedBeforeChannelClose(t *testing.T) {
|
||||
address, signer, _ := testTarget(t, true)
|
||||
p := testProxy(t, address, signer)
|
||||
gateway, cancel := startGateway(t, p)
|
||||
defer cancel()
|
||||
c, err := ssh.Dial("tcp", gateway, &ssh.ClientConfig{User: p.GatewayUser, Auth: []ssh.AuthMethod{ssh.Password(p.GatewayPassword)}, HostKeyCallback: ssh.InsecureIgnoreHostKey(), Timeout: 3 * time.Second})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
for i := 0; i < 30; i++ {
|
||||
session, err := c.NewSession()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := session.CombinedOutput("fixture")
|
||||
session.Close()
|
||||
var exit *ssh.ExitError
|
||||
if !errors.As(err, &exit) || exit.ExitStatus() != 7 || len(out) != 14 {
|
||||
t.Fatalf("lost reply/output: %q %v", out, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
func testProxy(t *testing.T, address string, signer ssh.Signer) model.Proxy {
|
||||
key, _ := testSigner(t)
|
||||
host, port, _ := net.SplitHostPort(address)
|
||||
@@ -159,6 +188,7 @@ func startGateway(t *testing.T, p model.Proxy) (string, context.CancelFunc) {
|
||||
func TestGatewayExecSFTPAndRejectedForwarding(t *testing.T) {
|
||||
address, signer, _ := testTarget(t)
|
||||
p := testProxy(t, address, signer)
|
||||
p.GatewayAccounts = []model.GatewayAccount{{ID: "other", Username: "other", Password: "other-secret"}, {ID: "gateway", Username: p.GatewayUser, Password: p.GatewayPassword}}
|
||||
gateway, cancel := startGateway(t, p)
|
||||
gatewayKey, err := ssh.ParsePrivateKey([]byte(p.SSH.HostKey))
|
||||
if err != nil {
|
||||
@@ -238,6 +268,7 @@ func TestGatewayPublicKeyAndSessionLimit(t *testing.T) {
|
||||
_, clientKey := testSigner(t)
|
||||
p.GatewayPassword = ""
|
||||
p.SSH.AuthorizedKeys = []string{string(ssh.MarshalAuthorizedKey(clientKey.PublicKey()))}
|
||||
p.GatewayAccounts = []model.GatewayAccount{{ID: "other", Username: "other", Password: "other-secret"}, {ID: "gateway", Username: p.GatewayUser, AuthorizedKeys: p.SSH.AuthorizedKeys}}
|
||||
gateway, _ := startGateway(t, p)
|
||||
host, _ := ssh.ParsePrivateKey([]byte(p.SSH.HostKey))
|
||||
c, err := ssh.Dial("tcp", gateway, &ssh.ClientConfig{User: p.GatewayUser, Auth: []ssh.AuthMethod{ssh.PublicKeys(clientKey)}, HostKeyCallback: ssh.FixedHostKey(host.PublicKey()), Timeout: 5 * time.Second})
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"serviceport/internal/model"
|
||||
)
|
||||
|
||||
// Account applies both gateway and account network restrictions.
|
||||
func Account(p model.Proxy, username string, remote net.Addr) (model.GatewayAccount, bool) {
|
||||
a, ok := p.GatewayAccount(username)
|
||||
if !ok || a.Apply(p).CheckTargetCredential() != nil || a.Disabled || !allowedAccountIP(a.AllowCIDRs, remote) || !allowedAccountIP(p.AllowCIDRs, remote) {
|
||||
return a, false
|
||||
}
|
||||
return a, true
|
||||
}
|
||||
|
||||
func allowedAccountIP(cidrs []string, remote net.Addr) bool {
|
||||
if len(cidrs) == 0 {
|
||||
return true
|
||||
}
|
||||
if remote == nil {
|
||||
return false
|
||||
}
|
||||
host, _, err := net.SplitHostPort(remote.String())
|
||||
if err != nil {
|
||||
host = remote.String()
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
for _, entry := range cidrs {
|
||||
if candidate := net.ParseIP(entry); candidate != nil && candidate.Equal(ip) {
|
||||
return true
|
||||
}
|
||||
if _, network, err := net.ParseCIDR(entry); err == nil && network.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func AuthenticationEvent(event model.EventFunc, username string, success bool) {
|
||||
if event == nil {
|
||||
return
|
||||
}
|
||||
category := "authentication_failed"
|
||||
if success {
|
||||
category = "authentication_succeeded"
|
||||
}
|
||||
data, _ := json.Marshal(struct {
|
||||
Username string `json:"username"`
|
||||
}{Username: username})
|
||||
event(category, string(data))
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"serviceport/internal/model"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAccountRestrictions(t *testing.T) {
|
||||
p := model.Proxy{GatewayUser: "legacy", GatewayPassword: "secret", AllowCIDRs: []string{"127.0.0.0/8"}, GatewayAccounts: []model.GatewayAccount{
|
||||
{ID: "one", Username: "first", Password: "first-secret"},
|
||||
{ID: "two", Username: "second", Password: "second-secret", AllowCIDRs: []string{"127.0.0.1"}, AccessMode: "readonly"},
|
||||
{ID: "off", Username: "disabled", Password: "secret", Disabled: true},
|
||||
}}
|
||||
for _, tc := range []struct {
|
||||
user, ip string
|
||||
want bool
|
||||
}{
|
||||
{"first", "127.0.0.2", true}, {"second", "127.0.0.1", true}, {"second", "127.0.0.2", false},
|
||||
{"first", "192.0.2.1", false}, {"disabled", "127.0.0.1", false}, {"legacy", "127.0.0.1", false}, {"missing", "127.0.0.1", false},
|
||||
} {
|
||||
a, got := Account(p, tc.user, &net.TCPAddr{IP: net.ParseIP(tc.ip), Port: 1234})
|
||||
if got != tc.want {
|
||||
t.Errorf("%s from %s: got %v", tc.user, tc.ip, got)
|
||||
}
|
||||
if got && tc.user == "second" && a.Apply(p).AccessMode != "readonly" {
|
||||
t.Fatal("account readonly lost")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticationEventEscapesUsername(t *testing.T) {
|
||||
AuthenticationEvent(func(category, rule string) {
|
||||
if category != "authentication_failed" {
|
||||
t.Fatal(category)
|
||||
}
|
||||
var record map[string]string
|
||||
if err := json.Unmarshal([]byte(rule), &record); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(record) != 1 || record["username"] != "user\nname" {
|
||||
t.Fatal(record)
|
||||
}
|
||||
}, "user\nname", false)
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"serviceport/internal/model"
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
@@ -9,6 +8,7 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"serviceport/internal/model"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -32,6 +32,9 @@ func TLSConfig(p model.Proxy) (*tls.Config, error) {
|
||||
return cfg, nil
|
||||
}
|
||||
func Dial(ctx context.Context, p model.Proxy, dial model.DialFunc) (net.Conn, error) {
|
||||
if err := p.CheckTargetCredential(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c, err := dial(ctx, "tcp", net.JoinHostPort(p.TargetHost, strconv.Itoa(p.TargetPort)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AlertSettings struct {
|
||||
WebhookEnabled bool `json:"webhookEnabled"`
|
||||
WebhookURL string `json:"webhookURL"`
|
||||
WebhookSecret string `json:"webhookSecret"`
|
||||
AuthFailureThreshold int `json:"authFailureThreshold"`
|
||||
AuthFailureWindowSeconds int `json:"authFailureWindowSeconds"`
|
||||
CertificateReminderDays int `json:"certificateReminderDays"`
|
||||
}
|
||||
|
||||
func DefaultAlertSettings() AlertSettings {
|
||||
return AlertSettings{AuthFailureThreshold: 10, AuthFailureWindowSeconds: 60, CertificateReminderDays: 30}
|
||||
}
|
||||
func (c AlertSettings) Validate() error {
|
||||
if c.AuthFailureThreshold < 1 || c.AuthFailureThreshold > 100000 || c.AuthFailureWindowSeconds < 10 || c.AuthFailureWindowSeconds > 3600 || c.CertificateReminderDays < 1 || c.CertificateReminderDays > 365 {
|
||||
return errors.New("invalid alert thresholds")
|
||||
}
|
||||
if len(c.WebhookSecret) > 4096 || len(c.WebhookURL) > 4096 {
|
||||
return errors.New("webhook settings too long")
|
||||
}
|
||||
if c.WebhookURL != "" {
|
||||
u, e := url.Parse(c.WebhookURL)
|
||||
if e != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Hostname() == "" || u.User != nil || u.Fragment != "" {
|
||||
return errors.New("webhook URL must be HTTP(S), without user info or fragment")
|
||||
}
|
||||
}
|
||||
if c.WebhookEnabled && c.WebhookURL == "" {
|
||||
return errors.New("webhook URL required when enabled")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Alert struct {
|
||||
ID int64 `json:"id"`
|
||||
Key string `json:"key"`
|
||||
Kind string `json:"kind"`
|
||||
ResourceID string `json:"resourceId"`
|
||||
AccountID string `json:"accountId"`
|
||||
Severity string `json:"severity"`
|
||||
Message string `json:"message"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
AcknowledgedAt string `json:"acknowledgedAt"`
|
||||
DeliveryStatus string `json:"deliveryStatus"`
|
||||
DeliveryAttempts int `json:"deliveryAttempts"`
|
||||
}
|
||||
|
||||
func (s *Store) InitAlerts() error {
|
||||
_, e := s.db.Exec(`CREATE TABLE IF NOT EXISTS alert_settings(id INTEGER PRIMARY KEY CHECK(id=1),config BLOB NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS alerts(id INTEGER PRIMARY KEY AUTOINCREMENT,alert_key TEXT NOT NULL UNIQUE,kind TEXT NOT NULL,resource_id TEXT NOT NULL,account_id TEXT NOT NULL,severity TEXT NOT NULL,message TEXT NOT NULL,status TEXT NOT NULL,created_at TEXT NOT NULL,updated_at TEXT NOT NULL,acknowledged_at TEXT NOT NULL DEFAULT '',delivery_status TEXT NOT NULL,delivery_attempts INTEGER NOT NULL DEFAULT 0);`)
|
||||
return e
|
||||
}
|
||||
func (s *Store) AlertSettings() (AlertSettings, error) {
|
||||
c := DefaultAlertSettings()
|
||||
var raw []byte
|
||||
e := s.db.QueryRow("SELECT config FROM alert_settings WHERE id=1").Scan(&raw)
|
||||
if e == sql.ErrNoRows {
|
||||
return c, nil
|
||||
}
|
||||
if e != nil {
|
||||
return c, e
|
||||
}
|
||||
raw, e = s.vault.Open(raw, "alert-settings")
|
||||
if e != nil {
|
||||
return c, e
|
||||
}
|
||||
e = json.Unmarshal(raw, &c)
|
||||
return c, e
|
||||
}
|
||||
func (s *Store) SaveAlertSettings(c AlertSettings) error {
|
||||
if e := c.Validate(); e != nil {
|
||||
return e
|
||||
}
|
||||
raw, e := json.Marshal(c)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
raw, e = s.vault.Seal(raw, "alert-settings")
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
_, e = s.db.Exec("INSERT INTO alert_settings(id,config) VALUES(1,?) ON CONFLICT(id) DO UPDATE SET config=excluded.config", raw)
|
||||
return e
|
||||
}
|
||||
|
||||
const alertColumns = "id,alert_key,kind,resource_id,account_id,severity,message,status,created_at,updated_at,acknowledged_at,delivery_status,delivery_attempts"
|
||||
|
||||
func (s *Store) Alerts() ([]Alert, error) {
|
||||
rows, e := s.db.Query("SELECT " + alertColumns + " FROM alerts ORDER BY updated_at DESC,id DESC")
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []Alert{}
|
||||
for rows.Next() {
|
||||
var a Alert
|
||||
if e = rows.Scan(&a.ID, &a.Key, &a.Kind, &a.ResourceID, &a.AccountID, &a.Severity, &a.Message, &a.Status, &a.CreatedAt, &a.UpdatedAt, &a.AcknowledgedAt, &a.DeliveryStatus, &a.DeliveryAttempts); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ReconcileAlert creates one durable row per condition and only re-notifies on a state transition.
|
||||
func (s *Store) ReconcileAlert(a Alert, active, notify bool, now time.Time) error {
|
||||
status := "resolved"
|
||||
if active {
|
||||
status = "active"
|
||||
}
|
||||
delivery := "disabled"
|
||||
if notify {
|
||||
delivery = "pending"
|
||||
}
|
||||
at := now.UTC().Format(time.RFC3339Nano)
|
||||
if !active {
|
||||
_, e := s.db.Exec("UPDATE alerts SET status='resolved',updated_at=?,delivery_status=?,delivery_attempts=0 WHERE alert_key=? AND status='active'", at, delivery, a.Key)
|
||||
return e
|
||||
}
|
||||
_, e := s.db.Exec(`INSERT INTO alerts(alert_key,kind,resource_id,account_id,severity,message,status,created_at,updated_at,delivery_status) VALUES(?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(alert_key) DO UPDATE SET kind=excluded.kind,severity=excluded.severity,message=excluded.message,status=excluded.status,updated_at=excluded.updated_at,acknowledged_at='',delivery_status=excluded.delivery_status,delivery_attempts=0 WHERE alerts.status!='active' OR alerts.kind!=excluded.kind`, a.Key, a.Kind, a.ResourceID, a.AccountID, a.Severity, a.Message, status, at, at, delivery)
|
||||
return e
|
||||
}
|
||||
func (s *Store) AcknowledgeAlert(id int64) error {
|
||||
r, e := s.db.Exec("UPDATE alerts SET acknowledged_at=? WHERE id=?", time.Now().UTC().Format(time.RFC3339Nano), id)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
n, e := r.RowsAffected()
|
||||
if e == nil && n == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return e
|
||||
}
|
||||
func (s *Store) AlertDelivery(id int64, transition, status string) error {
|
||||
_, e := s.db.Exec("UPDATE alerts SET delivery_status=?,delivery_attempts=delivery_attempts+1 WHERE id=? AND updated_at=?", status, id, transition)
|
||||
return e
|
||||
}
|
||||
|
||||
// Prune resolved history after 90 days; active alerts are never discarded.
|
||||
func (s *Store) PruneAlerts(now time.Time) error {
|
||||
_, e := s.db.Exec("DELETE FROM alerts WHERE status='resolved' AND (updated_at<? OR id IN (SELECT id FROM alerts WHERE status='resolved' ORDER BY updated_at DESC,id DESC LIMIT -1 OFFSET 1000))", now.Add(-90*24*time.Hour).UTC().Format(time.RFC3339Nano))
|
||||
return e
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"serviceport/internal/model"
|
||||
"serviceport/internal/security"
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
@@ -11,6 +9,8 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"serviceport/internal/model"
|
||||
"serviceport/internal/security"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"serviceport/internal/model"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTemplatesRedactOnSaveAndLegacyRead(t *testing.T) {
|
||||
s := configStore(t)
|
||||
b := Bundle{Version: 1, Targets: []model.Target{{ID: "t", Accounts: []model.TargetAccount{{ID: "a", Password: "private-pass", PrivateKey: "private-key", Passphrase: "private-phrase"}}, Outbound: model.Outbound{KubeConfig: "private-kube"}}}, Gateways: []model.Proxy{{ID: "g", GatewayPassword: "private-gateway", GatewayAccounts: []model.GatewayAccount{{ID: "a", Password: "private-client"}}, SSH: model.SSHConfig{HostKey: "private-host"}}}}
|
||||
v, err := s.SaveTemplate("safe", b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := json.Marshal(v)
|
||||
if strings.Contains(string(raw), "private-") {
|
||||
t.Fatal("save response leaked")
|
||||
}
|
||||
// Simulate an encrypted template created by the old implementation.
|
||||
v.Bundle = b
|
||||
raw, _ = json.Marshal(v)
|
||||
sealed, err := s.vault.Seal(raw, "template:"+v.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = s.db.Exec("UPDATE config_templates SET config=? WHERE id=?", sealed, v.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := s.Template(v.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ = json.Marshal(got)
|
||||
if strings.Contains(string(raw), "private-") {
|
||||
t.Fatal("legacy template leaked")
|
||||
}
|
||||
if b.Targets[0].Accounts[0].Password != "private-pass" {
|
||||
t.Fatal("redaction mutated caller")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportPreviewMatchesMergeAndRejectsStaleRevision(t *testing.T) {
|
||||
s := configStore(t)
|
||||
target := configTarget()
|
||||
if err := s.SaveTarget(target); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A partial bundle may reference a target already in storage.
|
||||
gateway := configGateway()
|
||||
gateway.Type = target.Type
|
||||
gateway.TargetID = target.ID
|
||||
gateway.TargetAccountID = "account"
|
||||
in := Bundle{Version: 1, Gateways: []model.Proxy{gateway}}
|
||||
preview, err := s.PreviewBundle(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := json.Marshal(preview)
|
||||
if strings.Contains(string(raw), "private-") {
|
||||
t.Fatal("preview leaked")
|
||||
}
|
||||
if len(preview.Resources) != 1 || preview.Resources[0].Action != "create" {
|
||||
t.Fatal(preview)
|
||||
}
|
||||
if err = s.RestoreBundleChecked(in, preview.Revision); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
preview, err = s.PreviewBundle(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if preview.Resources[0].Action != "unchanged" {
|
||||
t.Fatal("identical import marked as changed", preview)
|
||||
}
|
||||
in.Gateways[0].Name = "replacement"
|
||||
in.Gateways[0].GatewayAccounts[0].Password = "private-replacement-password"
|
||||
replaced, err := s.PreviewBundle(in)
|
||||
if err != nil || replaced.Resources[0].Action != "replace" {
|
||||
t.Fatal("replacement missing", err)
|
||||
}
|
||||
raw, _ = json.Marshal(replaced)
|
||||
if strings.Contains(string(raw), "private-") {
|
||||
t.Fatal("replacement preview leaked credentials")
|
||||
}
|
||||
secretChanged := false
|
||||
for _, change := range replaced.Resources[0].Changes {
|
||||
secretChanged = secretChanged || change.SecretChanged
|
||||
}
|
||||
if !secretChanged {
|
||||
t.Fatal("password change missing from preview")
|
||||
}
|
||||
target.Name = "changed elsewhere"
|
||||
if err = s.SaveTarget(target); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = s.RestoreBundleChecked(in, preview.Revision); !errors.Is(err, ErrConfigurationChanged) {
|
||||
t.Fatal("stale preview accepted", err)
|
||||
}
|
||||
invalid := Bundle{Version: 1, Targets: []model.Target{target}}
|
||||
invalid.Targets[0].Accounts = nil
|
||||
if _, err = s.PreviewBundle(invalid); err == nil {
|
||||
t.Fatal("invalid merged configuration accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenExpiryAndDisable(t *testing.T) {
|
||||
s := configStore(t)
|
||||
if err := s.InitAdmin("admin", "admin-test-password"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token, secret, err := s.CreateToken(APIToken{Name: "expiry", Owner: "admin", Scopes: []string{"read"}, ExpiresAt: time.Now().Add(time.Hour).UTC().Format(time.RFC3339)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = s.VerifyToken(secret); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = s.db.Exec("UPDATE api_tokens SET expires_at=? WHERE id=?", time.Now().Add(-time.Hour).UTC().Format(time.RFC3339), token.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = s.VerifyToken(secret); err == nil {
|
||||
t.Fatal("expired token accepted")
|
||||
}
|
||||
password := "secondary-admin-password"
|
||||
if err = s.SaveUser(User{Username: "operator", Role: "admin"}, &password, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, secret, err = s.CreateToken(APIToken{Name: "disable", Owner: "operator", Scopes: []string{"read"}, ExpiresAt: time.Now().Add(time.Hour).UTC().Format(time.RFC3339)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = s.SaveUser(User{Username: "operator", Role: "admin", Disabled: true}, nil, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = s.VerifyToken(secret); err == nil {
|
||||
t.Fatal("disabled owner token accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"golang.org/x/crypto/pbkdf2"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"regexp"
|
||||
"serviceport/internal/model"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Bundle struct {
|
||||
Version int `json:"version"`
|
||||
Targets []model.Target `json:"targets"`
|
||||
Gateways []model.Proxy `json:"gateways"`
|
||||
Outbounds []model.OutboundProfile `json:"outbounds"`
|
||||
}
|
||||
type EncryptedBackup struct {
|
||||
Version int `json:"version"`
|
||||
Salt []byte `json:"salt"`
|
||||
Nonce []byte `json:"nonce"`
|
||||
Ciphertext []byte `json:"ciphertext"`
|
||||
}
|
||||
|
||||
const backupPurpose = "serviceport-backup-v1"
|
||||
|
||||
func (s *Store) bundle(tx *sql.Tx) (Bundle, error) {
|
||||
b := Bundle{Version: 1, Targets: []model.Target{}, Gateways: []model.Proxy{}, Outbounds: []model.OutboundProfile{}}
|
||||
for _, kind := range []string{"outbounds", "targets", "gateways"} {
|
||||
rows, err := tx.Query("SELECT id,config FROM " + kind + " ORDER BY id")
|
||||
if err != nil {
|
||||
return b, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var id string
|
||||
var blob []byte
|
||||
if err = rows.Scan(&id, &blob); err != nil {
|
||||
rows.Close()
|
||||
return b, err
|
||||
}
|
||||
purpose, _ := configPurpose(kind, id)
|
||||
raw, err := s.vault.Open(blob, purpose)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return b, err
|
||||
}
|
||||
switch kind {
|
||||
case "outbounds":
|
||||
var p model.OutboundProfile
|
||||
err = json.Unmarshal(raw, &p)
|
||||
b.Outbounds = append(b.Outbounds, p)
|
||||
case "targets":
|
||||
var t model.Target
|
||||
err = json.Unmarshal(raw, &t)
|
||||
b.Targets = append(b.Targets, t)
|
||||
case "gateways":
|
||||
var p model.Proxy
|
||||
err = json.Unmarshal(raw, &p)
|
||||
b.Gateways = append(b.Gateways, p)
|
||||
}
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return b, err
|
||||
}
|
||||
}
|
||||
err = rows.Err()
|
||||
rows.Close()
|
||||
if err != nil {
|
||||
return b, err
|
||||
}
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
func (s *Store) exportSecrets() (Bundle, error) {
|
||||
tx, e := s.db.Begin()
|
||||
if e != nil {
|
||||
return Bundle{}, e
|
||||
}
|
||||
defer tx.Rollback()
|
||||
return s.bundle(tx)
|
||||
}
|
||||
func (s *Store) ExportBundle() (Bundle, error) {
|
||||
b, e := s.exportSecrets()
|
||||
if e != nil {
|
||||
return b, e
|
||||
}
|
||||
return RedactBundle(b), nil
|
||||
}
|
||||
|
||||
// RedactBundle deep-copies before clearing credentials, including old templates.
|
||||
func RedactBundle(b Bundle) Bundle {
|
||||
raw, _ := json.Marshal(b)
|
||||
var copy Bundle
|
||||
_ = json.Unmarshal(raw, ©)
|
||||
b = copy
|
||||
for i := range b.Targets {
|
||||
b.Targets[i], _ = b.Targets[i].Redacted()
|
||||
b.Targets[i].Outbound.KubeConfig = ""
|
||||
}
|
||||
for i := range b.Gateways {
|
||||
b.Gateways[i], _ = b.Gateways[i].Redacted()
|
||||
b.Gateways[i].Outbound.KubeConfig = ""
|
||||
}
|
||||
for i := range b.Outbounds {
|
||||
b.Outbounds[i].Outbound.Password = ""
|
||||
b.Outbounds[i].Outbound.KubeConfig = ""
|
||||
}
|
||||
return b
|
||||
}
|
||||
func backupAEAD(pass string, salt []byte) (cipher.AEAD, error) {
|
||||
if len(pass) < 12 || len(pass) > 1024 {
|
||||
return nil, errors.New("backup passphrase must contain 12-1024 bytes")
|
||||
}
|
||||
key := pbkdf2.Key([]byte(pass), salt, 600000, 32, sha256.New)
|
||||
block, e := aes.NewCipher(key)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
return cipher.NewGCM(block)
|
||||
}
|
||||
func (s *Store) Backup(pass string) (EncryptedBackup, error) {
|
||||
b := EncryptedBackup{Version: 1, Salt: make([]byte, 16), Nonce: make([]byte, 12)}
|
||||
if len(pass) < 12 || len(pass) > 1024 {
|
||||
return b, errors.New("backup passphrase must contain 12-1024 bytes")
|
||||
}
|
||||
if _, e := rand.Read(b.Salt); e != nil {
|
||||
return b, e
|
||||
}
|
||||
if _, e := rand.Read(b.Nonce); e != nil {
|
||||
return b, e
|
||||
}
|
||||
a, e := backupAEAD(pass, b.Salt)
|
||||
if e != nil {
|
||||
return b, e
|
||||
}
|
||||
bundle, e := s.exportSecrets()
|
||||
if e != nil {
|
||||
return b, e
|
||||
}
|
||||
raw, e := json.Marshal(bundle)
|
||||
if e != nil {
|
||||
return b, e
|
||||
}
|
||||
if len(raw) > (16<<20)-a.Overhead() {
|
||||
return b, errors.New("configuration exceeds the 16 MiB backup limit")
|
||||
}
|
||||
b.Ciphertext = a.Seal(nil, b.Nonce, raw, []byte(backupPurpose))
|
||||
return b, nil
|
||||
}
|
||||
func OpenBackup(b EncryptedBackup, pass string) (Bundle, error) {
|
||||
if b.Version != 1 || len(b.Salt) != 16 || len(b.Nonce) != 12 || len(b.Ciphertext) < 16 || len(b.Ciphertext) > 16<<20 {
|
||||
return Bundle{}, errors.New("invalid backup envelope")
|
||||
}
|
||||
a, e := backupAEAD(pass, b.Salt)
|
||||
if e != nil {
|
||||
return Bundle{}, e
|
||||
}
|
||||
raw, e := a.Open(nil, b.Nonce, b.Ciphertext, []byte(backupPurpose))
|
||||
if e != nil {
|
||||
return Bundle{}, errors.New("incorrect passphrase or damaged backup")
|
||||
}
|
||||
var bundle Bundle
|
||||
e = json.Unmarshal(raw, &bundle)
|
||||
return bundle, e
|
||||
}
|
||||
|
||||
var configID = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,128}$`)
|
||||
|
||||
func backupSupportedKey(key ssh.PublicKey) bool {
|
||||
return key != nil && (key.Type() == ssh.KeyAlgoED25519 || key.Type() == ssh.KeyAlgoRSA)
|
||||
}
|
||||
|
||||
func validBackupPublicKey(raw string) bool {
|
||||
key, _, options, rest, err := ssh.ParseAuthorizedKey([]byte(raw))
|
||||
return err == nil && len(options) == 0 && strings.TrimSpace(string(rest)) == "" && backupSupportedKey(key)
|
||||
}
|
||||
|
||||
func validateBundle(b *Bundle) error {
|
||||
if b.Version != 1 {
|
||||
return errors.New("unsupported configuration version")
|
||||
}
|
||||
if len(b.Targets)+len(b.Gateways)+len(b.Outbounds) > 10000 {
|
||||
return errors.New("too many configurations")
|
||||
}
|
||||
os := map[string]model.OutboundProfile{}
|
||||
ts := map[string]model.Target{}
|
||||
gs := map[string]bool{}
|
||||
for i := range b.Outbounds {
|
||||
o := b.Outbounds[i]
|
||||
if !configID.MatchString(o.ID) {
|
||||
return errors.New("invalid outbound id")
|
||||
}
|
||||
if _, ok := os[o.ID]; ok {
|
||||
return errors.New("duplicate outbound id")
|
||||
}
|
||||
if e := o.Validate(); e != nil {
|
||||
return e
|
||||
}
|
||||
os[o.ID] = o
|
||||
b.Outbounds[i] = o
|
||||
}
|
||||
for i := range b.Targets {
|
||||
t := b.Targets[i]
|
||||
if !configID.MatchString(t.ID) {
|
||||
return errors.New("invalid target id")
|
||||
}
|
||||
if _, ok := ts[t.ID]; ok {
|
||||
return errors.New("duplicate target id")
|
||||
}
|
||||
if t.OutboundID != "" {
|
||||
o, ok := os[t.OutboundID]
|
||||
if !ok {
|
||||
return errors.New("target outbound not found")
|
||||
}
|
||||
t.Outbound = o.Outbound
|
||||
}
|
||||
if e := t.Validate(); e != nil {
|
||||
return e
|
||||
}
|
||||
for _, a := range t.Accounts {
|
||||
if a.AuthMethod == "privatekey" {
|
||||
var e error
|
||||
var signer ssh.Signer
|
||||
if a.Passphrase != "" {
|
||||
signer, e = ssh.ParsePrivateKeyWithPassphrase([]byte(a.PrivateKey), []byte(a.Passphrase))
|
||||
} else {
|
||||
signer, e = ssh.ParsePrivateKey([]byte(a.PrivateKey))
|
||||
}
|
||||
if e != nil || !backupSupportedKey(signer.PublicKey()) {
|
||||
return errors.New("invalid target SSH key")
|
||||
}
|
||||
}
|
||||
}
|
||||
if t.SSH.TargetHostKey != "" {
|
||||
if !validBackupPublicKey(t.SSH.TargetHostKey) {
|
||||
return errors.New("invalid target SSH host key")
|
||||
}
|
||||
}
|
||||
ts[t.ID] = t
|
||||
b.Targets[i] = t
|
||||
}
|
||||
for i := range b.Gateways {
|
||||
p := b.Gateways[i]
|
||||
if !configID.MatchString(p.ID) || gs[p.ID] {
|
||||
return errors.New("invalid or duplicate gateway id")
|
||||
}
|
||||
gs[p.ID] = true
|
||||
if p.TargetID != "" {
|
||||
t, ok := ts[p.TargetID]
|
||||
if !ok || t.Type != p.Type {
|
||||
return errors.New("gateway target missing or protocol mismatch")
|
||||
}
|
||||
if t.ValidateAccountReferences(p) != nil {
|
||||
return errors.New("gateway target account not found")
|
||||
}
|
||||
} else if (model.Target{}).ValidateAccountReferences(p) != nil {
|
||||
return errors.New("gateway account requires target")
|
||||
}
|
||||
if e := p.ValidateGateway(); e != nil {
|
||||
return e
|
||||
}
|
||||
if model.IsSSH(p.Type) {
|
||||
if p.SSH.HostKey != "" {
|
||||
signer, e := ssh.ParsePrivateKey([]byte(p.SSH.HostKey))
|
||||
if e != nil || !backupSupportedKey(signer.PublicKey()) {
|
||||
return errors.New("invalid gateway SSH host key")
|
||||
}
|
||||
}
|
||||
keys := append([]string(nil), p.SSH.AuthorizedKeys...)
|
||||
for _, account := range p.GatewayAccounts {
|
||||
keys = append(keys, account.AuthorizedKeys...)
|
||||
}
|
||||
for _, k := range keys {
|
||||
if !validBackupPublicKey(k) {
|
||||
return errors.New("invalid gateway authorized key")
|
||||
}
|
||||
}
|
||||
}
|
||||
p.Enabled = false
|
||||
b.Gateways[i] = p.GatewayOnly()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RestoreBundle merges a complete import with existing resources. All references
|
||||
// are validated in the same transaction before any replacement is committed.
|
||||
func (s *Store) RestoreBundle(in Bundle) error {
|
||||
return s.RestoreBundleChecked(in, "")
|
||||
}
|
||||
|
||||
var ErrConfigurationChanged = errors.New("configuration changed; preview the import again")
|
||||
|
||||
func (s *Store) RestoreBundleChecked(in Bundle, expectedRevision string) error {
|
||||
if in.Version != 1 {
|
||||
return errors.New("unsupported configuration version")
|
||||
}
|
||||
tx, e := s.db.Begin()
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if expectedRevision != "" {
|
||||
rev, err := configurationRevision(tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rev != expectedRevision {
|
||||
return ErrConfigurationChanged
|
||||
}
|
||||
}
|
||||
all, e := s.bundle(tx)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
combined, seen, e := mergeBundle(all, in)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
for _, o := range combined.Outbounds {
|
||||
if !seen["o"+o.ID] {
|
||||
continue
|
||||
}
|
||||
if e = s.saveOutbound(tx, o); e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
for _, t := range combined.Targets {
|
||||
if !seen["t"+t.ID] {
|
||||
continue
|
||||
}
|
||||
if e = s.saveTarget(tx, t); e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
for _, p := range combined.Gateways {
|
||||
if !seen["g"+p.ID] {
|
||||
continue
|
||||
}
|
||||
if e = s.saveGateway(tx, p.GatewayOnly()); e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func ValidateBundle(b *Bundle) error { return validateBundle(b) }
|
||||
|
||||
func (s *Store) RestoreRevision(kind, id string, rev int64) error {
|
||||
raw, e := s.ConfigRevision(kind, id, rev)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
b := Bundle{Version: 1}
|
||||
switch kind {
|
||||
case "targets":
|
||||
var t model.Target
|
||||
e = json.Unmarshal(raw, &t)
|
||||
b.Targets = []model.Target{t}
|
||||
case "outbounds":
|
||||
var o model.OutboundProfile
|
||||
e = json.Unmarshal(raw, &o)
|
||||
b.Outbounds = []model.OutboundProfile{o}
|
||||
case "gateways":
|
||||
var p model.Proxy
|
||||
e = json.Unmarshal(raw, &p)
|
||||
b.Gateways = []model.Proxy{p}
|
||||
}
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
return s.RestoreBundle(b)
|
||||
}
|
||||
|
||||
func mergeBundle(all, in Bundle) (Bundle, map[string]bool, error) {
|
||||
if in.Version != 1 {
|
||||
return Bundle{}, nil, errors.New("unsupported configuration version")
|
||||
}
|
||||
os, ts, gs := map[string]model.OutboundProfile{}, map[string]model.Target{}, map[string]model.Proxy{}
|
||||
for _, o := range all.Outbounds {
|
||||
os[o.ID] = o
|
||||
}
|
||||
for _, t := range all.Targets {
|
||||
ts[t.ID] = t
|
||||
}
|
||||
for _, p := range all.Gateways {
|
||||
gs[p.ID] = p
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, o := range in.Outbounds {
|
||||
if seen["o"+o.ID] {
|
||||
return Bundle{}, nil, errors.New("duplicate outbound id")
|
||||
}
|
||||
seen["o"+o.ID] = true
|
||||
os[o.ID] = o
|
||||
}
|
||||
for _, t := range in.Targets {
|
||||
if seen["t"+t.ID] {
|
||||
return Bundle{}, nil, errors.New("duplicate target id")
|
||||
}
|
||||
seen["t"+t.ID] = true
|
||||
ts[t.ID] = t
|
||||
}
|
||||
for _, p := range in.Gateways {
|
||||
if seen["g"+p.ID] {
|
||||
return Bundle{}, nil, errors.New("duplicate gateway id")
|
||||
}
|
||||
seen["g"+p.ID] = true
|
||||
gs[p.ID] = p
|
||||
}
|
||||
combined := Bundle{Version: 1}
|
||||
for _, o := range os {
|
||||
combined.Outbounds = append(combined.Outbounds, o)
|
||||
}
|
||||
for _, t := range ts {
|
||||
combined.Targets = append(combined.Targets, t)
|
||||
}
|
||||
for _, p := range gs {
|
||||
combined.Gateways = append(combined.Gateways, p)
|
||||
}
|
||||
if e := validateBundle(&combined); e != nil {
|
||||
return Bundle{}, nil, e
|
||||
}
|
||||
return combined, seen, nil
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"serviceport/internal/model"
|
||||
"serviceport/internal/security"
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"serviceport/internal/model"
|
||||
"serviceport/internal/security"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"serviceport/internal/model"
|
||||
"serviceport/internal/security"
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"path/filepath"
|
||||
"serviceport/internal/model"
|
||||
"serviceport/internal/security"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ConfigChange struct {
|
||||
Path string `json:"path"`
|
||||
Before any `json:"before"`
|
||||
After any `json:"after"`
|
||||
SecretChanged bool `json:"secretChanged"`
|
||||
}
|
||||
|
||||
func secretField(field string) bool {
|
||||
switch strings.ToLower(field) {
|
||||
case "password", "targetpassword", "gatewaypassword", "privatekey", "passphrase", "hostkey", "kubeconfig", "webhooksecret":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func configLeaves(value any, path string, secret bool, result map[string]any, secrets map[string]bool) {
|
||||
if secret {
|
||||
result[path] = value
|
||||
secrets[path] = true
|
||||
return
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case map[string]any:
|
||||
for key, child := range v {
|
||||
configLeaves(child, path+"/"+strings.ReplaceAll(strings.ReplaceAll(key, "~", "~0"), "/", "~1"), secretField(key), result, secrets)
|
||||
}
|
||||
case []any:
|
||||
// Account IDs keep comparisons stable when the editor reorders accounts.
|
||||
isAccounts := strings.HasSuffix(path, "/accounts") || strings.HasSuffix(path, "/gatewayAccounts")
|
||||
if isAccounts {
|
||||
for _, child := range v {
|
||||
if account, ok := child.(map[string]any); ok {
|
||||
if id, ok := account["id"].(string); ok {
|
||||
configLeaves(child, path+"/"+strings.ReplaceAll(strings.ReplaceAll(id, "~", "~0"), "/", "~1"), false, result, secrets)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
result[path] = value
|
||||
}
|
||||
default:
|
||||
result[path] = value
|
||||
}
|
||||
}
|
||||
|
||||
// ConfigurationDiff compares the current persisted configuration with a revision.
|
||||
// Secret values never leave this method, including secrets in added accounts.
|
||||
func (s *Store) ConfigurationDiff(kind, id string, revision int64) ([]ConfigChange, error) {
|
||||
purpose, err := configPurpose(kind, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
historical, err := s.ConfigRevision(kind, id, revision)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var blob []byte
|
||||
if err = s.db.QueryRow("SELECT config FROM "+kind+" WHERE id=?", id).Scan(&blob); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
current, err := s.vault.Open(blob, purpose)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var before, after any
|
||||
if err = json.Unmarshal(current, &before); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = json.Unmarshal(historical, &after); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return compareConfig(before, after), nil
|
||||
}
|
||||
|
||||
func compareConfig(before, after any) []ConfigChange {
|
||||
a, b := map[string]any{}, map[string]any{}
|
||||
secrets := map[string]bool{}
|
||||
configLeaves(before, "", false, a, secrets)
|
||||
configLeaves(after, "", false, b, secrets)
|
||||
keys := map[string]bool{}
|
||||
for key := range a {
|
||||
keys[key] = true
|
||||
}
|
||||
for key := range b {
|
||||
keys[key] = true
|
||||
}
|
||||
changes := []ConfigChange{}
|
||||
for key := range keys {
|
||||
if reflect.DeepEqual(a[key], b[key]) {
|
||||
continue
|
||||
}
|
||||
change := ConfigChange{Path: key, Before: a[key], After: b[key], SecretChanged: secrets[key]}
|
||||
if change.SecretChanged {
|
||||
change.Before = nil
|
||||
change.After = nil
|
||||
}
|
||||
changes = append(changes, change)
|
||||
}
|
||||
sort.Slice(changes, func(i, j int) bool { return changes[i].Path < changes[j].Path })
|
||||
return changes
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user