feat(nodes): opt-in encryption at rest for the outbound node API token (#6186)

* node: encrypt outbound bearer token at rest

* fix(nodes): keep bearer tokens encrypted throughout

---------

Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com>
This commit is contained in:
n0ctal
2026-08-15 19:48:50 +05:00
committed by GitHub
parent 8e7fb144ee
commit 1793a9b8b4
9 changed files with 982 additions and 15 deletions
+118 -8
View File
@@ -17,6 +17,7 @@ import (
"sync"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/crypto/nodetoken"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
@@ -100,6 +101,23 @@ func (s *NodeService) FetchCertFingerprint(ctx context.Context, n *model.Node) (
return base64.StdEncoding.EncodeToString(sum[:]), nil
}
// decryptToken exposes plaintext to callers. Failures blank only this token
// and surface through LastError instead of dropping the node row.
func decryptToken(n *model.Node) {
if n == nil || n.ApiToken == "" {
return
}
pt, err := nodetoken.Decrypt(n.Id, n.ApiToken)
if err != nil {
n.ApiToken = ""
if n.LastError == "" {
n.LastError = "token decrypt failed: " + err.Error()
}
return
}
n.ApiToken = pt
}
func (s *NodeService) GetAll() ([]*model.Node, error) {
db := database.GetDB()
var nodes []*model.Node
@@ -107,6 +125,9 @@ func (s *NodeService) GetAll() ([]*model.Node, error) {
if err != nil || len(nodes) == 0 {
return nodes, err
}
for _, n := range nodes {
decryptToken(n)
}
type inboundRow struct {
Id int
@@ -333,6 +354,7 @@ func (s *NodeService) GetById(id int) (*model.Node, error) {
if err := db.Model(model.Node{}).Where("id = ?", id).First(n).Error; err != nil {
return nil, err
}
decryptToken(n)
return n, nil
}
@@ -429,7 +451,29 @@ func (s *NodeService) Create(n *model.Node) error {
return err
}
db := database.GetDB()
return db.Create(n).Error
if !nodetoken.Enabled() {
return db.Create(n).Error
}
plaintext := n.ApiToken
return db.Transaction(func(tx *gorm.DB) error {
// The id-bound ciphertext can only be produced after insertion. Never put
// plaintext in the initial tuple: PostgreSQL WAL would retain it.
n.ApiToken = ""
defer func() { n.ApiToken = plaintext }()
if err := tx.Create(n).Error; err != nil {
return err
}
enc, err := nodetoken.Encrypt(n.Id, plaintext)
if err != nil {
return err
}
if enc == plaintext {
return nil // off-mode / empty token: nothing to rewrite
}
// DB column gets ciphertext; the in-memory struct keeps plaintext so the
// create response echoes the same usable value GetById would return.
return tx.Model(model.Node{}).Where("id = ?", n.Id).Update("api_token", enc).Error
})
}
func (s *NodeService) CreateFromRequest(req *NodeMutationRequest) (*NodeView, error) {
@@ -456,6 +500,15 @@ func (s *NodeService) Update(id int, in *model.Node) error {
if err := db.Where("id = ?", id).First(existing).Error; err != nil {
return err
}
// Blank means keep the hidden stored token; non-blank values are encrypted.
apiToken := existing.ApiToken
if in.ApiToken != "" {
enc, eerr := nodetoken.Encrypt(id, in.ApiToken)
if eerr != nil {
return eerr
}
apiToken = enc
}
updates := map[string]any{
"name": in.Name,
"remark": in.Remark,
@@ -463,7 +516,7 @@ func (s *NodeService) Update(id int, in *model.Node) error {
"address": in.Address,
"port": in.Port,
"base_path": in.BasePath,
"api_token": in.ApiToken,
"api_token": apiToken,
"enable": in.Enable,
"allow_private_address": in.AllowPrivateAddress,
"tls_verify_mode": in.TlsVerifyMode,
@@ -508,7 +561,10 @@ func (s *NodeService) UpdateFromRequest(id int, req *NodeMutationRequest) error
case req.ClearApiToken:
apiToken = ""
case req.ApiToken != nil:
apiToken = *req.ApiToken
apiToken, err = nodetoken.Encrypt(id, *req.ApiToken)
if err != nil {
return err
}
}
if apiToken == "" && in.Enable && in.TlsVerifyMode != "mtls" {
return common.NewError("apiToken is required unless mtls is enabled")
@@ -529,12 +585,14 @@ func (s *NodeService) UpdateFromRequest(id int, req *NodeMutationRequest) error
"inbound_tags": string(inboundTagsJSON),
"outbound_tag": in.OutboundTag,
}
if err := db.Model(model.Node{}).Where("id = ?", id).Updates(updates).Error; err != nil {
if err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(model.Node{}).Where("id = ?", id).Updates(updates).Error; err != nil {
return err
}
return s.MarkNodeDirtyTx(tx, id)
}); err != nil {
return err
}
if dErr := s.MarkNodeDirty(id); dErr != nil {
logger.Warning("mark node dirty after update failed:", dErr)
}
if mgr := runtime.GetManager(); mgr != nil {
mgr.InvalidateNode(id)
}
@@ -590,6 +648,53 @@ func (s *NodeService) NodeFromRequestForCertificate(req *NodeMutationRequest) (*
return n, nil
}
// MigrateNodeTokensToActiveKey uses compare-and-swap to avoid clobbering live
// changes. Current-key rows are skipped; changed and skipped counts are returned.
func (s *NodeService) MigrateNodeTokensToActiveKey() (int, int, error) {
codec := nodetoken.Active()
if !codec.Enabled() {
return 0, 0, errors.New("node-token encryption is off; set NODE_TOKEN_ENCRYPTION=migration|required and a key first")
}
db := database.GetDB()
var nodes []*model.Node
if err := db.Model(model.Node{}).Order("id asc").Find(&nodes).Error; err != nil {
return 0, 0, err
}
changed, skipped := 0, 0
for _, n := range nodes {
old := n.ApiToken
if old == "" {
skipped++
continue
}
if codec.EncryptedWithActive(old) {
if _, err := codec.Decrypt(n.Id, old); err != nil {
return changed, skipped, fmt.Errorf("node %d validate active ciphertext: %w", n.Id, err)
}
skipped++
continue
}
plain, err := codec.Decrypt(n.Id, old) // plaintext passes through; old-key ciphertext is decrypted
if err != nil {
return changed, skipped, fmt.Errorf("node %d decrypt: %w", n.Id, err)
}
enc, err := codec.Encrypt(n.Id, plain)
if err != nil {
return changed, skipped, fmt.Errorf("node %d encrypt: %w", n.Id, err)
}
res := db.Model(model.Node{}).Where("id = ? AND api_token = ?", n.Id, old).Update("api_token", enc)
if res.Error != nil {
return changed, skipped, res.Error
}
if res.RowsAffected == 1 {
changed++
} else {
skipped++ // raced with a live update; a later run handles it
}
}
return changed, skipped, nil
}
func (s *NodeService) GetRemoteInboundOptions(ctx context.Context, n *model.Node) ([]runtime.RemoteInboundOption, error) {
if err := s.normalize(n); err != nil {
return nil, err
@@ -1128,7 +1233,12 @@ func (s *NodeService) probe(ctx context.Context, n *model.Node, proxyURL string)
return patch, err
}
if n.ApiToken != "" {
req.Header.Set("Authorization", "Bearer "+n.ApiToken)
token, derr := nodetoken.Decrypt(n.Id, n.ApiToken)
if derr != nil {
patch.LastError = derr.Error()
return patch, derr
}
req.Header.Set("Authorization", "Bearer "+token)
}
req.Header.Set("Accept", "application/json")