feat: create standalone native fnOS repository
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// ReplaceCustomEndpoints 用解析出的占用集合替换该规则在 proxy_custom_endpoints 里的记录。
|
||||
// 手动(custom)Nginx 规则保存或回滚成功后调用,使其占用对其它规则的保存校验可见。
|
||||
func (s *Store) ReplaceCustomEndpoints(ctx context.Context, ruleID int64, endpoints []Endpoint) error {
|
||||
if err := s.ClearCustomEndpoints(ctx, ruleID); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, endpoint := range endpoints {
|
||||
if _, err := s.querier().ExecContext(ctx, `
|
||||
INSERT INTO proxy_custom_endpoints(rule_id, hostname, listen_port)
|
||||
VALUES (?, ?, ?)
|
||||
`, ruleID, endpoint.Hostname, endpoint.Port); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearCustomEndpoints 清除该规则的手动配置占用(切回自动模式或删除规则时调用)
|
||||
func (s *Store) ClearCustomEndpoints(ctx context.Context, ruleID int64) error {
|
||||
_, err := s.querier().ExecContext(ctx, `DELETE FROM proxy_custom_endpoints WHERE rule_id = ?`, ruleID)
|
||||
return err
|
||||
}
|
||||
|
||||
// EnsureEndpointsAvailable 校验一批 (域名, 端口) 是否已被其它规则占用,
|
||||
// 用于手动 Nginx 配置的保存前检查(与表单规则的校验共用同一条判定)。
|
||||
func (s *Store) EnsureEndpointsAvailable(ctx context.Context, endpoints []Endpoint, excludeRuleID int64) error {
|
||||
for _, endpoint := range endpoints {
|
||||
if err := s.ensureHostAvailable(ctx, endpoint.Hostname, endpoint.Port, excludeRuleID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"github.com/havline/havline/internal/db"
|
||||
)
|
||||
|
||||
func newTestStore(t *testing.T) (*Store, *sql.DB) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
name := strings.ReplaceAll(t.Name(), "/", "_")
|
||||
conn, err := sql.Open("sqlite", fmt.Sprintf("file:%s?mode=memory&cache=shared&_pragma=foreign_keys(1)", name))
|
||||
if err != nil {
|
||||
t.Fatalf("open test db: %v", err)
|
||||
}
|
||||
conn.SetMaxOpenConns(1)
|
||||
if err := db.Migrate(ctx, conn, filepath.Join("..", "..", "migrations")); err != nil {
|
||||
t.Fatalf("migrate test db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
return NewStore(conn), conn
|
||||
}
|
||||
|
||||
func createTestRule(t *testing.T, store *Store, name, host string, port int) Rule {
|
||||
t.Helper()
|
||||
rule, err := store.Create(context.Background(), CreateInput{
|
||||
Upstream: "http://192.168.1.10:8080",
|
||||
ListenPort: port,
|
||||
ListenIPv4: true,
|
||||
Hosts: []string{host},
|
||||
Enabled: true,
|
||||
Name: name,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create rule %s: %v", name, err)
|
||||
}
|
||||
return rule
|
||||
}
|
||||
|
||||
func TestEnsureEndpointsAvailableNamesOwningRule(t *testing.T) {
|
||||
store, _ := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
owner := createTestRule(t, store, "官网", "a.com", 8011)
|
||||
|
||||
err := store.EnsureEndpointsAvailable(ctx, []Endpoint{{Hostname: "a.com", Port: 8011}}, 0)
|
||||
if err == nil {
|
||||
t.Fatal("expected conflict error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "规则「官网」") {
|
||||
t.Fatalf("expected owning rule name in error, got %q", err.Error())
|
||||
}
|
||||
|
||||
// 编辑自身不应被判为冲突
|
||||
if err := store.EnsureEndpointsAvailable(ctx, []Endpoint{{Hostname: "a.com", Port: 8011}}, owner.ID); err != nil {
|
||||
t.Fatalf("exclude self should pass, got %v", err)
|
||||
}
|
||||
// 同域名不同端口是合法虚拟主机
|
||||
if err := store.EnsureEndpointsAvailable(ctx, []Endpoint{{Hostname: "a.com", Port: 8012}}, 0); err != nil {
|
||||
t.Fatalf("different port should pass, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureEndpointsAvailableSeesCustomEndpoints(t *testing.T) {
|
||||
store, conn := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
custom := createTestRule(t, store, "手动规则", "b.com", 8011)
|
||||
if err := store.ReplaceCustomEndpoints(ctx, custom.ID, []Endpoint{{Hostname: "c.com", Port: 8013}}); err != nil {
|
||||
t.Fatalf("replace custom endpoints: %v", err)
|
||||
}
|
||||
|
||||
err := store.EnsureEndpointsAvailable(ctx, []Endpoint{{Hostname: "c.com", Port: 8013}}, 0)
|
||||
if err == nil {
|
||||
t.Fatal("expected conflict with custom endpoints")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "手动 Nginx 配置") {
|
||||
t.Fatalf("expected custom-rule hint in error, got %q", err.Error())
|
||||
}
|
||||
|
||||
// 删除规则后占用记录随外键级联清除
|
||||
if _, err := conn.ExecContext(ctx, `DELETE FROM proxy_rules WHERE id = ?`, custom.ID); err != nil {
|
||||
t.Fatalf("delete rule: %v", err)
|
||||
}
|
||||
if err := store.EnsureEndpointsAvailable(ctx, []Endpoint{{Hostname: "c.com", Port: 8013}}, 0); err != nil {
|
||||
t.Fatalf("cascade should free the binding, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearCustomEndpoints(t *testing.T) {
|
||||
store, _ := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
rule := createTestRule(t, store, "手动规则", "b.com", 8011)
|
||||
if err := store.ReplaceCustomEndpoints(ctx, rule.ID, []Endpoint{{Hostname: "c.com", Port: 8013}}); err != nil {
|
||||
t.Fatalf("replace custom endpoints: %v", err)
|
||||
}
|
||||
if err := store.ClearCustomEndpoints(ctx, rule.ID); err != nil {
|
||||
t.Fatalf("clear custom endpoints: %v", err)
|
||||
}
|
||||
if err := store.EnsureEndpointsAvailable(ctx, []Endpoint{{Hostname: "c.com", Port: 8013}}, 0); err != nil {
|
||||
t.Fatalf("cleared binding should be free, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStorePersistsExitsAndFiltersLocal(t *testing.T) {
|
||||
store, _ := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
rule, err := store.Create(ctx, CreateInput{
|
||||
Upstream: "http://192.168.1.10:8080",
|
||||
ListenPort: 8443,
|
||||
ListenIPv4: true,
|
||||
Hosts: []string{"cf.example.com"},
|
||||
Enabled: true,
|
||||
Exits: []string{ExitCloudflare},
|
||||
CFTunnelID: 7,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rule.CFTunnelID != 7 || len(rule.Exits) != 1 || rule.Exits[0] != ExitCloudflare {
|
||||
t.Fatalf("Cloudflare 出口未持久化:%#v", rule)
|
||||
}
|
||||
localRules, err := store.ListEnabledLocal(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(localRules) != 0 {
|
||||
t.Fatalf("仅 Cloudflare 规则不应生成本机 Nginx:%#v", localRules)
|
||||
}
|
||||
|
||||
enabled := true
|
||||
updated, err := store.Update(ctx, rule.ID, UpdateInput{
|
||||
Exits: &[]string{ExitLocal, ExitCloudflare},
|
||||
Enabled: &enabled,
|
||||
CFTunnelID: int64Ptr(7),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(updated.Exits) != 2 {
|
||||
t.Fatalf("本机 + Cloudflare 出口未保存:%#v", updated.Exits)
|
||||
}
|
||||
localRules, err = store.ListEnabledLocal(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(localRules) != 1 || localRules[0].ID != rule.ID {
|
||||
t.Fatalf("启用本机出口后应进入 Nginx 规则:%#v", localRules)
|
||||
}
|
||||
}
|
||||
|
||||
func int64Ptr(v int64) *int64 { return &v }
|
||||
@@ -0,0 +1,39 @@
|
||||
package proxy
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeExitsDefaultsToLocal(t *testing.T) {
|
||||
exits, tunnelID, err := normalizeExits(nil, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tunnelID != 0 || len(exits) != 1 || exits[0] != ExitLocal {
|
||||
t.Fatalf("默认出口应为 local:%#v tunnel=%d", exits, tunnelID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeExitsRequiresTunnelForCloudflare(t *testing.T) {
|
||||
if _, _, err := normalizeExits([]string{ExitCloudflare}, 0); err == nil {
|
||||
t.Fatal("Cloudflare 出口缺少隧道时应报错")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeExitsDeduplicatesAndSorts(t *testing.T) {
|
||||
exits, tunnelID, err := normalizeExits([]string{ExitCloudflare, ExitLocal, ExitCloudflare}, 12)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tunnelID != 12 || len(exits) != 2 || exits[0] != ExitCloudflare || exits[1] != ExitLocal {
|
||||
t.Fatalf("出口去重排序不符:%#v tunnel=%d", exits, tunnelID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuleUsesExitTreatsEmptyAsLocal(t *testing.T) {
|
||||
rule := Rule{}
|
||||
if !rule.UsesExit(ExitLocal) {
|
||||
t.Fatal("空出口应兼容为本机出口")
|
||||
}
|
||||
if rule.UsesExit(ExitCloudflare) {
|
||||
t.Fatal("空出口不应被视为 Cloudflare")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,919 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/havline/havline/internal/validate"
|
||||
)
|
||||
|
||||
const (
|
||||
ExitLocal = "local"
|
||||
ExitCloudflare = "cloudflare"
|
||||
)
|
||||
|
||||
type Host struct {
|
||||
ID int64 `json:"id"`
|
||||
Hostname string `json:"hostname"`
|
||||
ListenPort *int `json:"listen_port,omitempty"`
|
||||
}
|
||||
|
||||
type Rule struct {
|
||||
ID int64 `json:"id"`
|
||||
Domain string `json:"domain"`
|
||||
Upstream string `json:"upstream"`
|
||||
ListenPort int `json:"listen_port"`
|
||||
ListenIPv4 bool `json:"listen_ipv4"`
|
||||
ListenIPv6 bool `json:"listen_ipv6"`
|
||||
Hosts []Host `json:"hosts"`
|
||||
HTTPSEnabled bool `json:"https_enabled"`
|
||||
HTTPRedirect bool `json:"http_redirect"`
|
||||
Enabled bool `json:"enabled"`
|
||||
NginxMode string `json:"nginx_mode"`
|
||||
Name string `json:"name"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
Security SecurityConfig `json:"security"`
|
||||
Exits []string `json:"exits"`
|
||||
CFTunnelID int64 `json:"cf_tunnel_id"`
|
||||
SyncWarning string `json:"sync_warning,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type PortGroup struct {
|
||||
Port int
|
||||
Hostnames []string
|
||||
}
|
||||
|
||||
func (r Rule) PrimaryHost() string {
|
||||
if len(r.Hosts) > 0 {
|
||||
return r.Hosts[0].Hostname
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type Endpoint struct {
|
||||
Hostname string
|
||||
Port int
|
||||
}
|
||||
|
||||
func (r Rule) Endpoints() []Endpoint {
|
||||
seen := make(map[string]bool)
|
||||
var out []Endpoint
|
||||
for _, host := range r.Hosts {
|
||||
name := strings.ToLower(strings.TrimSpace(host.Hostname))
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
port := hostEffectivePort(host, r.ListenPort)
|
||||
key := fmt.Sprintf("%s:%d", name, port)
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
out = append(out, Endpoint{Hostname: name, Port: port})
|
||||
}
|
||||
if len(out) == 0 {
|
||||
domain := strings.ToLower(strings.TrimSpace(r.Domain))
|
||||
if domain != "" {
|
||||
out = append(out, Endpoint{Hostname: domain, Port: r.ListenPort})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (r Rule) Hostnames() []string {
|
||||
seen := make(map[string]bool)
|
||||
var names []string
|
||||
for _, host := range r.Hosts {
|
||||
name := strings.ToLower(strings.TrimSpace(host.Hostname))
|
||||
if name == "" || seen[name] {
|
||||
continue
|
||||
}
|
||||
seen[name] = true
|
||||
names = append(names, name)
|
||||
}
|
||||
if len(names) == 0 {
|
||||
domain := strings.ToLower(strings.TrimSpace(r.Domain))
|
||||
if domain != "" {
|
||||
names = append(names, domain)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// UsesExit 判断规则是否启用了指定出口;空值按旧数据兼容为本机出口。
|
||||
func (r Rule) UsesExit(exit string) bool {
|
||||
if len(r.Exits) == 0 {
|
||||
return exit == ExitLocal
|
||||
}
|
||||
for _, item := range r.Exits {
|
||||
if item == exit {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HasExit 判断出口列表是否包含指定出口;空值按旧数据兼容为本机出口。
|
||||
func HasExit(exits []string, exit string) bool {
|
||||
return Rule{Exits: exits}.UsesExit(exit)
|
||||
}
|
||||
|
||||
// LocalEnabled 判断规则当前是否需要生成本机 Nginx 配置。
|
||||
func (r Rule) LocalEnabled() bool {
|
||||
return r.Enabled && r.UsesExit(ExitLocal)
|
||||
}
|
||||
|
||||
// CloudflareEnabled 判断规则当前是否需要同步到 Cloudflare 隧道。
|
||||
func (r Rule) CloudflareEnabled() bool {
|
||||
return r.Enabled && r.UsesExit(ExitCloudflare) && r.CFTunnelID > 0
|
||||
}
|
||||
|
||||
func (r Rule) PortGroups() []PortGroup {
|
||||
groups := map[int][]string{}
|
||||
ports := make([]int, 0)
|
||||
for _, host := range r.Hosts {
|
||||
port := r.ListenPort
|
||||
if host.ListenPort != nil && *host.ListenPort > 0 {
|
||||
port = *host.ListenPort
|
||||
}
|
||||
if _, ok := groups[port]; !ok {
|
||||
ports = append(ports, port)
|
||||
}
|
||||
groups[port] = append(groups[port], host.Hostname)
|
||||
}
|
||||
|
||||
out := make([]PortGroup, 0, len(ports))
|
||||
for _, port := range ports {
|
||||
out = append(out, PortGroup{Port: port, Hostnames: groups[port]})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type CreateInput struct {
|
||||
Upstream string
|
||||
ListenPort int
|
||||
ListenIPv4 bool
|
||||
ListenIPv6 bool
|
||||
Hosts []string
|
||||
HTTPSEnabled bool
|
||||
HTTPRedirect bool
|
||||
Enabled bool
|
||||
Name string
|
||||
Security SecurityInput
|
||||
Exits []string
|
||||
CFTunnelID int64
|
||||
}
|
||||
|
||||
type UpdateInput struct {
|
||||
Upstream *string
|
||||
ListenPort *int
|
||||
ListenIPv4 *bool
|
||||
ListenIPv6 *bool
|
||||
Hosts *[]string
|
||||
HTTPSEnabled *bool
|
||||
HTTPRedirect *bool
|
||||
Enabled *bool
|
||||
Name *string
|
||||
Security *SecurityInput
|
||||
Exits *[]string
|
||||
CFTunnelID *int64
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
tx *sql.Tx
|
||||
}
|
||||
|
||||
func NewStore(db *sql.DB) *Store {
|
||||
return &Store{db: db}
|
||||
}
|
||||
|
||||
func NewStoreWithTx(tx *sql.Tx) *Store {
|
||||
return &Store{tx: tx}
|
||||
}
|
||||
|
||||
func (s *Store) querier() interface {
|
||||
QueryContext(context.Context, string, ...any) (*sql.Rows, error)
|
||||
QueryRowContext(context.Context, string, ...any) *sql.Row
|
||||
ExecContext(context.Context, string, ...any) (sql.Result, error)
|
||||
} {
|
||||
if s.tx != nil {
|
||||
return s.tx
|
||||
}
|
||||
return s.db
|
||||
}
|
||||
|
||||
func (s *Store) List(ctx context.Context) ([]Rule, error) {
|
||||
if err := s.cleanupOrphanRules(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := s.querier()
|
||||
rows, err := q.QueryContext(ctx, `
|
||||
SELECT id, upstream, listen_port, listen_ipv4, listen_ipv6, https_enabled, http_redirect, enabled, nginx_mode, name, sort_order, security_json, COALESCE(exits_json, '["local"]'), COALESCE(cf_tunnel_id, 0), created_at, updated_at
|
||||
FROM proxy_rules
|
||||
ORDER BY sort_order ASC, id ASC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var rules []Rule
|
||||
for rows.Next() {
|
||||
rule, err := scanRule(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rules = append(rules, rule)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.attachHosts(ctx, rules)
|
||||
}
|
||||
|
||||
func (s *Store) Get(ctx context.Context, id int64) (Rule, error) {
|
||||
row := s.querier().QueryRowContext(ctx, `
|
||||
SELECT id, upstream, listen_port, listen_ipv4, listen_ipv6, https_enabled, http_redirect, enabled, nginx_mode, name, sort_order, security_json, COALESCE(exits_json, '["local"]'), COALESCE(cf_tunnel_id, 0), created_at, updated_at
|
||||
FROM proxy_rules WHERE id = ?
|
||||
`, id)
|
||||
rule, err := scanRule(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Rule{}, fmt.Errorf("规则不存在")
|
||||
}
|
||||
if err != nil {
|
||||
return Rule{}, err
|
||||
}
|
||||
rules, err := s.attachHosts(ctx, []Rule{rule})
|
||||
if err != nil {
|
||||
return Rule{}, err
|
||||
}
|
||||
return rules[0], nil
|
||||
}
|
||||
|
||||
func (s *Store) Create(ctx context.Context, in CreateInput) (Rule, error) {
|
||||
upstream, hosts, err := validateCreateInput(in)
|
||||
if err != nil {
|
||||
return Rule{}, err
|
||||
}
|
||||
if err := s.ensureHostsAvailable(ctx, hosts, in.ListenPort, 0); err != nil {
|
||||
return Rule{}, err
|
||||
}
|
||||
|
||||
name, err := normalizeName(in.Name)
|
||||
if err != nil {
|
||||
return Rule{}, err
|
||||
}
|
||||
sortOrder, err := s.nextSortOrder(ctx)
|
||||
if err != nil {
|
||||
return Rule{}, err
|
||||
}
|
||||
|
||||
security, err := MergeSecurity(DefaultSecurityConfig(), in.Security)
|
||||
if err != nil {
|
||||
return Rule{}, err
|
||||
}
|
||||
securityJSON, err := security.ToJSON()
|
||||
if err != nil {
|
||||
return Rule{}, err
|
||||
}
|
||||
exits, cfTunnelID, err := normalizeExits(in.Exits, in.CFTunnelID)
|
||||
if err != nil {
|
||||
return Rule{}, err
|
||||
}
|
||||
exitsJSON, err := marshalExits(exits)
|
||||
if err != nil {
|
||||
return Rule{}, err
|
||||
}
|
||||
|
||||
res, err := s.querier().ExecContext(ctx, `
|
||||
INSERT INTO proxy_rules(upstream, listen_port, listen_ipv4, listen_ipv6, https_enabled, http_redirect, enabled, name, sort_order, security_json, exits_json, cf_tunnel_id, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
||||
`, upstream, in.ListenPort, boolInt(in.ListenIPv4), boolInt(in.ListenIPv6), boolInt(in.HTTPSEnabled), boolInt(in.HTTPRedirect), boolInt(in.Enabled), name, sortOrder, securityJSON, exitsJSON, cfTunnelID)
|
||||
if err != nil {
|
||||
return Rule{}, err
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return Rule{}, err
|
||||
}
|
||||
if err := s.replaceHosts(ctx, id, in.ListenPort, hosts); err != nil {
|
||||
_, _ = s.querier().ExecContext(ctx, `DELETE FROM proxy_rules WHERE id = ?`, id)
|
||||
return Rule{}, err
|
||||
}
|
||||
return s.Get(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Store) Update(ctx context.Context, id int64, in UpdateInput) (Rule, error) {
|
||||
current, err := s.Get(ctx, id)
|
||||
if err != nil {
|
||||
return Rule{}, err
|
||||
}
|
||||
|
||||
oldListenPort := current.ListenPort
|
||||
upstream := current.Upstream
|
||||
listenPort := current.ListenPort
|
||||
listenIPv4 := current.ListenIPv4
|
||||
listenIPv6 := current.ListenIPv6
|
||||
httpsEnabled := current.HTTPSEnabled
|
||||
httpRedirect := current.HTTPRedirect
|
||||
enabled := current.Enabled
|
||||
name := current.Name
|
||||
hosts := current.Hosts
|
||||
exits := current.Exits
|
||||
cfTunnelID := current.CFTunnelID
|
||||
|
||||
if in.Upstream != nil {
|
||||
upstream, err = validate.Upstream(*in.Upstream)
|
||||
if err != nil {
|
||||
return Rule{}, err
|
||||
}
|
||||
}
|
||||
if in.ListenPort != nil {
|
||||
if err := validate.ListenPort(*in.ListenPort); err != nil {
|
||||
return Rule{}, err
|
||||
}
|
||||
listenPort = *in.ListenPort
|
||||
}
|
||||
if in.ListenIPv4 != nil {
|
||||
listenIPv4 = *in.ListenIPv4
|
||||
}
|
||||
if in.ListenIPv6 != nil {
|
||||
listenIPv6 = *in.ListenIPv6
|
||||
}
|
||||
if !listenIPv4 && !listenIPv6 {
|
||||
return Rule{}, fmt.Errorf("至少需要启用 IPv4 或 IPv6 监听")
|
||||
}
|
||||
if in.HTTPSEnabled != nil {
|
||||
httpsEnabled = *in.HTTPSEnabled
|
||||
}
|
||||
if in.HTTPRedirect != nil {
|
||||
httpRedirect = *in.HTTPRedirect
|
||||
}
|
||||
if in.Enabled != nil {
|
||||
enabled = *in.Enabled
|
||||
}
|
||||
if in.Name != nil {
|
||||
name, err = normalizeName(*in.Name)
|
||||
if err != nil {
|
||||
return Rule{}, err
|
||||
}
|
||||
}
|
||||
if in.Exits != nil || in.CFTunnelID != nil {
|
||||
rawExits := exits
|
||||
if in.Exits != nil {
|
||||
rawExits = *in.Exits
|
||||
}
|
||||
rawTunnelID := cfTunnelID
|
||||
if in.CFTunnelID != nil {
|
||||
rawTunnelID = *in.CFTunnelID
|
||||
}
|
||||
exits, cfTunnelID, err = normalizeExits(rawExits, rawTunnelID)
|
||||
if err != nil {
|
||||
return Rule{}, err
|
||||
}
|
||||
}
|
||||
if in.Hosts != nil {
|
||||
hosts, err = parseHosts(*in.Hosts, listenPort)
|
||||
if err != nil {
|
||||
return Rule{}, err
|
||||
}
|
||||
if len(hosts) == 0 {
|
||||
return Rule{}, fmt.Errorf("至少需要一个前端域名")
|
||||
}
|
||||
}
|
||||
|
||||
security := current.Security
|
||||
if in.Security != nil {
|
||||
security, err = MergeSecurity(current.Security, *in.Security)
|
||||
if err != nil {
|
||||
return Rule{}, err
|
||||
}
|
||||
}
|
||||
securityJSON, err := security.ToJSON()
|
||||
if err != nil {
|
||||
return Rule{}, err
|
||||
}
|
||||
exitsJSON, err := marshalExits(exits)
|
||||
if err != nil {
|
||||
return Rule{}, err
|
||||
}
|
||||
|
||||
if in.ListenPort != nil && *in.ListenPort != oldListenPort && in.Hosts == nil {
|
||||
for _, host := range current.Hosts {
|
||||
if hostEffectivePort(host, oldListenPort) != oldListenPort {
|
||||
continue
|
||||
}
|
||||
if err := s.ensureHostAvailable(ctx, host.Hostname, listenPort, id); err != nil {
|
||||
return Rule{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_, err = s.querier().ExecContext(ctx, `
|
||||
UPDATE proxy_rules
|
||||
SET upstream = ?, listen_port = ?, listen_ipv4 = ?, listen_ipv6 = ?, https_enabled = ?, http_redirect = ?, enabled = ?, name = ?, security_json = ?, exits_json = ?, cf_tunnel_id = ?, updated_at = datetime('now')
|
||||
WHERE id = ?
|
||||
`, upstream, listenPort, boolInt(listenIPv4), boolInt(listenIPv6), boolInt(httpsEnabled), boolInt(httpRedirect), boolInt(enabled), name, securityJSON, exitsJSON, cfTunnelID, id)
|
||||
if err != nil {
|
||||
return Rule{}, err
|
||||
}
|
||||
if in.ListenPort != nil && *in.ListenPort != oldListenPort && in.Hosts == nil {
|
||||
if _, err := s.querier().ExecContext(ctx, `
|
||||
UPDATE proxy_hosts SET listen_port = ? WHERE rule_id = ? AND listen_port = ?
|
||||
`, listenPort, id, oldListenPort); err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
// 保存前校验与写入之间存在窗口,被并发规则抢占时按域名复检并给出带规则名的原因
|
||||
for _, host := range current.Hosts {
|
||||
if hostEffectivePort(host, oldListenPort) != oldListenPort {
|
||||
continue
|
||||
}
|
||||
if ownerErr := s.ensureHostAvailable(ctx, host.Hostname, listenPort, id); ownerErr != nil {
|
||||
return Rule{}, ownerErr
|
||||
}
|
||||
}
|
||||
}
|
||||
return Rule{}, err
|
||||
}
|
||||
}
|
||||
if in.Hosts != nil {
|
||||
if err := s.ensureHostsAvailable(ctx, hosts, listenPort, id); err != nil {
|
||||
return Rule{}, err
|
||||
}
|
||||
if err := s.replaceHosts(ctx, id, listenPort, hosts); err != nil {
|
||||
return Rule{}, err
|
||||
}
|
||||
}
|
||||
return s.Get(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Store) SetNginxMode(ctx context.Context, id int64, mode string) error {
|
||||
mode = strings.TrimSpace(mode)
|
||||
if mode != "auto" && mode != "custom" {
|
||||
return fmt.Errorf("无效的 nginx 模式")
|
||||
}
|
||||
res, err := s.querier().ExecContext(ctx, `
|
||||
UPDATE proxy_rules SET nginx_mode = ?, updated_at = datetime('now') WHERE id = ?
|
||||
`, mode, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return fmt.Errorf("规则不存在")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) Delete(ctx context.Context, id int64) error {
|
||||
res, err := s.querier().ExecContext(ctx, `DELETE FROM proxy_rules WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return fmt.Errorf("规则不存在")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) ListEnabled(ctx context.Context) ([]Rule, error) {
|
||||
rows, err := s.querier().QueryContext(ctx, `
|
||||
SELECT id, upstream, listen_port, listen_ipv4, listen_ipv6, https_enabled, http_redirect, enabled, nginx_mode, name, sort_order, security_json, COALESCE(exits_json, '["local"]'), COALESCE(cf_tunnel_id, 0), created_at, updated_at
|
||||
FROM proxy_rules WHERE enabled = 1 ORDER BY sort_order ASC, id ASC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var rules []Rule
|
||||
for rows.Next() {
|
||||
rule, err := scanRule(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rules = append(rules, rule)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.attachHosts(ctx, rules)
|
||||
}
|
||||
|
||||
// ListEnabledLocal 只返回需要生成本机 Nginx 配置的启用规则。
|
||||
func (s *Store) ListEnabledLocal(ctx context.Context) ([]Rule, error) {
|
||||
rules, err := s.ListEnabled(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]Rule, 0, len(rules))
|
||||
for _, rule := range rules {
|
||||
if rule.UsesExit(ExitLocal) {
|
||||
out = append(out, rule)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func validateCreateInput(in CreateInput) (string, []Host, error) {
|
||||
upstream, err := validate.Upstream(in.Upstream)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if err := validate.ListenPort(in.ListenPort); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if !in.ListenIPv4 && !in.ListenIPv6 {
|
||||
return "", nil, fmt.Errorf("至少需要启用 IPv4 或 IPv6 监听")
|
||||
}
|
||||
hosts, err := parseHosts(in.Hosts, in.ListenPort)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if len(hosts) == 0 {
|
||||
return "", nil, fmt.Errorf("至少需要一个前端域名")
|
||||
}
|
||||
return upstream, hosts, nil
|
||||
}
|
||||
|
||||
func (s *Store) cleanupOrphanRules(ctx context.Context) error {
|
||||
_, err := s.querier().ExecContext(ctx, `
|
||||
DELETE FROM proxy_rules
|
||||
WHERE id NOT IN (SELECT rule_id FROM proxy_hosts)
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
// isUniqueViolation 判断是否为 SQLite 唯一约束冲突(hostname + listen_port 撞车)
|
||||
func isUniqueViolation(err error) bool {
|
||||
return err != nil && strings.Contains(strings.ToUpper(err.Error()), "UNIQUE")
|
||||
}
|
||||
|
||||
func hostEffectivePort(host Host, ruleListenPort int) int {
|
||||
if host.ListenPort != nil && *host.ListenPort > 0 {
|
||||
return *host.ListenPort
|
||||
}
|
||||
return ruleListenPort
|
||||
}
|
||||
|
||||
func parseHosts(raw []string, ruleListenPort int) ([]Host, error) {
|
||||
seen := make(map[string]struct{})
|
||||
hosts := make([]Host, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
hostname, port, err := validate.FrontendAddress(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
effectivePort := ruleListenPort
|
||||
if port > 0 {
|
||||
effectivePort = port
|
||||
}
|
||||
key := fmt.Sprintf("%s:%d", strings.ToLower(hostname), effectivePort)
|
||||
if _, ok := seen[key]; ok {
|
||||
return nil, fmt.Errorf("前端域名 %s:%d 重复", hostname, effectivePort)
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
host := Host{Hostname: hostname}
|
||||
if port > 0 {
|
||||
host.ListenPort = &port
|
||||
}
|
||||
hosts = append(hosts, host)
|
||||
}
|
||||
return hosts, nil
|
||||
}
|
||||
|
||||
func (s *Store) ensureHostAvailable(ctx context.Context, hostname string, port int, excludeRuleID int64) error {
|
||||
owner, err := s.endpointOwner(ctx, "proxy_hosts", hostname, port, excludeRuleID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if owner != "" {
|
||||
return fmt.Errorf("域名 %s:%d 已被%s使用,请编辑现有规则或更换域名/端口", hostname, port, owner)
|
||||
}
|
||||
// 手动(custom)Nginx 规则的占用单独存表,其域名/端口不在 proxy_hosts 里,需一并检查
|
||||
owner, err = s.endpointOwner(ctx, "proxy_custom_endpoints", hostname, port, excludeRuleID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if owner != "" {
|
||||
return fmt.Errorf("域名 %s:%d 已被%s使用(该规则使用手动 Nginx 配置),请编辑现有规则或更换域名/端口", hostname, port, owner)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// endpointOwner 返回占用该 (域名, 端口) 的规则描述(如 规则「名称」);空闲时返回空串。
|
||||
// table 只传入本包内两张固定表名(proxy_hosts / proxy_custom_endpoints),不接受外部输入。
|
||||
// 规则描述优先用备注名,其次用该规则的首个域名,最后回落为规则 ID。
|
||||
func (s *Store) endpointOwner(ctx context.Context, table, hostname string, port int, excludeRuleID int64) (string, error) {
|
||||
var (
|
||||
ownerID int64
|
||||
ownerDesc string
|
||||
)
|
||||
query := `SELECT h.rule_id,
|
||||
COALESCE(NULLIF(TRIM(r.name), ''),
|
||||
(SELECT hostname FROM proxy_hosts WHERE rule_id = h.rule_id ORDER BY id LIMIT 1), '')
|
||||
FROM ` + table + ` h
|
||||
LEFT JOIN proxy_rules r ON r.id = h.rule_id
|
||||
WHERE h.hostname = ? COLLATE NOCASE AND h.listen_port = ? AND h.rule_id != ?
|
||||
LIMIT 1`
|
||||
err := s.querier().QueryRowContext(ctx, query, hostname, port, excludeRuleID).Scan(&ownerID, &ownerDesc)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if desc := strings.TrimSpace(ownerDesc); desc != "" {
|
||||
return fmt.Sprintf("规则「%s」", desc), nil
|
||||
}
|
||||
return fmt.Sprintf("规则 #%d", ownerID), nil
|
||||
}
|
||||
|
||||
func (s *Store) ensureHostsAvailable(ctx context.Context, hosts []Host, ruleListenPort int, excludeRuleID int64) error {
|
||||
for _, host := range hosts {
|
||||
port := hostEffectivePort(host, ruleListenPort)
|
||||
if err := s.ensureHostAvailable(ctx, host.Hostname, port, excludeRuleID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) replaceHosts(ctx context.Context, ruleID int64, ruleListenPort int, hosts []Host) error {
|
||||
if _, err := s.querier().ExecContext(ctx, `DELETE FROM proxy_hosts WHERE rule_id = ?`, ruleID); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, host := range hosts {
|
||||
port := hostEffectivePort(host, ruleListenPort)
|
||||
_, err := s.querier().ExecContext(ctx, `
|
||||
INSERT INTO proxy_hosts(rule_id, hostname, listen_port)
|
||||
VALUES (?, ?, ?)
|
||||
`, ruleID, host.Hostname, port)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
// 并发下可能绕过保存前校验:复检一次以给出带规则名的原因
|
||||
if ownerErr := s.ensureHostAvailable(ctx, host.Hostname, port, ruleID); ownerErr != nil {
|
||||
return ownerErr
|
||||
}
|
||||
return fmt.Errorf("域名 %s:%d 已被其它规则占用", host.Hostname, port)
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) attachHosts(ctx context.Context, rules []Rule) ([]Rule, error) {
|
||||
if len(rules) == 0 {
|
||||
return rules, nil
|
||||
}
|
||||
ids := make([]string, len(rules))
|
||||
args := make([]any, len(rules))
|
||||
for i, rule := range rules {
|
||||
ids[i] = "?"
|
||||
args[i] = rule.ID
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT id, rule_id, hostname, listen_port
|
||||
FROM proxy_hosts
|
||||
WHERE rule_id IN (` + strings.Join(ids, ",") + `)
|
||||
ORDER BY id ASC
|
||||
`
|
||||
rows, err := s.querier().QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
byRule := make(map[int64][]Host)
|
||||
for rows.Next() {
|
||||
var host Host
|
||||
var ruleID int64
|
||||
var listenPort sql.NullInt64
|
||||
if err := rows.Scan(&host.ID, &ruleID, &host.Hostname, &listenPort); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if listenPort.Valid {
|
||||
port := int(listenPort.Int64)
|
||||
host.ListenPort = &port
|
||||
}
|
||||
byRule[ruleID] = append(byRule[ruleID], host)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for i := range rules {
|
||||
rules[i].Hosts = byRule[rules[i].ID]
|
||||
rules[i].Domain = rules[i].PrimaryHost()
|
||||
}
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
type rowScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanRule(row rowScanner) (Rule, error) {
|
||||
var rule Rule
|
||||
var listenIPv4 int
|
||||
var listenIPv6 int
|
||||
var httpsEnabled int
|
||||
var httpRedirect int
|
||||
var enabled int
|
||||
var nginxMode string
|
||||
var securityJSON string
|
||||
var exitsJSON string
|
||||
var cfTunnelID int64
|
||||
var createdAt string
|
||||
var updatedAt string
|
||||
if err := row.Scan(
|
||||
&rule.ID,
|
||||
&rule.Upstream,
|
||||
&rule.ListenPort,
|
||||
&listenIPv4,
|
||||
&listenIPv6,
|
||||
&httpsEnabled,
|
||||
&httpRedirect,
|
||||
&enabled,
|
||||
&nginxMode,
|
||||
&rule.Name,
|
||||
&rule.SortOrder,
|
||||
&securityJSON,
|
||||
&exitsJSON,
|
||||
&cfTunnelID,
|
||||
&createdAt,
|
||||
&updatedAt,
|
||||
); err != nil {
|
||||
return Rule{}, err
|
||||
}
|
||||
rule.ListenIPv4 = listenIPv4 == 1
|
||||
rule.ListenIPv6 = listenIPv6 == 1
|
||||
rule.HTTPSEnabled = httpsEnabled == 1
|
||||
rule.HTTPRedirect = httpRedirect == 1
|
||||
rule.Enabled = enabled == 1
|
||||
if nginxMode == "" {
|
||||
nginxMode = "auto"
|
||||
}
|
||||
rule.NginxMode = nginxMode
|
||||
security, err := ParseSecurityJSON(securityJSON)
|
||||
if err != nil {
|
||||
return Rule{}, err
|
||||
}
|
||||
rule.Security = security
|
||||
rule.Exits = parseExits(exitsJSON)
|
||||
rule.CFTunnelID = cfTunnelID
|
||||
rule.CreatedAt = parseTime(createdAt)
|
||||
rule.UpdatedAt = parseTime(updatedAt)
|
||||
return rule, nil
|
||||
}
|
||||
|
||||
func normalizeExits(raw []string, cfTunnelID int64) ([]string, int64, error) {
|
||||
if len(raw) == 0 {
|
||||
raw = []string{ExitLocal}
|
||||
}
|
||||
seen := make(map[string]struct{}, len(raw))
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
exit := strings.ToLower(strings.TrimSpace(item))
|
||||
switch exit {
|
||||
case ExitLocal, ExitCloudflare:
|
||||
default:
|
||||
return nil, 0, fmt.Errorf("不支持的出口: %s", item)
|
||||
}
|
||||
if _, ok := seen[exit]; ok {
|
||||
continue
|
||||
}
|
||||
seen[exit] = struct{}{}
|
||||
out = append(out, exit)
|
||||
}
|
||||
sort.Strings(out)
|
||||
_, usesCloudflare := seen[ExitCloudflare]
|
||||
if usesCloudflare && cfTunnelID <= 0 {
|
||||
return nil, 0, fmt.Errorf("选择 Cloudflare 出口时需要指定隧道")
|
||||
}
|
||||
if !usesCloudflare {
|
||||
cfTunnelID = 0
|
||||
}
|
||||
return out, cfTunnelID, nil
|
||||
}
|
||||
|
||||
func marshalExits(exits []string) (string, error) {
|
||||
normalized, _, err := normalizeExits(exits, 1)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
data, err := json.Marshal(normalized)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func parseExits(raw string) []string {
|
||||
var exits []string
|
||||
if err := json.Unmarshal([]byte(raw), &exits); err != nil {
|
||||
return []string{ExitLocal}
|
||||
}
|
||||
normalized, _, err := normalizeExits(exits, 1)
|
||||
if err != nil {
|
||||
return []string{ExitLocal}
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func parseTime(v string) time.Time {
|
||||
t, err := time.Parse("2006-01-02 15:04:05", v)
|
||||
if err != nil {
|
||||
t, _ = time.Parse(time.RFC3339, v)
|
||||
}
|
||||
return t.UTC()
|
||||
}
|
||||
|
||||
func boolInt(v bool) int {
|
||||
if v {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func normalizeName(raw string) (string, error) {
|
||||
name := strings.TrimSpace(raw)
|
||||
if len(name) > 100 {
|
||||
return "", fmt.Errorf("名称不能超过 100 个字符")
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
func (s *Store) nextSortOrder(ctx context.Context) (int, error) {
|
||||
var maxOrder sql.NullInt64
|
||||
err := s.querier().QueryRowContext(ctx, `SELECT MAX(sort_order) FROM proxy_rules`).Scan(&maxOrder)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !maxOrder.Valid {
|
||||
return 0, nil
|
||||
}
|
||||
return int(maxOrder.Int64) + 1, nil
|
||||
}
|
||||
|
||||
func (s *Store) Reorder(ctx context.Context, ids []int64) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[int64]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
if id <= 0 {
|
||||
return fmt.Errorf("无效的规则 ID")
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
return fmt.Errorf("排序列表包含重复的规则 ID")
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
|
||||
var total int
|
||||
if err := s.querier().QueryRowContext(ctx, `SELECT COUNT(*) FROM proxy_rules`).Scan(&total); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(ids) != total {
|
||||
return fmt.Errorf("排序列表必须包含全部规则")
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
for index, id := range ids {
|
||||
res, err := tx.ExecContext(ctx, `UPDATE proxy_rules SET sort_order = ?, updated_at = datetime('now') WHERE id = ?`, index, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return fmt.Errorf("规则不存在")
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package proxy
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRuleEndpointsUsesListenPort(t *testing.T) {
|
||||
rule := Rule{
|
||||
ListenPort: 6893,
|
||||
Hosts: []Host{
|
||||
{Hostname: "xxx.com"},
|
||||
{Hostname: "xxx.com", ListenPort: intPtr(8081)},
|
||||
},
|
||||
}
|
||||
endpoints := rule.Endpoints()
|
||||
if len(endpoints) != 2 {
|
||||
t.Fatalf("expected 2 endpoints, got %d", len(endpoints))
|
||||
}
|
||||
if endpoints[0].Hostname != "xxx.com" || endpoints[0].Port != 6893 {
|
||||
t.Fatalf("unexpected first endpoint: %+v", endpoints[0])
|
||||
}
|
||||
if endpoints[1].Port != 8081 {
|
||||
t.Fatalf("expected custom port 8081, got %d", endpoints[1].Port)
|
||||
}
|
||||
}
|
||||
|
||||
func intPtr(v int) *int { return &v }
|
||||
@@ -0,0 +1,28 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseHostsAllowsSameHostnameDifferentPorts(t *testing.T) {
|
||||
hosts, err := parseHosts([]string{"example.com", "example.com:6893"}, 8011)
|
||||
if err != nil {
|
||||
t.Fatalf("parseHosts failed: %v", err)
|
||||
}
|
||||
if len(hosts) != 2 {
|
||||
t.Fatalf("expected 2 hosts, got %d", len(hosts))
|
||||
}
|
||||
if hostEffectivePort(hosts[0], 8011) != 8011 {
|
||||
t.Fatalf("expected default port 8011, got %d", hostEffectivePort(hosts[0], 8011))
|
||||
}
|
||||
if hostEffectivePort(hosts[1], 8011) != 6893 {
|
||||
t.Fatalf("expected custom port 6893, got %d", hostEffectivePort(hosts[1], 8011))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHostsRejectsDuplicateBinding(t *testing.T) {
|
||||
_, err := parseHosts([]string{"example.com", "example.com"}, 8011)
|
||||
if err == nil {
|
||||
t.Fatal("expected duplicate binding error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/havline/havline/internal/validate"
|
||||
)
|
||||
|
||||
type BasicAuthConfig struct {
|
||||
Enabled bool `json:"enabled,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
PasswordHash string `json:"password_hash,omitempty"` // bcrypt htpasswd line body
|
||||
HasPassword bool `json:"has_password,omitempty"` // API response only
|
||||
}
|
||||
|
||||
type RateLimitConfig struct {
|
||||
Enabled bool `json:"enabled,omitempty"`
|
||||
Rate int `json:"rate,omitempty"` // requests per second
|
||||
Burst int `json:"burst,omitempty"` // burst count
|
||||
}
|
||||
|
||||
type ConnLimitConfig struct {
|
||||
Enabled bool `json:"enabled,omitempty"`
|
||||
Max int `json:"max,omitempty"` // max concurrent connections per IP
|
||||
}
|
||||
|
||||
type SecurityConfig struct {
|
||||
IPBlacklist []string `json:"ip_blacklist,omitempty"`
|
||||
IPWhitelist []string `json:"ip_whitelist,omitempty"`
|
||||
IPWhitelistMode bool `json:"ip_whitelist_mode,omitempty"`
|
||||
ChinaOnly bool `json:"china_only,omitempty"`
|
||||
BasicAuth *BasicAuthConfig `json:"basic_auth,omitempty"`
|
||||
RateLimit *RateLimitConfig `json:"rate_limit,omitempty"`
|
||||
ConnLimit *ConnLimitConfig `json:"conn_limit,omitempty"`
|
||||
ProxySSLVerifyOff bool `json:"proxy_ssl_verify_off,omitempty"`
|
||||
ProxyHostUpstream bool `json:"proxy_host_upstream,omitempty"` // use $proxy_host instead of $host
|
||||
TLSMin13Only bool `json:"tls_min_13_only,omitempty"`
|
||||
SecurityHeaders bool `json:"security_headers,omitempty"`
|
||||
}
|
||||
|
||||
type SecurityInput struct {
|
||||
IPBlacklist []string
|
||||
IPWhitelist []string
|
||||
IPWhitelistMode *bool
|
||||
ChinaOnly *bool
|
||||
BasicAuth *BasicAuthInput
|
||||
RateLimit *RateLimitConfig
|
||||
ConnLimit *ConnLimitConfig
|
||||
ProxySSLVerifyOff *bool
|
||||
ProxyHostUpstream *bool
|
||||
TLSMin13Only *bool
|
||||
SecurityHeaders *bool
|
||||
}
|
||||
|
||||
type BasicAuthInput struct {
|
||||
Enabled *bool
|
||||
Username *string
|
||||
Password *string // plaintext on write only
|
||||
}
|
||||
|
||||
// DefaultPrivateCIDRs are always exempt from china_only restrictions.
|
||||
var DefaultPrivateCIDRs = []string{
|
||||
"10.0.0.0/8",
|
||||
"127.0.0.0/8",
|
||||
"172.16.0.0/12",
|
||||
"192.168.0.0/16",
|
||||
"::1/128",
|
||||
"fc00::/7",
|
||||
}
|
||||
|
||||
func ChinaBypassCIDRs(extra []string) []string {
|
||||
seen := make(map[string]bool, len(DefaultPrivateCIDRs)+len(extra))
|
||||
out := make([]string, 0, len(DefaultPrivateCIDRs)+len(extra))
|
||||
for _, cidr := range DefaultPrivateCIDRs {
|
||||
if !seen[cidr] {
|
||||
seen[cidr] = true
|
||||
out = append(out, cidr)
|
||||
}
|
||||
}
|
||||
for _, cidr := range extra {
|
||||
cidr = strings.TrimSpace(cidr)
|
||||
if cidr != "" && !seen[cidr] {
|
||||
seen[cidr] = true
|
||||
out = append(out, cidr)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func DefaultSecurityConfig() SecurityConfig {
|
||||
return SecurityConfig{}
|
||||
}
|
||||
|
||||
func ParseSecurityJSON(raw string) (SecurityConfig, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || raw == "{}" {
|
||||
return DefaultSecurityConfig(), nil
|
||||
}
|
||||
var cfg SecurityConfig
|
||||
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
|
||||
return SecurityConfig{}, fmt.Errorf("安全配置格式无效")
|
||||
}
|
||||
return cfg.Normalize(), nil
|
||||
}
|
||||
|
||||
func (c SecurityConfig) Normalize() SecurityConfig {
|
||||
if c.RateLimit != nil && c.RateLimit.Enabled {
|
||||
if c.RateLimit.Rate <= 0 {
|
||||
c.RateLimit.Rate = 10
|
||||
}
|
||||
if c.RateLimit.Burst <= 0 {
|
||||
c.RateLimit.Burst = 20
|
||||
}
|
||||
}
|
||||
if c.ConnLimit != nil && c.ConnLimit.Enabled {
|
||||
if c.ConnLimit.Max <= 0 {
|
||||
c.ConnLimit.Max = 20
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func RuleForAPI(rule Rule) Rule {
|
||||
rule.Security = rule.Security.ForAPI()
|
||||
return rule
|
||||
}
|
||||
|
||||
func RulesForAPI(rules []Rule) []Rule {
|
||||
out := make([]Rule, len(rules))
|
||||
for i, rule := range rules {
|
||||
out[i] = RuleForAPI(rule)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c SecurityConfig) ForAPI() SecurityConfig {
|
||||
out := c.Normalize()
|
||||
if out.BasicAuth != nil {
|
||||
hasPassword := out.BasicAuth.PasswordHash != ""
|
||||
out.BasicAuth = &BasicAuthConfig{
|
||||
Enabled: out.BasicAuth.Enabled,
|
||||
Username: out.BasicAuth.Username,
|
||||
HasPassword: hasPassword,
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c SecurityConfig) MarshalJSON() ([]byte, error) {
|
||||
type alias SecurityConfig
|
||||
return json.Marshal(alias(c.Normalize()))
|
||||
}
|
||||
|
||||
func (c SecurityConfig) ToJSON() (string, error) {
|
||||
b, err := c.MarshalJSON()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
func (c SecurityConfig) BasicAuthEnabled() bool {
|
||||
return c.BasicAuth != nil && c.BasicAuth.Enabled && c.BasicAuth.Username != "" && c.BasicAuth.PasswordHash != ""
|
||||
}
|
||||
|
||||
func (c SecurityConfig) RateLimitEnabled() bool {
|
||||
return c.RateLimit != nil && c.RateLimit.Enabled
|
||||
}
|
||||
|
||||
func (c SecurityConfig) ConnLimitEnabled() bool {
|
||||
return c.ConnLimit != nil && c.ConnLimit.Enabled
|
||||
}
|
||||
|
||||
func (c SecurityConfig) WhitelistEnabled() bool {
|
||||
return c.IPWhitelistMode && len(c.IPWhitelist) > 0
|
||||
}
|
||||
|
||||
func MergeSecurity(current SecurityConfig, in SecurityInput) (SecurityConfig, error) {
|
||||
out := current.Normalize()
|
||||
|
||||
if in.IPBlacklist != nil {
|
||||
out.IPBlacklist = in.IPBlacklist
|
||||
}
|
||||
if in.IPWhitelist != nil {
|
||||
out.IPWhitelist = in.IPWhitelist
|
||||
}
|
||||
if in.IPWhitelistMode != nil {
|
||||
out.IPWhitelistMode = *in.IPWhitelistMode
|
||||
}
|
||||
if in.ChinaOnly != nil {
|
||||
out.ChinaOnly = *in.ChinaOnly
|
||||
}
|
||||
if in.RateLimit != nil {
|
||||
out.RateLimit = in.RateLimit
|
||||
}
|
||||
if in.ConnLimit != nil {
|
||||
out.ConnLimit = in.ConnLimit
|
||||
}
|
||||
if in.ProxySSLVerifyOff != nil {
|
||||
out.ProxySSLVerifyOff = *in.ProxySSLVerifyOff
|
||||
}
|
||||
if in.ProxyHostUpstream != nil {
|
||||
out.ProxyHostUpstream = *in.ProxyHostUpstream
|
||||
}
|
||||
if in.TLSMin13Only != nil {
|
||||
out.TLSMin13Only = *in.TLSMin13Only
|
||||
}
|
||||
if in.SecurityHeaders != nil {
|
||||
out.SecurityHeaders = *in.SecurityHeaders
|
||||
}
|
||||
|
||||
if in.BasicAuth != nil {
|
||||
if in.BasicAuth.Enabled != nil {
|
||||
if out.BasicAuth == nil {
|
||||
out.BasicAuth = &BasicAuthConfig{}
|
||||
}
|
||||
out.BasicAuth.Enabled = *in.BasicAuth.Enabled
|
||||
}
|
||||
if in.BasicAuth.Username != nil {
|
||||
if out.BasicAuth == nil {
|
||||
out.BasicAuth = &BasicAuthConfig{}
|
||||
}
|
||||
out.BasicAuth.Username = strings.TrimSpace(*in.BasicAuth.Username)
|
||||
}
|
||||
if in.BasicAuth.Password != nil && strings.TrimSpace(*in.BasicAuth.Password) != "" {
|
||||
if out.BasicAuth == nil {
|
||||
out.BasicAuth = &BasicAuthConfig{}
|
||||
}
|
||||
hash, err := validate.BasicAuthPasswordHash(out.BasicAuth.Username, *in.BasicAuth.Password)
|
||||
if err != nil {
|
||||
return SecurityConfig{}, err
|
||||
}
|
||||
out.BasicAuth.PasswordHash = hash
|
||||
}
|
||||
}
|
||||
|
||||
return out, ValidateSecurity(out)
|
||||
}
|
||||
|
||||
func ValidateSecurity(cfg SecurityConfig) error {
|
||||
cfg = cfg.Normalize()
|
||||
if err := validate.IPOrCIDRList(cfg.IPBlacklist); err != nil {
|
||||
return fmt.Errorf("IP 黑名单:%w", err)
|
||||
}
|
||||
if err := validate.IPOrCIDRList(cfg.IPWhitelist); err != nil {
|
||||
return fmt.Errorf("IP 白名单:%w", err)
|
||||
}
|
||||
if cfg.IPWhitelistMode && len(cfg.IPWhitelist) == 0 {
|
||||
return fmt.Errorf("启用白名单模式时至少添加一条 IP 或 CIDR")
|
||||
}
|
||||
if cfg.BasicAuth != nil && cfg.BasicAuth.Enabled {
|
||||
if err := validate.Username(cfg.BasicAuth.Username); err != nil {
|
||||
return fmt.Errorf("Basic Auth:%w", err)
|
||||
}
|
||||
if cfg.BasicAuth.PasswordHash == "" {
|
||||
return fmt.Errorf("Basic Auth 需要设置密码")
|
||||
}
|
||||
}
|
||||
if cfg.RateLimit != nil && cfg.RateLimit.Enabled {
|
||||
if cfg.RateLimit.Rate < 1 || cfg.RateLimit.Rate > 10000 {
|
||||
return fmt.Errorf("限流速率无效(1-10000 次/秒)")
|
||||
}
|
||||
if cfg.RateLimit.Burst < 1 || cfg.RateLimit.Burst > 100000 {
|
||||
return fmt.Errorf("限流突发值无效(1-100000)")
|
||||
}
|
||||
}
|
||||
if cfg.ConnLimit != nil && cfg.ConnLimit.Enabled {
|
||||
if cfg.ConnLimit.Max < 1 || cfg.ConnLimit.Max > 10000 {
|
||||
return fmt.Errorf("连接数限制无效(1-10000)")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ParseIPListText(text string) []string {
|
||||
lines := strings.Split(text, "\n")
|
||||
out := make([]string, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
out = append(out, line)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func FormatIPListText(list []string) string {
|
||||
return strings.Join(list, "\n")
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/havline/havline/internal/validate"
|
||||
)
|
||||
|
||||
func TestForAPIStripsPasswordHashButRuntimeKeepsAuth(t *testing.T) {
|
||||
hash := "$2a$10$abcdefghijklmnopqrstuv"
|
||||
sec := SecurityConfig{
|
||||
BasicAuth: &BasicAuthConfig{
|
||||
Enabled: true,
|
||||
Username: "admin",
|
||||
PasswordHash: hash,
|
||||
},
|
||||
}
|
||||
if !sec.BasicAuthEnabled() {
|
||||
t.Fatal("expected runtime basic auth enabled")
|
||||
}
|
||||
api := sec.ForAPI()
|
||||
if api.BasicAuth.PasswordHash != "" {
|
||||
t.Fatal("expected API view to strip password hash")
|
||||
}
|
||||
if !api.BasicAuth.HasPassword {
|
||||
t.Fatal("expected has_password flag in API view")
|
||||
}
|
||||
if api.BasicAuthEnabled() {
|
||||
t.Fatal("API view must not be treated as nginx-ready basic auth config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeSecurityBasicAuthKeepsHash(t *testing.T) {
|
||||
hash, err := validate.BasicAuthPasswordHash("admin", "password123")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
current := SecurityConfig{
|
||||
BasicAuth: &BasicAuthConfig{
|
||||
Enabled: true,
|
||||
Username: "admin",
|
||||
PasswordHash: hash,
|
||||
},
|
||||
}
|
||||
enabled := true
|
||||
out, err := MergeSecurity(current, SecurityInput{
|
||||
BasicAuth: &BasicAuthInput{Enabled: &enabled, Username: strPtr("admin")},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.BasicAuth.PasswordHash != hash {
|
||||
t.Fatalf("expected hash preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSecurityWhitelistRequiresEntries(t *testing.T) {
|
||||
err := ValidateSecurity(SecurityConfig{IPWhitelistMode: true})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChinaBypassCIDRsIncludesDefaults(t *testing.T) {
|
||||
got := ChinaBypassCIDRs(nil)
|
||||
for _, want := range DefaultPrivateCIDRs {
|
||||
if !contains(got, want) {
|
||||
t.Fatalf("missing default %s in %v", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChinaBypassCIDRsMergesExtra(t *testing.T) {
|
||||
got := ChinaBypassCIDRs([]string{"203.0.113.0/24", "192.168.0.0/16"})
|
||||
if !contains(got, "203.0.113.0/24") {
|
||||
t.Fatalf("missing extra CIDR: %v", got)
|
||||
}
|
||||
if count(got, "192.168.0.0/16") != 1 {
|
||||
t.Fatalf("expected single 192.168.0.0/16 entry: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(ss []string, s string) bool {
|
||||
for _, v := range ss {
|
||||
if v == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func count(ss []string, s string) int {
|
||||
n := 0
|
||||
for _, v := range ss {
|
||||
if v == s {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func strPtr(s string) *string { return &s }
|
||||
Reference in New Issue
Block a user