feat: add nftables forwarding mode

This commit is contained in:
sagitchu
2026-06-01 19:47:26 +08:00
parent 6bfa7f0166
commit a599f383f5
29 changed files with 5161 additions and 53 deletions
+37
View File
@@ -87,6 +87,7 @@ type Node struct {
UDPListenAddr string `gorm:"column:udp_listen_addr;type:varchar(100);not null;default:'[::]'"`
Inx int `gorm:"not null;default:0"`
IsRemote int `gorm:"column:is_remote;default:0"`
ForwardMode string `gorm:"column:forward_mode;type:varchar(20);not null;default:'agent'"`
RemoteURL sql.NullString `gorm:"column:remote_url;type:text"`
RemoteToken sql.NullString `gorm:"column:remote_token;type:text"`
RemoteConfig sql.NullString `gorm:"column:remote_config;type:text"`
@@ -95,6 +96,41 @@ type Node struct {
func (Node) TableName() string { return "node" }
type NodeSSHConfig struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
NodeID int64 `gorm:"column:node_id;not null;uniqueIndex"`
Host string `gorm:"type:varchar(255);not null"`
Port int `gorm:"not null;default:22"`
Username string `gorm:"type:varchar(100);not null"`
AuthType string `gorm:"column:auth_type;type:varchar(20);not null"`
Password sql.NullString `gorm:"type:text"`
PrivateKey sql.NullString `gorm:"column:private_key;type:text"`
Passphrase sql.NullString `gorm:"type:text"`
SudoMode string `gorm:"column:sudo_mode;type:varchar(20);not null;default:'none'"`
CreatedTime int64 `gorm:"column:created_time;not null"`
UpdatedTime int64 `gorm:"column:updated_time;not null"`
}
func (NodeSSHConfig) TableName() string { return "node_ssh_config" }
type NftRuleBinding struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
ForwardID int64 `gorm:"column:forward_id;not null;uniqueIndex:idx_nft_rule_binding_forward_node;index"`
NodeID int64 `gorm:"column:node_id;not null;uniqueIndex:idx_nft_rule_binding_forward_node;index"`
InPort int `gorm:"column:in_port;not null"`
Protocols string `gorm:"type:varchar(20);not null;default:'tcp,udp'"`
TargetAddr string `gorm:"column:target_addr;type:text;not null"`
BindIP string `gorm:"column:bind_ip;type:text;not null;default:''"`
RuleHash string `gorm:"column:rule_hash;type:varchar(128);not null;default:''"`
Status string `gorm:"type:varchar(20);not null;default:'pending'"`
LastError string `gorm:"column:last_error;type:text;not null;default:''"`
AppliedTime int64 `gorm:"column:applied_time;not null;default:0"`
CreatedTime int64 `gorm:"column:created_time;not null"`
UpdatedTime int64 `gorm:"column:updated_time;not null"`
}
func (NftRuleBinding) TableName() string { return "nft_rule_binding" }
type SpeedLimit struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
Name string `gorm:"type:varchar(100);not null"`
@@ -602,6 +638,7 @@ type NodeRecord struct {
UDPListenAddr string
InterfaceName string
IsRemote int
ForwardMode string
RemoteURL string
RemoteToken string
RemoteConfig string
+47 -1
View File
@@ -189,7 +189,6 @@ func Open(path string) (*Repository, error) {
_ = sqlDB.Close()
return nil, fmt.Errorf("prepare sqlite legacy schema: %w", err)
}
if err := autoMigrateAll(db); err != nil {
_ = sqlDB.Close()
return nil, fmt.Errorf("auto migrate: %w", err)
@@ -269,12 +268,20 @@ func (r *Repository) Close() error {
}
func autoMigrateAll(db *gorm.DB) error {
if db.Dialector.Name() == "sqlite" {
if err := prepareSQLiteNftablesColumns(db); err != nil {
return err
}
}
models := []interface{}{
&model.User{},
&model.UserQuota{},
&model.Forward{},
&model.ForwardPort{},
&model.Node{},
&model.NodeSSHConfig{},
&model.NftRuleBinding{},
&model.SpeedLimit{},
&model.StatisticsFlow{},
&model.Tunnel{},
@@ -418,6 +425,21 @@ func prepareSQLiteLegacyColumns(db *gorm.DB) error {
return nil
}
func prepareSQLiteNftablesColumns(db *gorm.DB) error {
if db == nil || db.Dialector.Name() != "sqlite" {
return nil
}
if !db.Migrator().HasTable(&model.Node{}) {
return nil
}
if !db.Migrator().HasColumn(&model.Node{}, "forward_mode") {
if err := db.Exec("ALTER TABLE node ADD COLUMN forward_mode varchar(20) NOT NULL DEFAULT 'agent'").Error; err != nil {
return err
}
}
return nil
}
func seedData(db *gorm.DB) {
var adminCount int64
if err := db.Model(&model.User{}).Where("id = ?", 1).Count(&adminCount).Error; err == nil && adminCount == 0 {
@@ -793,6 +815,28 @@ func (r *Repository) ListNodes() ([]map[string]interface{}, error) {
if err := r.db.Order("inx ASC, id ASC").Find(&nodes).Error; err != nil {
return nil, err
}
nodeIDs := make([]int64, 0, len(nodes))
for _, n := range nodes {
if defaultNodeForwardMode(n.ForwardMode) == "nftables" {
nodeIDs = append(nodeIDs, n.ID)
}
}
sshConfigByNodeID := make(map[int64]map[string]interface{}, len(nodeIDs))
if len(nodeIDs) > 0 {
var configs []model.NodeSSHConfig
if err := r.db.Where("node_id IN ?", nodeIDs).Find(&configs).Error; err != nil {
return nil, err
}
for _, cfg := range configs {
sshConfigByNodeID[cfg.NodeID] = map[string]interface{}{
"host": cfg.Host,
"port": cfg.Port,
"username": cfg.Username,
"authType": cfg.AuthType,
"sudoMode": cfg.SudoMode,
}
}
}
items := make([]map[string]interface{}, 0, len(nodes))
for _, n := range nodes {
items = append(items, map[string]interface{}{
@@ -810,11 +854,13 @@ func (r *Repository) ListNodes() ([]map[string]interface{}, error) {
"version": nullableString(n.Version),
"http": n.HTTP, "tls": n.TLS, "socks": n.Socks,
"status": n.Status, "isRemote": n.IsRemote,
"forwardMode": defaultNodeForwardMode(n.ForwardMode),
"remoteUrl": nullableString(n.RemoteURL),
"remoteToken": nullableString(n.RemoteToken),
"remoteConfig": nullableString(n.RemoteConfig),
"expiryReminderDismissed": n.ExpiryReminderDismissed,
"interfaceName": nullableString(n.InterfaceName),
"sshConfig": sshConfigByNodeID[n.ID],
})
}
return items, nil
@@ -233,7 +233,8 @@ func nodeRecordFromModel(n *model.Node) *model.NodeRecord {
Status: n.Status,
PortRange: n.Port,
TCPListenAddr: n.TCPListenAddr, UDPListenAddr: n.UDPListenAddr,
IsRemote: n.IsRemote,
IsRemote: n.IsRemote,
ForwardMode: defaultNodeForwardMode(n.ForwardMode),
}
if n.ServerIPV4.Valid {
rec.ServerIPv4 = strings.TrimSpace(n.ServerIPV4.String)
@@ -220,7 +220,7 @@ func (r *Repository) GetUserDefaultsForTunnel(userID int64) (flow int64, num int
return user.Flow, user.Num, user.ExpTime, user.FlowResetTime, nil
}
func (r *Repository) CreateNode(name, secret, serverIP string, serverIPV4, serverIPV6, port, interfaceName, version, remark, expiryTime, renewalCycle interface{}, httpFlag, tlsFlag, socksFlag int, now int64, status int, tcpAddr, udpAddr string, inx, isRemote int, remoteURL, remoteToken, remoteConfig, extraIPs interface{}) error {
func (r *Repository) CreateNode(name, secret, serverIP string, serverIPV4, serverIPV6, port, interfaceName, version, remark, expiryTime, renewalCycle interface{}, httpFlag, tlsFlag, socksFlag int, now int64, status int, tcpAddr, udpAddr string, inx, isRemote int, remoteURL, remoteToken, remoteConfig, extraIPs interface{}, forwardMode string) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
@@ -247,6 +247,7 @@ func (r *Repository) CreateNode(name, secret, serverIP string, serverIPV4, serve
UDPListenAddr: udpAddr,
Inx: inx,
IsRemote: isRemote,
ForwardMode: defaultNodeForwardMode(forwardMode),
RemoteURL: nullStringFromInterface(remoteURL),
RemoteToken: nullStringFromInterface(remoteToken),
RemoteConfig: nullStringFromInterface(remoteConfig),
@@ -266,31 +267,44 @@ func (r *Repository) GetNodeStatusFields(nodeID int64) (status, httpFlag, tlsFla
return node.Status, node.HTTP, node.TLS, node.Socks, nil
}
func (r *Repository) UpdateNode(id int64, name, serverIP string, serverIPV4, serverIPV6, port, interfaceName, extraIPs, remark, expiryTime, renewalCycle interface{}, httpFlag, tlsFlag, socksFlag int, tcpAddr, udpAddr string, now int64) error {
func (r *Repository) UpdateNode(id int64, name, serverIP string, serverIPV4, serverIPV6, port, interfaceName, extraIPs, remark, expiryTime, renewalCycle interface{}, forwardMode string, httpFlag, tlsFlag, socksFlag int, tcpAddr, udpAddr string, now int64) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
updates := map[string]interface{}{
"name": name,
"remark": nullStringFromInterface(remark),
"expiry_time": nullInt64FromInterface(expiryTime),
"renewal_cycle": nullStringFromInterface(renewalCycle),
"server_ip": serverIP,
"server_ip_v4": nullStringFromInterface(serverIPV4),
"server_ip_v6": nullStringFromInterface(serverIPV6),
"extra_ips": nullStringFromInterface(extraIPs),
"port": stringFromInterface(port),
"interface_name": nullStringFromInterface(interfaceName),
"http": httpFlag,
"tls": tlsFlag,
"socks": socksFlag,
"tcp_listen_addr": tcpAddr,
"udp_listen_addr": udpAddr,
"updated_time": sql.NullInt64{Int64: now, Valid: true},
"expiry_reminder_dismissed": 0,
}
if strings.TrimSpace(forwardMode) != "" {
updates["forward_mode"] = defaultNodeForwardMode(forwardMode)
}
return r.db.Model(&model.Node{}).
Where("id = ?", id).
Updates(map[string]interface{}{
"name": name,
"remark": nullStringFromInterface(remark),
"expiry_time": nullInt64FromInterface(expiryTime),
"renewal_cycle": nullStringFromInterface(renewalCycle),
"server_ip": serverIP,
"server_ip_v4": nullStringFromInterface(serverIPV4),
"server_ip_v6": nullStringFromInterface(serverIPV6),
"extra_ips": nullStringFromInterface(extraIPs),
"port": stringFromInterface(port),
"interface_name": nullStringFromInterface(interfaceName),
"http": httpFlag,
"tls": tlsFlag,
"socks": socksFlag,
"tcp_listen_addr": tcpAddr,
"udp_listen_addr": udpAddr,
"updated_time": sql.NullInt64{Int64: now, Valid: true},
"expiry_reminder_dismissed": 0,
}).Error
Updates(updates).Error
}
func defaultNodeForwardMode(mode string) string {
switch strings.TrimSpace(strings.ToLower(mode)) {
case "nftables":
return "nftables"
default:
return "agent"
}
}
func (r *Repository) GetNodeSecret(nodeID int64) (string, error) {
@@ -0,0 +1,239 @@
package repo
import (
"database/sql"
"errors"
"strings"
"go-backend/internal/store/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type NftSSHConfigInput struct {
Host string
Port int
Username string
AuthType string
Password string
PrivateKey string
Passphrase string
SudoMode string
}
type NftRuleBindingInput struct {
ForwardID int64
NodeID int64
InPort int
Protocols string
TargetAddr string
BindIP string
RuleHash string
Status string
LastError string
}
func (r *Repository) UpsertNodeSSHConfig(nodeID int64, cfg NftSSHConfigInput, now int64) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
if nodeID <= 0 {
return errors.New("node id is required")
}
port := cfg.Port
if port <= 0 {
port = 22
}
authType := strings.TrimSpace(strings.ToLower(cfg.AuthType))
if authType == "" {
authType = "private_key"
}
sudoMode := strings.TrimSpace(strings.ToLower(cfg.SudoMode))
if sudoMode == "" {
sudoMode = "none"
}
row := model.NodeSSHConfig{
NodeID: nodeID,
Host: strings.TrimSpace(cfg.Host),
Port: port,
Username: strings.TrimSpace(cfg.Username),
AuthType: authType,
Password: nullStringFromInterface(cfg.Password),
PrivateKey: nullStringFromInterface(cfg.PrivateKey),
Passphrase: nullStringFromInterface(cfg.Passphrase),
SudoMode: sudoMode,
CreatedTime: now,
UpdatedTime: now,
}
return r.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "node_id"}},
DoUpdates: clause.Assignments(map[string]interface{}{
"host": row.Host,
"port": row.Port,
"username": row.Username,
"auth_type": row.AuthType,
"password": row.Password,
"private_key": row.PrivateKey,
"passphrase": row.Passphrase,
"sudo_mode": row.SudoMode,
"updated_time": row.UpdatedTime,
}),
}).Create(&row).Error
}
func (r *Repository) GetNodeSSHConfig(nodeID int64) (*model.NodeSSHConfig, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
return r.GetNodeSSHConfigTx(r.db, nodeID)
}
func (r *Repository) GetNodeSSHConfigTx(tx *gorm.DB, nodeID int64) (*model.NodeSSHConfig, error) {
if tx == nil {
return nil, errors.New("database unavailable")
}
var cfg model.NodeSSHConfig
if err := tx.Where("node_id = ?", nodeID).First(&cfg).Error; err != nil {
return nil, normalizeNotFoundErr(err)
}
return &cfg, nil
}
func (r *Repository) DeleteNodeSSHConfig(nodeID int64) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
return r.db.Where("node_id = ?", nodeID).Delete(&model.NodeSSHConfig{}).Error
}
func (r *Repository) UpsertNftRuleBinding(input NftRuleBindingInput, now int64) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
row := model.NftRuleBinding{
ForwardID: input.ForwardID,
NodeID: input.NodeID,
InPort: input.InPort,
Protocols: defaultString(strings.TrimSpace(input.Protocols), "tcp,udp"),
TargetAddr: strings.TrimSpace(input.TargetAddr),
BindIP: strings.TrimSpace(input.BindIP),
RuleHash: strings.TrimSpace(input.RuleHash),
Status: defaultString(strings.TrimSpace(input.Status), "pending"),
LastError: strings.TrimSpace(input.LastError),
AppliedTime: now,
CreatedTime: now,
UpdatedTime: now,
}
return r.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "forward_id"}, {Name: "node_id"}},
DoUpdates: clause.Assignments(map[string]interface{}{
"in_port": row.InPort,
"protocols": row.Protocols,
"target_addr": row.TargetAddr,
"bind_ip": row.BindIP,
"rule_hash": row.RuleHash,
"status": row.Status,
"last_error": row.LastError,
"applied_time": row.AppliedTime,
"updated_time": row.UpdatedTime,
}),
}).Create(&row).Error
}
func (r *Repository) MarkNftRuleBindingError(forwardID, nodeID int64, message string, now int64) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
return r.db.Model(&model.NftRuleBinding{}).
Where("forward_id = ? AND node_id = ?", forwardID, nodeID).
Updates(map[string]interface{}{
"status": "error",
"last_error": strings.TrimSpace(message),
"updated_time": now,
}).Error
}
func (r *Repository) ListNftRuleBindingsByNode(nodeID int64) ([]model.NftRuleBinding, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var rows []model.NftRuleBinding
err := r.db.Where("node_id = ?", nodeID).Order("forward_id ASC").Find(&rows).Error
return rows, err
}
func (r *Repository) DeleteNftRuleBindingsByForward(forwardID int64) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
return r.db.Where("forward_id = ?", forwardID).Delete(&model.NftRuleBinding{}).Error
}
func (r *Repository) GetNodeForwardMode(nodeID int64) (string, error) {
if r == nil || r.db == nil {
return "", errors.New("repository not initialized")
}
return r.GetNodeForwardModeTx(r.db, nodeID)
}
func (r *Repository) GetNodeForwardModeTx(tx *gorm.DB, nodeID int64) (string, error) {
if tx == nil {
return "", errors.New("database unavailable")
}
var row struct {
ForwardMode sql.NullString `gorm:"column:forward_mode"`
}
err := tx.Model(&model.Node{}).Select("forward_mode").Where("id = ?", nodeID).First(&row).Error
if err != nil {
return "", normalizeNotFoundErr(err)
}
return defaultNodeForwardMode(row.ForwardMode.String), nil
}
func (r *Repository) ListActiveForwardsByNode(nodeID int64) ([]model.ForwardRecord, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var forwards []model.Forward
err := r.db.Model(&model.Forward{}).
Joins("JOIN forward_port ON forward_port.forward_id = forward.id").
Where("forward_port.node_id = ? AND forward.status = 1", nodeID).
Order("forward.id ASC").
Distinct("forward.*").
Find(&forwards).Error
if err != nil {
return nil, err
}
rows := make([]model.ForwardRecord, 0, len(forwards))
for _, f := range forwards {
rows = append(rows, model.ForwardRecord{
ID: f.ID,
UserID: f.UserID,
UserName: f.UserName,
Name: f.Name,
TunnelID: f.TunnelID,
RemoteAddr: f.RemoteAddr,
Strategy: f.Strategy,
Status: f.Status,
SpeedID: f.SpeedID,
MaxConn: f.MaxConn,
IPMaxConn: f.IPMaxConn,
IPSpeedID: f.IPSpeedID,
ProxyProtocol: f.ProxyProtocol,
})
}
for i := range rows {
if strings.TrimSpace(rows[i].Strategy) == "" {
rows[i].Strategy = "fifo"
}
}
return rows, nil
}
func defaultString(value, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return value
}
@@ -0,0 +1,214 @@
package repo
import (
"strings"
"testing"
"time"
)
func TestNftablesNodeModeSSHConfigAndBindingPersistence(t *testing.T) {
r, err := Open(":memory:")
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
now := time.Now().UnixMilli()
if err := r.CreateNode(
"nft-node",
"secret",
"203.0.113.10",
nil,
nil,
"10000-20000",
nil,
nil,
nil,
nil,
nil,
0,
0,
0,
now,
1,
"[::]",
"[::]",
1,
0,
nil,
nil,
nil,
nil,
"nftables",
); err != nil {
t.Fatalf("CreateNode: %v", err)
}
nodes, err := r.ListNodes()
if err != nil {
t.Fatalf("ListNodes: %v", err)
}
if len(nodes) != 1 {
t.Fatalf("expected 1 node, got %d", len(nodes))
}
nodeID := nodes[0]["id"].(int64)
if got := nodes[0]["forwardMode"]; got != "nftables" {
t.Fatalf("expected forwardMode nftables, got %#v", got)
}
cfg := NftSSHConfigInput{
Host: "203.0.113.10",
Port: 22,
Username: "root",
AuthType: "private_key",
PrivateKey: "encrypted-private-key",
SudoMode: "none",
}
if err := r.UpsertNodeSSHConfig(nodeID, cfg, now); err != nil {
t.Fatalf("UpsertNodeSSHConfig: %v", err)
}
loaded, err := r.GetNodeSSHConfig(nodeID)
if err != nil {
t.Fatalf("GetNodeSSHConfig: %v", err)
}
if loaded.Host != cfg.Host || loaded.Port != cfg.Port || loaded.Username != cfg.Username || loaded.AuthType != cfg.AuthType {
t.Fatalf("unexpected ssh config: %+v", loaded)
}
binding := NftRuleBindingInput{
ForwardID: 42,
NodeID: nodeID,
InPort: 24000,
Protocols: "tcp,udp",
TargetAddr: "198.51.100.20:443",
BindIP: "",
RuleHash: "hash-a",
Status: "applied",
LastError: "",
}
if err := r.UpsertNftRuleBinding(binding, now); err != nil {
t.Fatalf("UpsertNftRuleBinding: %v", err)
}
bindings, err := r.ListNftRuleBindingsByNode(nodeID)
if err != nil {
t.Fatalf("ListNftRuleBindingsByNode: %v", err)
}
if len(bindings) != 1 {
t.Fatalf("expected 1 binding, got %d", len(bindings))
}
if bindings[0].ForwardID != 42 || bindings[0].RuleHash != "hash-a" || bindings[0].Status != "applied" {
t.Fatalf("unexpected binding: %+v", bindings[0])
}
if err := r.MarkNftRuleBindingError(42, nodeID, "nft failed", now+1); err != nil {
t.Fatalf("MarkNftRuleBindingError: %v", err)
}
bindings, err = r.ListNftRuleBindingsByNode(nodeID)
if err != nil {
t.Fatalf("ListNftRuleBindingsByNode after error: %v", err)
}
if bindings[0].Status != "error" || !strings.Contains(bindings[0].LastError, "nft failed") {
t.Fatalf("expected error binding, got %+v", bindings[0])
}
if err := r.DeleteNftRuleBindingsByForward(42); err != nil {
t.Fatalf("DeleteNftRuleBindingsByForward: %v", err)
}
bindings, err = r.ListNftRuleBindingsByNode(nodeID)
if err != nil {
t.Fatalf("ListNftRuleBindingsByNode after delete: %v", err)
}
if len(bindings) != 0 {
t.Fatalf("expected no bindings after delete, got %+v", bindings)
}
}
func TestUpdateNodeWithoutForwardModePreservesExistingMode(t *testing.T) {
r, err := Open(":memory:")
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
now := time.Now().UnixMilli()
if err := r.CreateNode(
"nft-node",
"secret",
"203.0.113.11",
nil,
nil,
"10000-20000",
nil,
nil,
nil,
nil,
nil,
0,
0,
0,
now,
1,
"[::]",
"[::]",
1,
0,
nil,
nil,
nil,
nil,
"nftables",
); err != nil {
t.Fatalf("CreateNode: %v", err)
}
nodes, err := r.ListNodes()
if err != nil {
t.Fatalf("ListNodes: %v", err)
}
if len(nodes) != 1 {
t.Fatalf("expected 1 node, got %d", len(nodes))
}
nodeID := nodes[0]["id"].(int64)
if err := r.UpdateNode(
nodeID,
"nft-node-updated",
"203.0.113.11",
nil,
nil,
"10000-20000",
nil,
nil,
nil,
nil,
nil,
"",
0,
0,
0,
"[::]",
"[::]",
now+1,
); err != nil {
t.Fatalf("UpdateNode: %v", err)
}
gotNode, err := r.GetNodeRecord(nodeID)
if err != nil {
t.Fatalf("GetNodeRecord: %v", err)
}
if gotNode == nil {
t.Fatal("expected node record, got nil")
}
if gotNode.ForwardMode != "nftables" {
t.Fatalf("expected mapped forward mode nftables, got %q", gotNode.ForwardMode)
}
nodes, err = r.ListNodes()
if err != nil {
t.Fatalf("ListNodes after update: %v", err)
}
if got := nodes[0]["forwardMode"]; got != "nftables" {
t.Fatalf("expected persisted forwardMode nftables after update, got %#v", got)
}
}