chore: 初始提交 Fonu 1.0.2
- 公网反代:Nginx 状态卡片(版本 / 运行状态 / 配置校验 / 端口监听) - 部署设置第一、二梯队(HTTPS 开关、安全头、限流、连接数、仅中国大陆 IP) - 隧道检测兼容 frps v0.68 / v0.71(V2 与旧版端点) - 恢复被清空的 internal/frp/store.go、internal/api/ddns_handler.go、web/src/views/DdnsView.vue
This commit is contained in:
@@ -0,0 +1,724 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/validate"
|
||||
)
|
||||
|
||||
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"`
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
type UpdateInput struct {
|
||||
Upstream *string
|
||||
ListenPort *int
|
||||
ListenIPv4 *bool
|
||||
ListenIPv6 *bool
|
||||
Hosts *[]string
|
||||
HTTPSEnabled *bool
|
||||
HTTPRedirect *bool
|
||||
Enabled *bool
|
||||
Name *string
|
||||
Security *SecurityInput
|
||||
}
|
||||
|
||||
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, 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, 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
|
||||
}
|
||||
|
||||
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, 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)
|
||||
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
|
||||
|
||||
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.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
|
||||
}
|
||||
|
||||
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 = ?, updated_at = datetime('now')
|
||||
WHERE id = ?
|
||||
`, upstream, listenPort, boolInt(listenIPv4), boolInt(listenIPv6), boolInt(httpsEnabled), boolInt(httpRedirect), boolInt(enabled), name, securityJSON, 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 {
|
||||
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, 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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
var ownerID int64
|
||||
err := s.querier().QueryRowContext(ctx, `
|
||||
SELECT rule_id FROM proxy_hosts WHERE hostname = ? COLLATE NOCASE AND listen_port = ? LIMIT 1
|
||||
`, hostname, port).Scan(&ownerID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ownerID != excludeRuleID {
|
||||
return fmt.Errorf("前端域名 %s:%d 已被其他规则使用", hostname, port)
|
||||
}
|
||||
return 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 strings.Contains(err.Error(), "UNIQUE") {
|
||||
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 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,
|
||||
&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.CreatedAt = parseTime(createdAt)
|
||||
rule.UpdatedAt = parseTime(updatedAt)
|
||||
return rule, nil
|
||||
}
|
||||
|
||||
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/fonu/fonu/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/fonu/fonu/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