This commit is contained in:
zoe
2026-09-11 12:14:20 +08:00
parent 13dc781fd6
commit 7cd92bc2d1
27 changed files with 392 additions and 138 deletions
+1
View File
@@ -7,6 +7,7 @@ steps:
image: golang:1.25.0-bookworm
environment:
GOTOOLCHAIN: go1.25.0
GO111MODULE: "on"
commands:
- go test ./... -count=1 -timeout=120s
- go vet ./...
+13 -1
View File
@@ -138,11 +138,23 @@ Docker 构建:`docker build -t agentdb .`。容器默认以 UID 10001 运行
## Windows 便携包
所有协议的目标服务统一使用账号列表(例如普通账号、root),不再单独填写一组用户名和密码。在目标编辑的“账号”页签中维护,在网关编辑的“目标账号”中选择。开发阶段不兼容旧账号设计:旧目标需重新配置账号,旧网关需重新选择账号才能启动,不自动迁移或回退。用户只使用网关凭据;root 账号为直接 SSH 登录,不是 sudo 提权。详见 [目标多账号](docs/target-accounts.md)。
```powershell
.\package.cmd
# Optional version override:
.\package.cmd -Version 0.3.0
# PowerShell entry:
powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/package.ps1
```
打包脚本重新构建内嵌前端与 Windows x64 程序,在 `artifacts/releases/<时间>/` 生成 ZIP 及 SHA-256 校验文件。包内包含主程序、开发证书工具、独立启动说明和配置示例,不包含本地数据库或凭据。解压后可用 `node scripts/verify-package.mjs <解压后的包目录>` 验证文件校验值、隔离启动、网页资源与管理员登录。
安装 Go、Node.js/npm 后,可双击根目录 `package.cmd` 打包;窗口保留结果,命令行自动化可设置 `AGENTDB_PACKAGE_NO_PAUSE=1` 取消暂停。版本默认读取 `VERSION`。
默认使用快速 ZIP 压缩,并输出前端、Go 编译、验证、压缩各阶段耗时。需要更小的压缩包时运行 `.\package.cmd -Compression Optimal`,耗时会增加。前端按源码、配置、依赖锁文件、Node 版本及产物内容校验缓存,变化或缺失时自动重建;依赖变化自动执行 `npm ci`。可用 `.\package.cmd -RebuildWeb` 强制重建前端。缓存位于 `artifacts/build-cache`。Go 每次构建当前代码、注入最新版本信息,并自动复用编译缓存;发布包验证始终执行。
`start-dev.cmd` 用于直接启动:先停止当前目录中的旧服务并等待退出,将整个 `data` 备份到 `artifacts/backups/<时间>/`。源码目录每次调用 `scripts/build.ps1` 构建最新前后端并运行 Go 测试,成功后运行 `bin/agentdb.exe`,失败不会启动旧程序;发布包内没有构建脚本时直接运行根目录的 `agentdb.exe`。发布包无需安装 Go 或 Node.js。此入口固定使用入口所在目录的 `data/agentdb.sqlite`,默认免登录,访问 http://127.0.0.1:8080。
打包脚本按需构建前端并编译 Windows x64 程序,自动验证文件校验值、隔离启动、网页资源与管理员登录,通过后在 `artifacts/releases/<时间>/` 生成 ZIP 及 SHA-256 校验文件。包内包含主程序、开发证书工具、独立启动说明和配置示例,不包含本地数据库或凭据。解压后也可用 `node scripts/verify-package.mjs <解压后的包目录>` 重复验证。打包不会替换正在运行的服务。
## 文档
+2 -2
View File
@@ -37,7 +37,7 @@ func TestSharedOutboundLifecycle(t *testing.T) {
}
tids := []string{}
for _, name := range []string{"one", "two"} {
w = f.request("POST", "/api/v1/targets", map[string]any{"name": name, "type": "mysql", "targetHost": "db.internal", "targetPort": 3306, "targetUser": "db", "outboundId": ov.Config.ID}, true)
w = f.request("POST", "/api/v1/targets", map[string]any{"name": name, "type": "mysql", "targetHost": "db.internal", "targetPort": 3306, "accounts": []any{map[string]any{"id": "db", "name": "DB", "username": "db"}}, "outboundId": ov.Config.ID}, true)
requireStatus(t, w, 201)
var tv struct {
Config model.Target `json:"config"`
@@ -51,7 +51,7 @@ func TestSharedOutboundLifecycle(t *testing.T) {
tids = append(tids, tv.Config.ID)
}
requireStatus(t, f.request("DELETE", opath, nil, true), 409)
w = f.request("POST", "/api/v1/proxies", map[string]any{"name": "gateway", "type": "mysql", "targetId": tids[0], "listenHost": "127.0.0.1", "listenPort": freePort(t), "gatewayUser": "u", "gatewayPassword": "p"}, true)
w = f.request("POST", "/api/v1/proxies", map[string]any{"name": "gateway", "type": "mysql", "targetId": tids[0], "targetAccountId": "db", "listenHost": "127.0.0.1", "listenPort": freePort(t), "gatewayUser": "u", "gatewayPassword": "p"}, true)
requireStatus(t, w, 201)
gid := readView(t, w).Config.ID
requireStatus(t, f.request("POST", "/api/v1/proxies/"+gid+"/start", nil, true), 200)
+4
View File
@@ -28,6 +28,10 @@ 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 "pgsql":
+29 -2
View File
@@ -175,11 +175,38 @@ func readView(t *testing.T, w *httptest.ResponseRecorder) proxyView {
}
func (f *fixture) create(kind string) proxyView {
f.t.Helper()
w := f.request("POST", "/api/v1/proxies", config(f.t, kind), true)
w := f.createConfigured(config(f.t, kind))
requireStatus(f.t, w, 201)
return readView(f.t, w)
}
// Build independent target/account and gateway fixtures using the current API.
func (f *fixture) createConfigured(c map[string]any) *httptest.ResponseRecorder {
f.t.Helper()
account := map[string]any{"id": "test-account", "name": "Test", "username": c["targetUser"], "password": c["targetPassword"], "authMethod": "password"}
if sshConfig, ok := c["ssh"].(map[string]any); ok {
for _, key := range []string{"authMethod", "privateKey", "passphrase"} {
if value, ok := sshConfig[key]; ok {
account[key] = value
}
}
}
target := map[string]any{"name": "Test target", "type": c["type"], "targetHost": c["targetHost"], "targetPort": c["targetPort"], "outbound": c["outbound"], "accounts": []any{account}}
w := f.request("POST", "/api/v1/targets", target, true)
requireStatus(f.t, w, 201)
var v struct {
Config model.Target `json:"config"`
}
json.Unmarshal(w.Body.Bytes(), &v)
gateway := map[string]any{"targetId": v.Config.ID, "targetAccountId": "test-account"}
for _, key := range []string{"name", "type", "listenHost", "listenPort", "allowCIDRs", "gatewayUser", "gatewayPassword", "accessMode"} {
if value, ok := c[key]; ok {
gateway[key] = value
}
}
return f.request("POST", "/api/v1/proxies", gateway, true)
}
func TestSessionsCSRFAndPasswordReset(t *testing.T) {
f := newFixture(t)
requireStatus(t, f.request("GET", "/api/v1/proxies", nil, false), 401)
@@ -317,7 +344,7 @@ func TestSSHHostKeyCannotBeInjectedAndPrivateKeyIsRedacted(t *testing.T) {
}
c := config(t, "ssh")
c["ssh"] = map[string]any{"authMethod": "privatekey", "privateKey": key}
w := f.request("POST", "/api/v1/proxies", c, true)
w := f.createConfigured(c)
requireStatus(t, w, 201)
if strings.Contains(w.Body.String(), "PRIVATE KEY") {
t.Fatal("SSH key leaked")
+46 -7
View File
@@ -9,6 +9,7 @@ import (
"context"
"errors"
"net/http"
"reflect"
"strings"
"time"
)
@@ -64,9 +65,7 @@ func (s *Server) targets(w http.ResponseWriter, r *http.Request, path string, se
case "GET":
respond(w, 200, s.targetView(t))
case "PUT":
if s.targetStopped(w, t.ID) {
s.saveTarget(w, r, t)
}
s.saveTarget(w, r, t)
case "DELETE":
ids, e := s.store.TargetReferences(t.ID)
if e != nil {
@@ -95,6 +94,12 @@ func (s *Server) targets(w http.ResponseWriter, r *http.Request, path string, se
p := t.Runtime()
switch parts[2] {
case "test":
p.TargetAccountID = r.URL.Query().Get("accountId")
p, err = t.Resolve(p)
if err != nil {
fail(w, 400, "target account not found")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 25*time.Second)
defer cancel()
result, e := s.manager.Test(ctx, p)
@@ -105,6 +110,12 @@ func (s *Server) targets(w http.ResponseWriter, r *http.Request, path string, se
}
respond(w, 200, result)
case "reveal-password":
p.TargetAccountID = r.URL.Query().Get("accountId")
p, err = t.Resolve(p)
if err != nil {
fail(w, 400, "select a target account")
return
}
s.reveal(w, r, p, sess, token)
case "probe":
if t.Type != "ssh" {
@@ -177,7 +188,6 @@ func (s *Server) saveTarget(w http.ResponseWriter, r *http.Request, current mode
base.AuthSource = "/"
base.TargetTLS = false
case "redis":
base.TargetUser = "default"
base.TargetTLS = false
}
}
@@ -217,8 +227,37 @@ func (s *Server) saveTarget(w http.ResponseWriter, r *http.Request, current mode
fail(w, 400, err.Error())
return
}
if err = validateKeys(t.Runtime()); err != nil {
fail(w, 400, err.Error())
for _, account := range t.Accounts {
if err = validateKeys(account.Apply(t.Runtime())); err != nil {
fail(w, 400, err.Error())
return
}
}
if !creating {
ids, e := s.store.TargetReferences(t.ID)
if e != nil {
fail(w, 500, "cannot load references")
return
}
for _, id := range ids {
p, e := s.store.Get(id)
if e != nil {
fail(w, 500, "cannot load gateway")
return
}
if p.TargetAccountID != "" && !t.HasAccount(p.TargetAccountID) {
fail(w, 409, "target account is referenced by a gateway")
return
}
}
}
// Display metadata does not change the active connection snapshot.
connection := t
connection.Name, connection.Group = current.Name, current.Group
if len(connection.Accounts) == 0 && len(current.Accounts) == 0 {
connection.Accounts = current.Accounts
}
if !creating && !reflect.DeepEqual(connection, current) && !s.targetStopped(w, t.ID) {
return
}
if err = s.store.SaveTarget(t); err != nil {
@@ -250,7 +289,7 @@ func (s *Server) resolveTarget(p model.Proxy) (model.Proxy, error) {
if err != nil {
return p, err
}
return t.Apply(p), nil
return t.Resolve(p)
}
func gatewayFields(patch map[string]any) error {
for _, k := range []string{"group", "targetHost", "targetPort", "targetUser", "targetPassword", "targetTLS", "targetCA", "targetCompression", "authSource", "outbound", "outboundId"} {
+23 -3
View File
@@ -12,20 +12,21 @@ import (
func TestSharedTargetLifecycle(t *testing.T) {
f := newFixture(t)
f.login()
target := map[string]any{"name": "shared database", "type": "mysql", "targetHost": "db.internal", "targetPort": 3306, "targetUser": "upstream", "targetPassword": "shared-secret"}
target := map[string]any{"name": "shared database", "type": "mysql", "targetHost": "db.internal", "targetPort": 3306, "accounts": []any{map[string]any{"id": "normal", "name": "Normal", "username": "upstream", "password": "shared-secret"}}}
w := f.request("POST", "/api/v1/targets", target, true)
requireStatus(t, w, 201)
var tv struct {
Config model.Target `json:"config"`
}
json.Unmarshal(w.Body.Bytes(), &tv)
if tv.Config.TargetPassword != "" {
if tv.Config.Accounts[0].Password != "" {
t.Fatal("target password leaked")
}
tid := tv.Config.ID
var gateways []string
for i := 0; i < 2; i++ {
config := map[string]any{"name": "gateway", "type": "mysql", "targetId": tid, "listenHost": "127.0.0.1", "listenPort": freePort(t), "allowCIDRs": []string{"127.0.0.1"}, "gatewayUser": "client", "gatewayPassword": "gateway-secret"}
config["targetAccountId"] = "normal"
w = f.request("POST", "/api/v1/proxies", config, true)
requireStatus(t, w, 201)
v := readView(t, w)
@@ -34,6 +35,25 @@ func TestSharedTargetLifecycle(t *testing.T) {
path := "/api/v1/targets/" + tid
requireStatus(t, f.request("DELETE", path, nil, true), 409)
requireStatus(t, f.request("POST", "/api/v1/proxies/"+gateways[0]+"/start", nil, true), 200)
// The editor submits the full public config, including unchanged connection fields.
full := map[string]any{}
public, _ := json.Marshal(tv.Config)
json.Unmarshal(public, &full)
delete(full, "id")
full["name"], full["group"] = "renamed database", "production"
requireStatus(t, f.request("PUT", path, full, true), 200)
saved, err := f.store.GetTarget(tid)
if err != nil || saved.Name != "renamed database" || saved.Group != "production" || saved.Accounts[0].Password != "shared-secret" {
t.Fatal("live metadata update was not preserved")
}
if !f.server.manager.Status(gateways[0]).Running {
t.Fatal("renaming stopped gateway")
}
requireStatus(t, f.request("PUT", path, map[string]any{"name": "must not persist", "accounts": []any{map[string]any{"id": "normal", "name": "Normal", "username": "upstream", "password": "changed"}}}, true), 409)
saved, _ = f.store.GetTarget(tid)
if saved.Name != "renamed database" || saved.Accounts[0].Password != "shared-secret" {
t.Fatal("rejected update mutated target")
}
requireStatus(t, f.request("PUT", path, map[string]any{"targetHost": "new.internal"}, true), 409)
requireStatus(t, f.request("POST", "/api/v1/proxies/"+gateways[0]+"/stop", nil, true), 200)
requireStatus(t, f.request("PUT", path, map[string]any{"targetHost": "new.internal"}, true), 200)
@@ -46,7 +66,7 @@ func TestSharedTargetLifecycle(t *testing.T) {
requireStatus(t, f.request("PUT", "/api/v1/proxies/"+gateways[0], map[string]any{"targetPassword": "injected"}, true), 400)
requireStatus(t, f.request("PUT", "/api/v1/proxies/"+gateways[0], map[string]any{"targetId": "missing"}, true), 400)
requireStatus(t, f.request("PUT", path, map[string]any{"ssh": map[string]any{"TargetHostKey": "injected"}}, true), 400)
requireStatus(t, f.request("POST", path+"/test", nil, true), 200)
requireStatus(t, f.request("POST", path+"/test?accountId=normal", nil, true), 200)
for _, id := range gateways {
requireStatus(t, f.request("DELETE", "/api/v1/proxies/"+id, nil, true), 200)
}
+4 -1
View File
@@ -41,6 +41,9 @@ 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}
}
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")
}
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.instances[p.ID]; ok {
@@ -50,7 +53,7 @@ func (m *Manager) Start(p model.Proxy) error {
if h == nil {
return errors.New("unsupported protocol")
}
if p.Type == "ssh" && p.SSH.TargetHostKey == "" {
if p.Type == "ssh" && !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)))
+2
View File
@@ -12,6 +12,7 @@ import (
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"`
@@ -55,6 +56,7 @@ type SSHConfig struct {
AuthorizedKeys []string `json:"authorizedKeys,omitempty"`
HostKey string `json:"hostKey,omitempty"`
TargetHostKey string `json:"targetHostKey,omitempty"`
IgnoreHostKey bool `json:"ignoreHostKey,omitempty"`
}
type DialFunc func(context.Context, string, string) (net.Conn, error)
+52 -22
View File
@@ -2,45 +2,53 @@ package model
import (
"encoding/json"
"errors"
"reflect"
)
// Target owns upstream identity and routing. It can be shared by many gateways.
type Target struct {
TargetCompression string `json:"targetCompression,omitempty"`
ID string `json:"id"`
OutboundID string `json:"outboundId,omitempty"`
Name string `json:"name"`
Group string `json:"group,omitempty"`
Type string `json:"type"`
TargetHost string `json:"targetHost"`
TargetPort int `json:"targetPort"`
TargetUser string `json:"targetUser"`
TargetPassword string `json:"targetPassword,omitempty"`
TargetTLS bool `json:"targetTLS"`
TargetCA string `json:"targetCA,omitempty"`
AuthSource string `json:"authSource,omitempty"`
Outbound Outbound `json:"outbound"`
SSH TargetSSH `json:"ssh"`
Accounts []TargetAccount `json:"accounts,omitempty"`
TargetCompression string `json:"targetCompression,omitempty"`
ID string `json:"id"`
OutboundID string `json:"outboundId,omitempty"`
Name string `json:"name"`
Group string `json:"group,omitempty"`
Type string `json:"type"`
TargetHost string `json:"targetHost"`
TargetPort int `json:"targetPort"`
TargetTLS bool `json:"targetTLS"`
TargetCA string `json:"targetCA,omitempty"`
AuthSource string `json:"authSource,omitempty"`
Outbound Outbound `json:"outbound"`
SSH TargetSSH `json:"ssh"`
}
type TargetSSH struct {
AuthMethod string `json:"authMethod,omitempty"`
PrivateKey string `json:"privateKey,omitempty"`
Passphrase string `json:"passphrase,omitempty"`
TargetHostKey string `json:"targetHostKey,omitempty"`
IgnoreHostKey bool `json:"ignoreHostKey,omitempty"`
}
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, TargetUser: p.TargetUser, TargetPassword: p.TargetPassword, TargetTLS: p.TargetTLS, TargetCA: p.TargetCA, AuthSource: p.AuthSource, Outbound: p.Outbound, SSH: TargetSSH{p.SSH.AuthMethod, p.SSH.PrivateKey, p.SSH.Passphrase, p.SSH.TargetHostKey}}
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.TargetCompression = t.TargetCompression
p.TargetID = t.ID
p.Group = t.Group
p.OutboundID = t.OutboundID
p.TargetHost, p.TargetPort, p.TargetUser, p.TargetPassword = t.TargetHost, t.TargetPort, t.TargetUser, t.TargetPassword
p.TargetHost, p.TargetPort = t.TargetHost, t.TargetPort
p.TargetTLS, p.TargetCA, p.AuthSource, p.Outbound = t.TargetTLS, t.TargetCA, t.AuthSource, t.Outbound
p.SSH.AuthMethod, p.SSH.PrivateKey, p.SSH.Passphrase, p.SSH.TargetHostKey = t.SSH.AuthMethod, t.SSH.PrivateKey, t.SSH.Passphrase, t.SSH.TargetHostKey
p.SSH.TargetHostKey, p.SSH.IgnoreHostKey = t.SSH.TargetHostKey, t.SSH.IgnoreHostKey
{
p.TargetUser, p.TargetPassword = "", ""
p.SSH.AuthMethod, p.SSH.PrivateKey, p.SSH.Passphrase = "", "", ""
for _, account := range t.Accounts {
if account.ID == p.TargetAccountID {
p = account.Apply(p)
break
}
}
}
return p
}
@@ -53,7 +61,14 @@ func (p Proxy) GatewayOnly() Proxy {
}
func TargetDefaults() Target { return TargetFromProxy(Defaults()) }
func (t *Target) Validate() error {
accounts := t.Accounts
if len(accounts) == 0 {
return errors.New("at least one target account is required")
}
p := t.Apply(Defaults())
if p.TargetUser == "" && len(accounts) > 0 {
p = accounts[0].Apply(p)
}
p.Name, p.Type = t.Name, t.Type
p.ListenPort = 1
p.AllowCIDRs = []string{"127.0.0.1"}
@@ -66,6 +81,10 @@ func (t *Target) Validate() error {
return err
}
*t = TargetFromProxy(p)
t.Accounts = accounts
if err := t.ValidateAccounts(); err != nil {
return err
}
return nil
}
func (t Target) Runtime() Proxy {
@@ -79,7 +98,15 @@ func (t Target) Runtime() Proxy {
func (t Target) Redacted() (Target, map[string]bool) {
p, flags := t.Runtime().Redacted()
delete(flags, "gatewayPassword")
return TargetFromProxy(p), flags
out := TargetFromProxy(p)
for _, account := range t.Accounts {
flags["account:"+account.ID+":password"] = account.Password != ""
flags["account:"+account.ID+":privateKey"] = account.PrivateKey != ""
flags["account:"+account.ID+":passphrase"] = account.Passphrase != ""
account.Password, account.PrivateKey, account.Passphrase = "", "", ""
out.Accounts = append(out.Accounts, account)
}
return out, flags
}
func MergeTarget(current Target, patch map[string]any) (Target, error) {
if err := exactFields(patch, reflect.TypeOf(Target{})); err != nil {
@@ -92,5 +119,8 @@ func MergeTarget(current Target, patch map[string]any) (Target, error) {
raw, _ = json.Marshal(base)
var out Target
err := json.Unmarshal(raw, &out)
if err == nil {
err = mergeAccountSecrets(current.Accounts, patch, &out)
}
return out, err
}
+22 -12
View File
@@ -100,11 +100,19 @@ func serverConfig(p model.Proxy) (*ssh.ServerConfig, error) {
return cfg, nil
}
func upstreamConfig(p model.Proxy) (*ssh.ClientConfig, error) {
key, _, _, rest, err := ssh.ParseAuthorizedKey([]byte(p.SSH.TargetHostKey))
if err != nil || len(bytes.TrimSpace(rest)) > 0 || !supportedKey(key) {
return nil, errors.New("target host key must be confirmed")
var callback ssh.HostKeyCallback
var err error
if p.SSH.IgnoreHostKey {
callback = ssh.InsecureIgnoreHostKey()
} else {
key, _, _, rest, parseErr := ssh.ParseAuthorizedKey([]byte(p.SSH.TargetHostKey))
err = parseErr
if err != nil || len(bytes.TrimSpace(rest)) > 0 || !supportedKey(key) {
return nil, errors.New("target host key must be confirmed")
}
callback = ssh.FixedHostKey(key)
}
cfg := &ssh.ClientConfig{Config: algorithms(), User: p.TargetUser, HostKeyAlgorithms: []string{ssh.KeyAlgoED25519, ssh.KeyAlgoRSASHA512, ssh.KeyAlgoRSASHA256}, HostKeyCallback: ssh.FixedHostKey(key)}
cfg := &ssh.ClientConfig{Config: algorithms(), User: p.TargetUser, HostKeyAlgorithms: []string{ssh.KeyAlgoED25519, ssh.KeyAlgoRSASHA512, ssh.KeyAlgoRSASHA256}, HostKeyCallback: callback}
switch p.SSH.AuthMethod {
case "password":
cfg.Auth = []ssh.AuthMethod{ssh.Password(p.TargetPassword)}
@@ -162,20 +170,22 @@ func (h *Handler) Test(ctx context.Context, p model.Proxy, dial model.DialFunc)
if err != nil {
return model.TestResult{}, err
}
var fingerprint string
verify := cfg.HostKeyCallback
cfg.HostKeyCallback = func(host string, addr net.Addr, key ssh.PublicKey) error {
if err := verify(host, addr, key); err != nil {
return err
}
fingerprint = ssh.FingerprintSHA256(key)
return nil
}
c, closeConn, err := connect(ctx, p, dial, cfg)
if err != nil {
return model.TestResult{}, err
}
defer closeConn()
defer c.Close()
result := model.TestResult{Version: string(c.ServerVersion())}
if p.SSH.TargetHostKey != "" {
key, _, _, _, parseErr := ssh.ParseAuthorizedKey([]byte(p.SSH.TargetHostKey))
if parseErr != nil || key == nil {
return model.TestResult{}, errors.New("invalid saved SSH target host key")
}
result.Fingerprint = ssh.FingerprintSHA256(key)
}
result := model.TestResult{Version: string(c.ServerVersion()), Fingerprint: fingerprint}
return result, nil
}
+11
View File
@@ -124,6 +124,17 @@ func TestProbeDoesNotAuthenticateAndPinIsEnforced(t *testing.T) {
if calls.Load() != before {
t.Fatal("sent credentials to mismatched host")
}
p.SSH.IgnoreHostKey = true
for _, saved := range []string{"", p.SSH.TargetHostKey, "invalid-key"} {
p.SSH.TargetHostKey = saved
got, err := New().Test(context.Background(), p, direct)
if err != nil {
t.Fatal(err)
}
if got.Fingerprint != ssh.FingerprintSHA256(signer.PublicKey()) {
t.Fatal("test did not report actual server fingerprint")
}
}
}
func startGateway(t *testing.T, p model.Proxy) (string, context.CancelFunc) {
+2 -2
View File
@@ -45,8 +45,8 @@ func TestLegacyMigrationAndIndependentTargetStorage(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if got.TargetID == "" || !got.Enabled || got.TargetPassword != p.TargetPassword || got.SSH.HostKey != p.SSH.HostKey || got.SSH.TargetHostKey != p.SSH.TargetHostKey || got.SSH.PrivateKey != p.SSH.PrivateKey {
t.Fatal("migration lost credentials, pins or enabled state")
if got.TargetID == "" || !got.Enabled || got.TargetAccountID != "" || got.TargetPassword != "" || got.SSH.HostKey != p.SSH.HostKey || got.SSH.TargetHostKey != p.SSH.TargetHostKey || got.SSH.PrivateKey != "" {
t.Fatal("old gateway must stay unbound without credential fallback")
}
var raw []byte
if err = s.db.QueryRow("SELECT config FROM gateways WHERE id='old'").Scan(&raw); err != nil {
+7 -1
View File
@@ -101,6 +101,9 @@ func (s *Store) saveGateway(tx *sql.Tx, p model.Proxy) error {
if target.Type != p.Type {
return errors.New("gateway and target protocols must match")
}
if p.TargetAccountID != "" && !target.HasAccount(p.TargetAccountID) {
return errors.New("target account not found")
}
blob, err := s.seal(p.GatewayOnly(), "proxy:"+p.ID)
if err != nil {
return err
@@ -124,7 +127,10 @@ func (s *Store) Get(id string) (model.Proxy, error) {
return p, err
}
t, err = s.resolveOutbound(t)
return t.Apply(p), err
if err != nil {
return p, err
}
return t.Apply(p), nil
}
func (s *Store) List() ([]model.Proxy, error) {
profiles, err := s.Outbounds()
+6
View File
@@ -33,6 +33,12 @@ func TestEncryptedPersistenceAndAdminReset(t *testing.T) {
p.GatewayPassword = "private-gateway-credential"
p.Outbound.Password = "private-socks-credential"
p.SSH.PrivateKey = "private-key-material"
p.TargetID, p.TargetAccountID = "target", "account"
target := model.TargetFromProxy(p)
target.Accounts = []model.TargetAccount{{ID: "account", Name: "Account", Username: "user", Password: p.TargetPassword, PrivateKey: p.SSH.PrivateKey}}
if err = s.SaveTarget(target); err != nil {
t.Fatal(err)
}
if err = s.Save(p); err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -8,7 +8,7 @@
「代理」菜单独立维护通用出口;目标服务通过下拉框选择代理或直接连接。多个目标可复用同一代理。代理被引用时禁止删除,修改前先停止所有关联网关;旧目标的内联出口自动迁移为独立加密配置。
1. 完整解压 ZIP,双击 `agentdb.exe`。
1. 完整解压 ZIP,双击 `start-dev.cmd`(开发模式,数据保存到解压目录的 `data`)。也可以直接双击 `agentdb.exe` 使用下面说明的 `%APPDATA%\AgentDB` 数据目录。两种入口的数据目录不同,请固定使用同一种入口。
2. 浏览器自动打开;开发阶段默认免登录,直接进入控制台。
3. 先添加目标服务,再创建网关并选择关联目标;多个网关可复用一个目标。以后双击即用。右上角支持浅色、深色、海蓝和跟随系统主题。
+12 -3
View File
@@ -4,14 +4,23 @@ $ErrorActionPreference = 'Stop'
$buildInfo = Get-AgentDBBuildInfo -Version $Version
Set-Location (Split-Path $PSScriptRoot -Parent)
$env:GOTOOLCHAIN = 'go1.25.0'
$env:GO111MODULE = 'on'
$goCommand = Get-Command go -ErrorAction SilentlyContinue
$goExecutable = if ($goCommand) { $goCommand.Source } else { 'C:\Program Files\Go\bin\go.exe' }
if (!(Test-Path -LiteralPath $goExecutable)) { throw 'Go is required' }
npm.cmd --prefix web ci --no-audit --no-fund
if ($LASTEXITCODE -ne 0) { throw 'Frontend dependency installation failed' }
npm.cmd --prefix web run build
if ($LASTEXITCODE -ne 0) { throw 'Frontend build failed' }
& $goExecutable test ./...
$keepPath = Join-Path $PWD 'web/dist/.gitkeep'
$keepBytes = [byte[]]@()
if (Test-Path -LiteralPath $keepPath) { $keepBytes = [IO.File]::ReadAllBytes($keepPath) }
try {
npm.cmd --prefix web run build
if ($LASTEXITCODE -ne 0) { throw 'Frontend build failed' }
} finally {
New-Item -ItemType Directory -Path (Split-Path $keepPath -Parent) -Force | Out-Null
[IO.File]::WriteAllBytes($keepPath, $keepBytes)
}
& $goExecutable test ./... -count=1 -timeout=120s
if ($LASTEXITCODE -ne 0) { throw 'Go tests failed' }
New-Item -ItemType Directory -Force bin | Out-Null
& $goExecutable build -trimpath -ldflags $buildInfo.LDFlags -o bin/agentdb.exe ./cmd/agentdb
+1
View File
@@ -2,6 +2,7 @@
set -eu
cd "$(dirname "$0")/.."
export GOTOOLCHAIN=go1.25.0
export GO111MODULE=on
version="${VERSION:-$(tr -d '\r\n' < VERSION)}"
if ! printf '%s\n' "$version" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$'; then
echo 'Invalid version' >&2
+34 -9
View File
@@ -1,5 +1,11 @@
param([string]$Version)
param([string]$Version, [ValidateSet('Fastest', 'Optimal')][string]$Compression = 'Fastest', [switch]$RebuildWeb)
$ErrorActionPreference = 'Stop'
$totalTimer = [Diagnostics.Stopwatch]::StartNew()
$phaseTimer = [Diagnostics.Stopwatch]::StartNew()
function Write-PhaseTime([string]$Name) {
Write-Host ('{0}: {1:N1}s' -f $Name, $phaseTimer.Elapsed.TotalSeconds)
$phaseTimer.Restart()
}
. (Join-Path $PSScriptRoot 'version.ps1')
$buildInfo = Get-AgentDBBuildInfo -Version $Version
$Version = $buildInfo.Version
@@ -7,33 +13,43 @@ $repo = Split-Path $PSScriptRoot -Parent
$goCommand = Get-Command go -ErrorAction SilentlyContinue
$goExecutable = if ($goCommand) { $goCommand.Source } else { 'C:\Program Files\Go\bin\go.exe' }
if (!(Test-Path -LiteralPath $goExecutable)) { throw 'Go is required' }
if (!(Get-Command npm.cmd -ErrorAction SilentlyContinue) -or !(Get-Command node -ErrorAction SilentlyContinue)) { throw 'Node.js and npm are required' }
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$name = "agentdb-$Version-windows-amd64"
$releaseRoot = Join-Path $repo "artifacts/releases/$stamp"
$stage = Join-Path $releaseRoot $name
$archive = Join-Path $releaseRoot "$name.zip"
$previous = @{}
foreach ($key in @('GOTOOLCHAIN','GOOS','GOARCH','CGO_ENABLED')) { $previous[$key] = [Environment]::GetEnvironmentVariable($key, 'Process') }
foreach ($key in @('GOTOOLCHAIN','GO111MODULE','GOOS','GOARCH','CGO_ENABLED','AGENTDB_NO_BROWSER')) { $previous[$key] = [Environment]::GetEnvironmentVariable($key, 'Process') }
$keepPath = Join-Path $repo 'web/dist/.gitkeep'
$hadKeep = Test-Path -LiteralPath $keepPath
$keepBytes = [byte[]]@()
if ($hadKeep) { $keepBytes = [IO.File]::ReadAllBytes($keepPath) }
Push-Location $repo
try {
$env:GOTOOLCHAIN = 'go1.25.0'
$env:GO111MODULE = 'on'
$env:AGENTDB_NO_BROWSER = '1'
$env:GOOS = 'windows'
$env:GOARCH = 'amd64'
$env:CGO_ENABLED = '0'
if (!(Test-Path web/node_modules/.bin/vite.cmd)) {
npm.cmd --prefix web ci --no-audit --no-fund
if ($LASTEXITCODE -ne 0) { throw 'Frontend dependency installation failed' }
}
npm.cmd --prefix web run build
$webArguments = @((Join-Path $PSScriptRoot 'build-web.mjs'))
if ($RebuildWeb) { $webArguments += '--force' }
node @webArguments
if ($LASTEXITCODE -ne 0) { throw 'Frontend build failed' }
Write-PhaseTime 'Frontend and dependencies'
New-Item -ItemType Directory -Path (Join-Path $stage 'docs') -Force | Out-Null
& $goExecutable build -trimpath -ldflags $buildInfo.LDFlags -o (Join-Path $stage 'agentdb.exe') ./cmd/agentdb
if ($LASTEXITCODE -ne 0) { throw 'AgentDB release build failed' }
& $goExecutable build -trimpath -ldflags '-s -w' -o (Join-Path $stage 'devcert.exe') ./cmd/devcert
if ($LASTEXITCODE -ne 0) { throw 'Certificate utility build failed' }
Write-PhaseTime 'Go build'
Copy-Item -LiteralPath packaging/windows/README.md -Destination (Join-Path $stage 'README.md')
Copy-Item -LiteralPath .env.example -Destination (Join-Path $stage '.env.example')
foreach ($file in @('development-plan.md','readonly.md','testing.md','kubernetes.md')) {
Copy-Item -LiteralPath start-dev.cmd -Destination (Join-Path $stage 'start-dev.cmd')
New-Item -ItemType Directory -Path (Join-Path $stage 'scripts') -Force | Out-Null
Copy-Item -LiteralPath scripts/start-dev.ps1 -Destination (Join-Path $stage 'scripts/start-dev.ps1')
foreach ($file in @('development-plan.md','readonly.md','testing.md','kubernetes.md','target-accounts.md')) {
Copy-Item -LiteralPath (Join-Path 'docs' $file) -Destination (Join-Path $stage "docs/$file")
}
$build = [ordered]@{ version = $Version; commit = $buildInfo.Commit; platform = 'windows-amd64'; builtAt = $buildInfo.BuildTime; go = (& $goExecutable version); revision = $buildInfo.Revision; workingTreeModified = $buildInfo.Dirty; embeddedWeb = $true }
@@ -45,13 +61,22 @@ try {
'{0} {1}' -f (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant(), $relative
}
[IO.File]::WriteAllText((Join-Path $stage 'SHA256SUMS.txt'), ($hashes -join "`n") + "`n", $utf8)
node (Join-Path $PSScriptRoot 'verify-package.mjs') $stage
if ($LASTEXITCODE -ne 0) { throw 'Package verification failed' }
Write-PhaseTime 'Metadata and verification'
Add-Type -AssemblyName System.IO.Compression.FileSystem
[IO.Compression.ZipFile]::CreateFromDirectory($stage, $archive, [IO.Compression.CompressionLevel]::Optimal, $true)
[IO.Compression.ZipFile]::CreateFromDirectory($stage, $archive, [IO.Compression.CompressionLevel]::$Compression, $true)
$digest = (Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash.ToLowerInvariant()
[IO.File]::WriteAllText("$archive.sha256", "$digest $name.zip`n", $utf8)
Write-Output $archive
Write-Output "SHA256: $digest"
Write-PhaseTime "ZIP ($Compression) and checksum"
Write-Host ('Total: {0:N1}s' -f $totalTimer.Elapsed.TotalSeconds)
} finally {
if ($hadKeep) {
New-Item -ItemType Directory -Path (Split-Path $keepPath -Parent) -Force | Out-Null
[IO.File]::WriteAllBytes($keepPath, [byte[]]$keepBytes)
}
foreach ($key in $previous.Keys) { [Environment]::SetEnvironmentVariable($key, $previous[$key], 'Process') }
Pop-Location
}
+31 -3
View File
@@ -1,8 +1,35 @@
$ErrorActionPreference = 'Stop'
$projectRoot = Split-Path -Parent $PSScriptRoot
$binary = Join-Path $projectRoot 'bin\agentdb.exe'
if (-not (Test-Path -LiteralPath $binary)) {
throw 'Build first: powershell -ExecutionPolicy Bypass -File scripts\build.ps1'
$buildScript = Join-Path $PSScriptRoot 'build.ps1'
$binary = Join-Path $projectRoot 'agentdb.exe'
$sourceMode = Test-Path -LiteralPath $buildScript
if ($sourceMode) {
$binary = Join-Path $projectRoot 'bin\agentdb.exe'
} elseif (-not (Test-Path -LiteralPath $binary)) {
throw 'agentdb.exe is missing. Extract the complete release ZIP again.'
}
Write-Host 'Stopping previous AgentDB service...'
$ownedPaths = @((Join-Path $projectRoot 'agentdb.exe'), (Join-Path $projectRoot 'bin\agentdb.exe'))
Get-CimInstance Win32_Process -Filter "Name = 'agentdb.exe'" | Where-Object { $_.ExecutablePath -in $ownedPaths } | ForEach-Object {
$previousProcess = Get-Process -Id $_.ProcessId -ErrorAction SilentlyContinue
if ($previousProcess) {
$previousProcess.Kill()
if (-not $previousProcess.WaitForExit(10000)) { throw 'Previous AgentDB process did not exit.' }
}
}
$dataDirectory = Join-Path $projectRoot 'data'
if (Test-Path -LiteralPath $dataDirectory) {
$backup = Join-Path $projectRoot ('artifacts\backups\' + (Get-Date -Format 'yyyyMMdd-HHmmss-fff'))
New-Item -ItemType Directory -Path $backup -Force | Out-Null
Copy-Item -LiteralPath $dataDirectory -Destination $backup -Recurse
Write-Host "Data backup: $backup"
}
if ($sourceMode) {
Write-Host 'Building latest AgentDB (Go and Node.js required)...'
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $buildScript
if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $binary)) {
throw 'Build failed. See the error above.'
}
}
# Use the same database and Windows-account-protected key on every launch.
$env:AGENTDB_DATA = Join-Path $projectRoot 'data\agentdb.sqlite'
@@ -15,5 +42,6 @@ $env:AGENTDB_SECURE_COOKIE = 'false'
$env:AGENTDB_TLS_CERT = ''
$env:AGENTDB_TLS_KEY = ''
Write-Host "SQLite: $env:AGENTDB_DATA"
Set-Location -LiteralPath $projectRoot
& $binary
exit $LASTEXITCODE
+4 -1
View File
@@ -1,3 +1,6 @@
@echo off
setlocal
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\start-dev.ps1"
if errorlevel 1 pause
set "startExit=%ERRORLEVEL%"
if not "%startExit%"=="0" if not "%AGENTDB_START_NO_PAUSE%"=="1" pause
exit /b %startExit%
+1
View File
@@ -0,0 +1 @@
+60 -56
View File
@@ -8,6 +8,7 @@ const protocolNames:Record<string,string>={mysql:'MySQL',mongodb:'MongoDB',ssh:'
import Connections from './Connections.vue';
import OutboundManager from './components/OutboundManager.vue';
import VersionInfo from './components/VersionInfo.vue';
import TargetAccounts from './components/TargetAccounts.vue';
import { outboundName, outboundDescription } from './domain/outbound';
type View = {
@@ -31,6 +32,7 @@ const page = ref("proxies"),
search = ref(""),
kind = ref("all"),
targetGroup = ref(""),
targetProtocol = ref(""),
rows = ref<View[]>([]),
targets = ref<any[]>([]);
const outbounds = ref<any[]>([]);
@@ -51,7 +53,7 @@ function showTest(row: View, started: number, result: Record<string, unknown>, s
testSucceeded.value = success;
testDetails.value = [
['名称', p.name], ['协议', p.type], ['目标地址', `${p.targetHost}:${p.targetPort}`],
['目标账号', p.targetUser || '未设置'],
['目标账号', p.targetUser || accountFor(row)?.username || '未设置'],
['连接方式', outboundDescription(p.outbound)],
['测试总耗时', `${Math.round(performance.now() - started)} ms`],
['测试时间', new Date().toLocaleString('zh-CN')],
@@ -88,7 +90,15 @@ const filtered = computed(() =>
),
);
const targetGroups = computed(() => [...new Set(targets.value.map(t => t.config.group).filter(Boolean))].sort());
const visibleTargets = computed(() => targets.value.filter(t => !targetGroup.value || (targetGroup.value === '__ungrouped__' ? !t.config.group : t.config.group === targetGroup.value)));
const targetAccountSelection = reactive<Record<string, string>>({});
function accountFor(row: View) {
const accounts = row.config.accounts || [];
const id = targetAccountSelection[row.config.id] || (accounts.length === 1 ? accounts[0].id : '');
return accounts.find((a: any) => a.id === id);
}
const visibleTargets = computed(() => targets.value.filter(t =>
(!targetProtocol.value || t.config.type === targetProtocol.value) &&
(!targetGroup.value || (targetGroup.value === '__ungrouped__' ? !t.config.group : t.config.group === targetGroup.value))));
const title = computed(() =>
page.value === 'outbounds' ? '代理' :
page.value === "connections" ? "拖拽连接" : page.value === "commands" ? "命令审计" : page.value === "targets" ? "目标服务" : page.value === "events"
@@ -142,8 +152,9 @@ async function connectServices(gatewayId:string,targetId:string){
if(gateway.runtime.running){ElMessage.error('请先停止网关');return;}
if(gateway.config.type!==target.config.type){ElMessage.error('网关和目标协议必须一致');return;}
if(gateway.config.targetId===targetId){ElMessage.info('已关联此目标');return;}
if((target.config.accounts || []).length !== 1){ElMessage.error('请在网关配置中选择目标账号');return;}
busy.value=true;
try{await api(`proxies/${gatewayId}`,'PUT',{targetId});await refresh();ElMessage.success('关联已保存');}catch(e){report(e)}finally{busy.value=false}
try{await api(`proxies/${gatewayId}`,'PUT',{targetId,targetAccountId:target.config.accounts[0].id});await refresh();ElMessage.success('关联已保存');}catch(e){report(e)}finally{busy.value=false}
}
async function signIn() {
busy.value = true;
@@ -179,6 +190,8 @@ function defaults(type = "mysql") {
name: "",
type,
targetId: "",
targetAccountId: "",
accounts: [{ id: crypto.randomUUID(), name: '', username: '', password: '', authMethod: 'password', privateKey: '', passphrase: '' }],
group: "",
outboundId: "",
targetCompression: "none",
@@ -261,6 +274,16 @@ async function save() {
.map((s: string) => s.trim())
.filter(Boolean);
if (p.type === "ssh") delete p.accessMode;
if (editingTarget.value) {
if (!p.accounts?.length) throw new Error('至少需要一个目标账号');
p.accounts = (p.accounts || []).map((account: any) => {
const saved = { ...account };
for (const key of ['password', 'privateKey', 'passphrase']) if (!saved[key]) delete saved[key];
if (saved.clearPassphrase) saved.passphrase = '';
delete saved.clearPassphrase;
return saved;
});
}
if (editID.value) {
for (const key of ["targetPassword", "gatewayPassword"])
if (!p[key]) delete p[key];
@@ -272,13 +295,14 @@ async function save() {
if (p.type === "ssh" && clearSaved.sshPassphrase) p.ssh.passphrase = "";
}
const allowed = editingTarget.value
? ["name", "group", "type", "targetHost", "targetPort", "targetUser", "targetPassword", "targetTLS", "targetCA", "targetCompression", "authSource", "outboundId", "ssh"]
: ["name", "type", "targetId", "listenHost", "listenPort", "allowCIDRs", "gatewayUser", "gatewayPassword", "accessMode", "maxConnections", "ssh"];
? ["name", "group", "type", "targetHost", "targetPort", "targetTLS", "targetCA", "targetCompression", "authSource", "outboundId", "ssh", "accounts"]
: ["name", "type", "targetId", "targetAccountId", "listenHost", "listenPort", "allowCIDRs", "gatewayUser", "gatewayPassword", "accessMode", "maxConnections", "ssh"];
for (const key of Object.keys(p)) if (!allowed.includes(key)) delete p[key];
for (const key of Object.keys(p.ssh)) {
if (editingTarget.value ? !["authMethod", "privateKey", "passphrase"].includes(key) : key !== "authorizedKeys") delete p.ssh[key];
if (editingTarget.value ? key !== "ignoreHostKey" : key !== "authorizedKeys") delete p.ssh[key];
}
if (!editingTarget.value && !p.targetId) throw new Error("请先选择目标服务;还没有目标时请先创建目标服务。");
if (!editingTarget.value && !p.targetAccountId) throw new Error('请选择目标账号');
const resource = editingTarget.value ? "targets" : "proxies";
await api(
editID.value ? `${resource}/${editID.value}` : resource,
@@ -309,7 +333,9 @@ async function action(row: View, op: string) {
},
);
busy.value = true;
const result = await api(`${page.value === "targets" ? "targets" : "proxies"}/${row.config.id}/${op}`, "POST", {});
const account = page.value === 'targets' && op === 'test' ? accountFor(row) : null;
if (page.value === 'targets' && op === 'test' && !account) throw new Error('请选择目标账号');
const result = await api(`${page.value === "targets" ? "targets" : "proxies"}/${row.config.id}/${op}${account ? '?accountId=' + encodeURIComponent(account.id) : ''}`, "POST", {});
if (op === "test") showTest(row, started, result, true);
else ElMessage.success(op === "start" ? "代理已启动" : "代理已停止");
await refresh();
@@ -370,6 +396,11 @@ function clearSecret() {
secret.loaded = false;
}
function openSecret(row: View) {
if (page.value === 'targets') {
const account = accountFor(row);
if (!account) { ElMessage.error('请选择目标账号'); return; }
row = { ...row, config: { ...row.config, targetAccountId: account.id, targetUser: account.username }, secretStatus: { ...row.secretStatus, targetPassword: !!row.secretStatus['account:'+account.id+':password'], sshPassphrase: !!row.secretStatus['account:'+account.id+':passphrase'] } };
}
selectedResource.value = page.value === "targets" ? "targets" : "proxies";
selected.value = row;
clearSecret();
@@ -380,7 +411,7 @@ async function reveal() {
const request = ++secretRequest;
try {
const result = await api(
`${selectedResource.value}/${selected.value!.config.id}/reveal-password`,
`${selectedResource.value}/${selected.value!.config.id}/reveal-password${selectedResource.value === 'targets' ? '?accountId=' + encodeURIComponent(selected.value!.config.targetAccountId) : ''}`,
"POST",
{ field: secret.field, password: secret.password },
);
@@ -635,11 +666,17 @@ onUnmounted(() => {
<Connections v-if="page === 'connections'" :gateways="rows" :targets="targets" :busy="busy" @connect="connectServices"/>
<OutboundManager v-if="page === 'outbounds'" :profiles="outbounds" :targets="targets" :gateways="rows" :api="api" @changed="refresh" />
<section v-if="page === 'targets'" class="target-grid">
<div class="target-filter"><el-select v-model="targetGroup" aria-label="目标分组" placeholder="全部分组" clearable><el-option label="未分组" value="__ungrouped__" /><el-option v-for="group in targetGroups" :key="group" :label="group" :value="group" /></el-select></div>
<div class="target-filter" style="display: flex; flex-wrap: wrap; gap: 12px">
<el-select v-model="targetProtocol" aria-label="目标协议" placeholder="全部协议" clearable style="width: 200px; max-width: 100%"><el-option v-for="(name, protocol) in protocolNames" :key="protocol" :label="name" :value="protocol" /></el-select>
<el-select v-model="targetGroup" aria-label="目标分组" placeholder="全部分组" clearable style="width: 200px; max-width: 100%"><el-option label="未分组" value="__ungrouped__" /><el-option v-for="group in targetGroups" :key="group" :label="group" :value="group" /></el-select>
</div>
<article v-for="target in visibleTargets" :key="target.config.id" class="target-card">
<div class="section-heading"><h3>{{ target.config.name }}</h3><span>{{ target.config.group || '未分组' }} · {{ target.config.type }}</span></div>
<p><code>{{ target.config.targetHost }}:{{ target.config.targetPort }}</code></p>
<p>{{ target.config.targetUser }} · {{ outboundName(target.config.outbound.mode) }}</p>
<p>{{ target.config.accounts?.length || 0 }} 个账号 · {{ outboundName(target.config.outbound.mode) }}</p>
<el-select :model-value="accountFor(target)?.id || ''" @update:model-value="targetAccountSelection[target.config.id] = $event" aria-label="测试及查看账号" placeholder="选择账号" style="width: 100%; margin-bottom: 12px">
<el-option v-for="account in target.config.accounts || []" :key="account.id" :value="account.id" :label="`${account.name} · ${account.username}`" />
</el-select>
<p>被 {{ target.gatewayIds.length }} 个网关引用</p>
<p v-if="target.config.type !== 'ssh'">传输压缩:{{ target.config.targetCompression === 'zlib' ? 'zlib(按目标支持情况协商)' : '关闭' }}</p>
<p v-if="target.config.type === 'ssh'">主机指纹:{{ target.config.ssh.targetHostKey ? '已确认' : '待确认' }}</p>
@@ -651,7 +688,7 @@ onUnmounted(() => {
<el-button type="danger" plain :disabled="target.gatewayIds.length > 0" @click="remove(target)">删除目标</el-button>
</div>
</article>
<div v-if="!visibleTargets.length" class="empty-guide"><h3>{{ targets.length ? '没有匹配的目标服务' : '先添加一个目标服务' }}</h3><p>目标独立保存连接地址和凭据,可供多个网关复用。</p><el-button @click="targets.length ? targetGroup = '' : openCreate()">{{ targets.length ? '清除筛选' : '新建目标' }}</el-button></div>
<div v-if="!visibleTargets.length" class="empty-guide"><h3>{{ targets.length ? '没有匹配的目标服务' : '先添加一个目标服务' }}</h3><el-button @click="targets.length ? (targetGroup = '', targetProtocol = '') : openCreate()">{{ targets.length ? '清除筛选' : '新建目标' }}</el-button></div>
</section>
<section v-if="page === 'commands'" class="table-card command-card">
<div class="table-toolbar command-filters">
@@ -1069,11 +1106,16 @@ onUnmounted(() => {
</el-radio-group>
</el-form-item>
<el-form-item v-if="!editingTarget" label="关联目标服务">
<el-select v-model="form.targetId" placeholder="选择已创建的同协议目标" filterable aria-label="关联目标服务">
<el-select v-model="form.targetId" @change="form.targetAccountId = targets.find(t => t.config.id === form.targetId)?.config.accounts?.length === 1 ? targets.find(t => t.config.id === form.targetId).config.accounts[0].id : ''" placeholder="选择已创建的同协议目标" filterable aria-label="关联目标服务">
<el-option v-for="target in targets.filter(t => t.config.type === form.type)" :key="target.config.id" :value="target.config.id" :label="`${target.config.group ? `[${target.config.group}] ` : ''}${target.config.name} · ${target.config.targetHost}:${target.config.targetPort}`" />
</el-select>
<p class="form-help">目标地址和凭据由目标服务管理,出口引用独立代理配置。</p>
</el-form-item>
<el-form-item v-if="!editingTarget && form.targetId" label="目标账号">
<el-select v-model="form.targetAccountId" aria-label="目标账号" style="width: 100%">
<el-option v-for="account in targets.find(t => t.config.id === form.targetId)?.config.accounts || []" :key="account.id" :value="account.id" :label="`${account.name} · ${account.username}`" />
</el-select>
</el-form-item>
<el-tabs v-model="tab">
<el-tab-pane v-if="editingTarget" label="目标服务" name="target">
<el-form-item label="代理出口">
@@ -1096,21 +1138,6 @@ onUnmounted(() => {
:max="65535"
/>
</el-form-item>
<el-form-item label="目标用户名">
<el-input v-model="form.targetUser" autocomplete="off" />
</el-form-item>
<el-form-item
v-if="form.type !== 'ssh' || form.ssh.authMethod === 'password'"
label="目标密码"
>
<el-input
v-model="form.targetPassword"
type="password"
show-password
:placeholder="editID ? '留空保留原值' : '输入目标密码'"
autocomplete="new-password"
/>
</el-form-item>
</div>
<el-form-item v-if="['mongodb', 'pgsql', 'rabbitmq'].includes(form.type)" :label="form.type === 'pgsql' ? '默认数据库' : form.type === 'rabbitmq' ? '连接测试 vhost' : '目标认证库'">
<el-input v-model="form.authSource" />
@@ -1141,42 +1168,19 @@ onUnmounted(() => {
</template>
<template v-else>
<p class="form-help">当前 SSH 传输库不支持协议压缩,SSH 目标保持未压缩连接。</p>
<el-form-item label="目标认证方式">
<el-radio-group v-model="form.ssh.authMethod">
<el-radio value="password">密码</el-radio>
<el-radio value="privatekey">私钥</el-radio>
</el-radio-group>
</el-form-item>
<template v-if="form.ssh.authMethod === 'privatekey'">
<el-form-item label="SSH 私钥">
<el-input
v-model="form.ssh.privateKey"
type="textarea"
:rows="5"
:placeholder="
editID ? '留空保留原私钥' : '粘贴 PEM / OpenSSH 私钥'
"
/>
</el-form-item>
<el-form-item label="私钥口令">
<el-input
v-model="form.ssh.passphrase"
type="password"
show-password
:placeholder="editID ? '留空保留原值' : '可选'"
/>
<el-checkbox v-if="editID" v-model="clearSaved.sshPassphrase">
清除已保存的私钥口令
</el-checkbox>
</el-form-item>
</template>
<el-alert
title="保存后在目标服务中探测并确认主机指纹,关联的 SSH 网关才能启动。"
type="info"
:closable="false"
/>
<el-checkbox v-model="form.ssh.ignoreHostKey">
忽略目标 SSH 主机指纹校验(不推荐)
</el-checkbox>
</template>
</el-tab-pane>
<el-tab-pane v-if="editingTarget" label="账号" name="accounts">
<TargetAccounts :accounts="form.accounts" :protocol="form.type" />
</el-tab-pane>
<el-tab-pane v-if="!editingTarget" label="网关与访问" name="gateway">
<template v-if="['mysql','mongodb'].includes(form.type)"><div class="setting-row">
<div>
+11 -3
View File
@@ -3,7 +3,7 @@ import { test, expect } from '@playwright/test';
test('目标分组保存、筛选与清空', async ({ page }) => {
await page.goto('/');
const session = await (await page.request.get('/api/v1/session')).json();
const created = await page.request.post('/api/v1/targets', { headers: { 'X-CSRF-Token': session.csrfToken }, data: { name: '分组测试目标', type: 'mysql', targetHost: 'localhost', targetPort: 3306, targetUser: 'test', targetPassword: 'test-password', group: '开发' } });
const created = await page.request.post('/api/v1/targets', { headers: { 'X-CSRF-Token': session.csrfToken }, data: { name: '分组测试目标', type: 'mysql', targetHost: 'localhost', targetPort: 3306, accounts: [{ id: 'test', name: 'Test', username: 'test', password: 'test-password' }], group: '开发' } });
expect(created.ok()).toBe(true);
await page.getByRole('button', { name: /目标服务/ }).first().click();
const card = page.locator('.target-card').filter({ hasText: '分组测试目标' });
@@ -17,10 +17,18 @@ test('目标分组保存、筛选与清空', async ({ page }) => {
await expect(drawer).not.toBeVisible();
await page.reload();
await page.getByRole('button', { name: /目标服务/ }).first().click();
await page.getByRole('combobox', { name: '目标分组', exact: true }).click();
const groupFilter = page.locator('.el-select').filter({ has: page.getByRole('combobox', { name: '目标分组', exact: true }) });
const protocolFilter = page.locator('.el-select').filter({ has: page.getByRole('combobox', { name: '目标协议', exact: true }) });
await groupFilter.click();
await page.getByRole('option', { name: '生产', exact: true }).click();
await expect(card).toBeVisible();
await page.getByRole('combobox', { name: '目标分组', exact: true }).click();
await protocolFilter.click();
await page.getByRole('option', { name: 'SSH', exact: true }).click();
await expect(card).not.toBeVisible();
await protocolFilter.click();
await page.getByRole('option', { name: 'MySQL', exact: true }).click();
await expect(card).toBeVisible();
await groupFilter.click();
await page.getByRole('option', { name: '未分组', exact: true }).click();
await expect(card).not.toBeVisible();
const target = (await created.json()).config;
+5 -3
View File
@@ -23,10 +23,12 @@ test('内置 K8s 出口配置、保存与版本信息', async ({ page }) => {
await page.getByRole('button', { name: '+ 新建目标' }).click();
await drawer.getByPlaceholder('例如:生产数据库 · 只读分析').fill('K8s MySQL');
await drawer.getByPlaceholder('数据库或服务器域名 / IP').fill('mysql.demo.svc');
await drawer.locator('.el-form-item').filter({ hasText: /^目标用户名/ }).getByRole('textbox').fill('dbuser');
await drawer.getByPlaceholder('输入目标密码').fill('test-only-password');
await drawer.getByRole('combobox', { name: '代理出口' }).click();
await page.getByRole('option', { name: 'K8s 测试代理', exact: true }).click();
await drawer.getByRole('tab', { name: '账号', exact: true }).click();
await drawer.getByRole('textbox', { name: '账号名称', exact: true }).fill('数据库账号');
await drawer.getByRole('textbox', { name: '登录用户名', exact: true }).fill('dbuser');
await drawer.getByRole('textbox', { name: '账号密码', exact: true }).fill('test-only-password');
await drawer.getByRole('button', { name: '保存配置' }).click();
await expect(drawer).not.toBeVisible();
const card = page.locator('.target-card').filter({ hasText: 'K8s MySQL' });
@@ -53,7 +55,7 @@ test('内置 K8s 出口配置、保存与版本信息', async ({ page }) => {
expect(saved.config.outbound).toMatchObject({ mode: 'k8s', namespace: 'demo', resource: 'service/mysql', kubeContext: 'test-cluster' });
expect(JSON.stringify(saved)).not.toContain('test-only-password');
const session = await (await page.request.get('/api/v1/session')).json();
const result = await page.request.post(`/api/v1/targets/${saved.config.id}/test`, { headers: { 'X-CSRF-Token': session.csrfToken } });
const result = await page.request.post(`/api/v1/targets/${saved.config.id}/test?accountId=${saved.config.accounts[0].id}`, { headers: { 'X-CSRF-Token': session.csrfToken } });
expect(result.ok()).toBe(false);
expect(JSON.stringify(await result.json())).toContain('k8s');
});
+7 -5
View File
@@ -37,8 +37,6 @@ async function target(page: Page, type: string, name: string, outbound: string |
await drawer.locator('.el-radio-button').filter({ hasText: new RegExp(`^${type}$`) }).click();
await drawer.getByPlaceholder('例如:生产数据库 · 只读分析').fill(name);
await drawer.getByPlaceholder('数据库或服务器域名 / IP').fill('127.0.0.1');
await drawer.locator('.el-form-item').filter({ hasText: /^目标用户名/ }).getByRole('textbox').fill('upstream');
await drawer.getByPlaceholder('输入目标密码').fill('target-test-password');
await expect(drawer.getByRole('switch', { name: '只读模式' })).toHaveCount(0);
if (['MySQL','MongoDB'].includes(type)) {
await expect(drawer.getByRole('switch', { name: '目标连接压缩' })).not.toBeChecked();
@@ -48,6 +46,10 @@ async function target(page: Page, type: string, name: string, outbound: string |
await drawer.getByRole('combobox', { name: '代理出口' }).click();
await page.getByRole('option', { name: outbound, exact: true }).click();
}
await drawer.getByRole('tab', { name: '账号', exact: true }).click();
await drawer.getByRole('textbox', { name: '账号名称', exact: true }).fill('普通账号');
await drawer.getByRole('textbox', { name: '登录用户名', exact: true }).fill('upstream');
await drawer.getByRole('textbox', { name: '账号密码', exact: true }).fill('target-test-password');
await drawer.getByRole('button', { name: '保存配置' }).click();
await expect(drawer).not.toBeVisible();
await expect(page.locator('.target-card').filter({ hasText: name })).toBeVisible();
@@ -121,12 +123,12 @@ test('默认免登录,目标复用与网关独立只读策略', async ({ page
await expect(secret.locator('.secret-result').first()).toContainText('upstream');
await expect(secret.locator('.secret-result').last()).toContainText('target-test-password');
await secret.getByRole('button', { name: 'Close this dialog' }).click();
await page.route('**/api/v1/targets/*/test', route => route.fulfill({ json: { version: '5.7.44', tls: true, compression: 'zlib', fingerprint: 'SHA256:test-fingerprint' } }));
await page.route('**/api/v1/targets/*/test?*', route => route.fulfill({ json: { version: '5.7.44', tls: true, compression: 'zlib', fingerprint: 'SHA256:test-fingerprint' } }));
await card.getByRole('button', { name: '测试连接' }).click();
const connection = page.getByRole('dialog', { name: '连接测试', exact: true });
for (const value of ['5.7.44', '已启用并验证证书', 'zlib', 'SHA256:test-fingerprint', 'new.internal:3306', '测试总耗时']) await expect(connection).toContainText(value);
await connection.getByRole('button', { name: '关闭', exact: true }).click();
await page.route('**/api/v1/targets/*/test', route => route.fulfill({ status: 422, json: { error: 'SOCKS5 authentication failed', stage: 'socks_auth_failed' } }));
await page.route('**/api/v1/targets/*/test?*', route => route.fulfill({ status: 422, json: { error: 'SOCKS5 authentication failed', stage: 'socks_auth_failed' } }));
await card.getByRole('button', { name: '测试连接' }).click();
await expect(connection).toContainText('连接失败');
await expect(connection).toContainText('socks_auth_failed');
@@ -163,7 +165,7 @@ test('MongoDB、SSH 目标独立保存 SOCKS5 凭据', async ({ page }) => {
const targets = await (await page.request.get('/api/v1/targets')).json();
for (const name of ['文档目标', '运维目标']) {
const view = targets.find((t: any) => t.config.name === name);
expect(view.secretStatus.targetPassword).toBe(true);
expect(view.secretStatus[`account:${view.config.accounts[0].id}:password`]).toBe(true);
expect(view.secretStatus.socksPassword).toBe(true);
expect(JSON.stringify(view)).not.toContain('socks-test-password');
expect(view.gatewayIds).toHaveLength(1);
+1 -1
View File
@@ -8,5 +8,5 @@ export default defineConfig({
"/healthz": "http://127.0.0.1:8080",
},
},
build: { chunkSizeWarningLimit: 1100 },
build: { chunkSizeWarningLimit: 1100, reportCompressedSize: false },
});