feat: create standalone native fnOS repository

This commit is contained in:
2026-10-02 15:19:09 +08:00
parent 69f3c30fab
commit 6725fc131d
492 changed files with 94023 additions and 3 deletions
@@ -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 }
+39
View File
@@ -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")
}
}
+919
View File
@@ -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")
}
}
+292
View File
@@ -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")
}
+102
View File
@@ -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 }