feat: 完善多账号网关、运维监控与配置管理

新增 HTTP/HTTPS 与 SQL Server 支持,完善账号映射、凭据生命周期、角色和 API Token 隔离。实现健康检查、告警、配置导入导出、模板与历史差异,并补充管理界面、回归测试和运维文档。
This commit is contained in:
曾志威
2026-09-12 22:19:10 +08:00
parent 34933a845e
commit 5b6cb3e2bf
144 changed files with 8263 additions and 330 deletions
+25 -2
View File
@@ -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 独立。
+217
View File
@@ -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
}
+43
View File
@@ -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)
}
}
+28
View File
@@ -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.
+8
View File
@@ -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.
+8
View File
@@ -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.
+109
View File
@@ -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 轮通过。这些协议检查使用本地测试桩,未宣称通过
外部真实服务或生产高可用环境验收。
+118
View File
@@ -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.
+7
View File
@@ -0,0 +1,7 @@
# SSH 连接测试
独立「代理」菜单的 SSH 转发项支持“测试连接”,验证跳板机连通性、已保存的主机指纹和账号密码,显示服务器版本、指纹与耗时。测试结束会关闭 SSH 连接,不执行远程命令。
此测试不打开目标通道。验证转发权限、目标可达性和目标认证,请在「目标服务」中测试连接。SSH 目标测试前仍须先探测并确认目标主机指纹。
验证使用进程内 SSH 测试服务器,覆盖认证失败、指纹不匹配、取消和连接清理;界面成功/失败展示使用模拟 API 响应验证。
+39
View File
@@ -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.
+3
View File
@@ -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
+6
View File
@@ -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=
+230
View File
@@ -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
}
+152
View File
@@ -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)
}
}
+106
View File
@@ -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
View File
@@ -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")
+125
View File
@@ -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 -1
View File
@@ -1,10 +1,10 @@
package api
import (
"serviceport/internal/store"
"encoding/csv"
"net/http"
"os"
"serviceport/internal/store"
"strconv"
"strings"
"time"
+1 -1
View File
@@ -1,9 +1,9 @@
package api
import (
"serviceport/internal/model"
"encoding/csv"
"encoding/json"
"serviceport/internal/model"
"strings"
"testing"
)
+188
View File
@@ -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")
}
}
+48
View File
@@ -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)
}
+44
View File
@@ -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 -1
View File
@@ -1,10 +1,10 @@
package api
import (
"serviceport/internal/store"
"encoding/csv"
"net/http"
"os"
"serviceport/internal/store"
"strconv"
"time"
)
+83
View File
@@ -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)
}
}
+19 -1
View File
@@ -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)
+86
View File
@@ -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)
}
+47
View File
@@ -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)
}
+3 -3
View File
@@ -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 -1
View File
@@ -1,8 +1,8 @@
package api
import (
"serviceport/internal/model"
"encoding/json"
"serviceport/internal/model"
"strings"
"testing"
)
+77
View File
@@ -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))
}
}
+52
View File
@@ -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
View File
@@ -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
}
+27
View File
@@ -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"})
}
+51
View File
@@ -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
View File
@@ -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)
+1 -1
View File
@@ -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"
+112
View File
@@ -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
View File
@@ -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 -1
View File
@@ -1,10 +1,10 @@
package api
import (
"serviceport/internal/model"
"encoding/json"
"net/http"
"net/http/httptest"
"serviceport/internal/model"
"strings"
"testing"
)
+56
View File
@@ -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")
}
+30
View File
@@ -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)
}
}
}
+68
View File
@@ -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")
}
+123
View File
@@ -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)
}
+63
View File
@@ -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)
}
+14 -3
View File
@@ -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 -1
View File
@@ -1,8 +1,8 @@
package api
import (
"serviceport/internal/buildinfo"
"encoding/json"
"serviceport/internal/buildinfo"
"testing"
)
+84
View File
@@ -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
}
+48
View File
@@ -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
View File
@@ -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))
+4 -4
View File
@@ -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()
+231
View File
@@ -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)
}
}
}
+165
View File
@@ -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)
}
}
+102
View File
@@ -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
}
+85
View File
@@ -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
}
+67
View File
@@ -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
View File
@@ -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"
+5 -2
View File
@@ -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
+121
View File
@@ -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
}
+41
View File
@@ -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")
}
}
+45 -6
View File
@@ -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
}
+170
View File
@@ -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
}
+80
View File
@@ -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"
+3 -1
View File
@@ -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)
+57 -13
View File
@@ -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
+6 -2
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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"
+34 -2
View File
@@ -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
+3 -1
View File
@@ -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"
+12 -3
View File
@@ -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")
+2 -1
View File
@@ -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 })
+7 -3
View File
@@ -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
}
+2 -1
View File
@@ -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")
}
})
}
}
+20 -9
View File
@@ -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"))
+2 -2
View File
@@ -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")
}
})
}
}
+197
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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"
+59 -15
View File
@@ -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()
}
+34 -3
View File
@@ -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})
+56
View File
@@ -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)
}
+4 -1
View File
@@ -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
+154
View File
@@ -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
}
+2 -2
View File
@@ -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"
+142
View File
@@ -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")
}
}
+430
View File
@@ -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, &copy)
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
}
+2 -2
View File
@@ -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"
)
+2 -2
View File
@@ -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"
)
+110
View File
@@ -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