Files
nginx-ui/api/cluster/node_auth_test.go
0xJacky 9794228148 refactor(node): authenticate node traffic with HMAC instead of the plaintext secret
The shared node secret used to travel as an X-Node-Secret header on every
proxied request, so anyone able to observe a plain HTTP link between two
instances collected a reusable full-access credential. Requests from a node that
authenticates with the secret are now signed with an HMAC derived from it,
reusing the same signature envelope, covered components, clock skew and replay
cache as the paired Ed25519 path. The secret itself never leaves either end.

With the transport already proving knowledge of the secret, the upgrade to a
dedicated key pair no longer needs a proof of its own: /api/node/pair/upgrade
moves back behind the authentication middleware and keeps only the return
direction, where the target signs the credential it issues so the controller can
tell it reached the node holding the same secret before relying on it.

The node secret is therefore the single trust root, which makes the one-time
pairing code redundant: adopting a node means supplying its secret, and the
maintenance pass swaps the relationship onto a key pair on its own. Removing
that path also removes the last HTTPS requirement in the system. The node form
gains the Node Secret field it never exposed, without which no node could be
added from the UI at all.

The LegacyAuthEnabled and LegacyMCPAuthEnabled switches are gone as well. They
existed to tell an upgraded installation from a fresh one, which is not a
distinction this migration needs anymore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 13:40:26 +08:00

145 lines
5.5 KiB
Go

package cluster
import (
"bytes"
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/0xJacky/Nginx-UI/internal/nodeauth"
"github.com/0xJacky/Nginx-UI/model"
"github.com/0xJacky/Nginx-UI/settings"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
const (
nodeSecret = "shared-legacy-node-secret"
controllerInstanceID = "22222222-2222-4222-8222-222222222222"
)
func TestLegacyAuthenticatedRelationshipUpgradeReplacesExistingControllerCredential(t *testing.T) {
gin.SetMode(gin.TestMode)
database, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, database.AutoMigrate(&model.NodeControllerCredential{}))
model.Use(database)
originalInstanceID := settings.NodeSettings.InstanceID
originalSecret := settings.NodeSettings.Secret
settings.NodeSettings.InstanceID = "11111111-1111-4111-8111-111111111111"
settings.NodeSettings.Secret = nodeSecret
t.Cleanup(func() {
settings.NodeSettings.InstanceID = originalInstanceID
settings.NodeSettings.Secret = originalSecret
model.Use(nil)
})
upgrade := func() (upgradeLegacyPairingResponse, ed25519.PublicKey) {
publicKey, _, generateErr := ed25519.GenerateKey(rand.Reader)
require.NoError(t, generateErr)
payload, marshalErr := json.Marshal(upgradeLegacyPairingRequest{
ControllerInstanceID: controllerInstanceID,
PublicKey: base64.RawURLEncoding.EncodeToString(publicKey),
})
require.NoError(t, marshalErr)
recorder := serveUpgrade(t, payload, legacyNodePrincipal)
require.Equal(t, http.StatusCreated, recorder.Code)
var response upgradeLegacyPairingResponse
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response))
assert.Equal(t, settings.NodeSettings.InstanceID, response.TargetInstanceID)
return response, publicKey
}
first, firstKey := upgrade()
second, _ := upgrade()
assert.NotEqual(t, first.CredentialID, second.CredentialID)
// The response proves the target holds the same secret, so the controller
// can trust the credential before it starts relying on it.
assert.NoError(t, nodeauth.VerifyUpgradeConfirmation([]byte(nodeSecret), controllerInstanceID,
firstKey, first.CredentialID, first.TargetInstanceID, first.Confirmation))
assert.Error(t, nodeauth.VerifyUpgradeConfirmation([]byte("another-secret"), controllerInstanceID,
firstKey, first.CredentialID, first.TargetInstanceID, first.Confirmation))
var credentials []model.NodeControllerCredential
require.NoError(t, database.Unscoped().Order("created_at ASC").Find(&credentials).Error)
require.Len(t, credentials, 2)
assert.Equal(t, model.NodeCredentialStatusRevoked, credentials[0].Status)
assert.NotNil(t, credentials[0].RevokedAt)
assert.Equal(t, model.NodeCredentialStatusActive, credentials[1].Status)
assert.Nil(t, credentials[1].RevokedAt)
}
func TestLegacyUpgradeRequiresSharedSecretAuthentication(t *testing.T) {
gin.SetMode(gin.TestMode)
database, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, database.AutoMigrate(&model.NodeControllerCredential{}))
model.Use(database)
originalInstanceID := settings.NodeSettings.InstanceID
originalSecret := settings.NodeSettings.Secret
settings.NodeSettings.InstanceID = "11111111-1111-4111-8111-111111111111"
settings.NodeSettings.Secret = nodeSecret
t.Cleanup(func() {
settings.NodeSettings.InstanceID = originalInstanceID
settings.NodeSettings.Secret = originalSecret
model.Use(nil)
})
publicKey, _, err := ed25519.GenerateKey(rand.Reader)
require.NoError(t, err)
payload, err := json.Marshal(upgradeLegacyPairingRequest{
ControllerInstanceID: controllerInstanceID,
PublicKey: base64.RawURLEncoding.EncodeToString(publicKey),
})
require.NoError(t, err)
t.Run("unauthenticated", func(t *testing.T) {
assert.Equal(t, http.StatusForbidden, serveUpgrade(t, payload, nil).Code)
})
t.Run("paired_principal", func(t *testing.T) {
// A controller that already holds its own key pair has nothing to
// upgrade, and must not be able to mint a replacement credential.
assert.Equal(t, http.StatusForbidden, serveUpgrade(t, payload, func(c *gin.Context) {
c.Set(nodeauth.GinPrincipalKey, &nodeauth.Principal{AuthMethod: model.NodeAuthMethodPaired})
}).Code)
})
t.Run("no_configured_secret", func(t *testing.T) {
settings.NodeSettings.Secret = ""
defer func() { settings.NodeSettings.Secret = nodeSecret }()
assert.Equal(t, http.StatusForbidden, serveUpgrade(t, payload, legacyNodePrincipal).Code)
})
var count int64
require.NoError(t, database.Unscoped().Model(&model.NodeControllerCredential{}).Count(&count).Error)
assert.Zero(t, count, "a rejected upgrade must not issue a credential")
}
// legacyNodePrincipal stands in for the middleware, which authenticates a
// shared-secret controller by verifying the signature on its request.
func legacyNodePrincipal(c *gin.Context) {
c.Set(nodeauth.GinPrincipalKey, &nodeauth.Principal{AuthMethod: model.NodeAuthMethodLegacy})
}
func serveUpgrade(t *testing.T, payload []byte, authenticate gin.HandlerFunc) *httptest.ResponseRecorder {
t.Helper()
router := gin.New()
if authenticate != nil {
router.Use(authenticate)
}
router.POST("/pair/upgrade", UpgradeLegacyPairing)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/pair/upgrade", bytes.NewReader(payload)))
return recorder
}