Scope client management and config-apply per server (step 4)

Real per-server data isolation, the core ask behind the access-control
work: clients, config generation, and the client-management UI are now
scoped by server ID instead of implicitly operating on one global
"the server".

- util.DefaultServerID ("wg0") is the server every legacy bare route
  now resolves to, so old and new routes share one consistent identity
  instead of drifting apart.
- New /servers/:id/... routes (new-client, update-client, remove-client,
  set-status, download, api/clients, api/client/:cid, api/apply-wg-config)
  reuse the same handlers as the legacy routes via resolveServerID(c),
  gated by RequireServerAccess middleware. Cross-server edits/deletes on
  scoped routes are rejected (403) if a client belongs to a different
  server.
- Fixes a real data leak: ApplyServerConfig previously wrote ALL clients
  from ALL servers into whichever single wg.conf it targeted. It now
  filters clients by server ID before generating a config, and resolves
  each server's own ConfigFilePath/EndpointAddress via the new
  ServerSetting record instead of the app-wide GlobalSetting.
- WireGuardServerInterfaces/WireGuardServerKeyPair/GlobalSettingSubmit
  (the legacy /wg-server and /global-settings edit routes) now write
  through to the new per-server registry record for "wg0" in addition
  to the legacy collection, so the two stay in sync until the legacy
  routes are eventually retired.
- New templates/server_clients.html: per-server clone of clients.html
  wired to the scoped endpoints, with a server name/id heading.
- base.html's shared "New Client" and "Apply Config" actions (used by
  every page's nav buttons) now target the scoped route when a
  serverID is present on the page, instead of always hitting the
  legacy default-server endpoint regardless of which server's client
  page is open.

Legacy bare routes (/, /new-client, /wg-server, ...) are untouched and
still fully functional against the default "wg0" server - nothing was
removed yet, per the incremental-delivery approach for this project.
This commit is contained in:
sysops
2026-07-11 23:47:57 +02:00
parent e3534625c3
commit 74389a9d49
8 changed files with 1226 additions and 18 deletions
+201 -14
View File
@@ -32,6 +32,33 @@ import (
var usernameRegexp = regexp.MustCompile("^\\w[\\w\\-.]*$")
// resolveServerID returns the server ID a request targets: the :id route
// param for /servers/:id/... routes, or util.DefaultServerID for the
// legacy bare routes (/, /new-client, /wg-server, ...) which always
// operate on the migrated default server.
func resolveServerID(c echo.Context) string {
if id := c.Param("id"); id != "" {
return id
}
return util.DefaultServerID
}
// buildEffectiveSettings merges the app-wide GlobalSetting (DNS/MTU/
// PersistentKeepalive) with a server's own EndpointAddress override (from
// ServerSetting) into a single model.GlobalSetting, so util.BuildClientConfig
// can keep its existing single-struct signature unchanged.
func buildEffectiveSettings(db store.IStore, serverID string) (model.GlobalSetting, error) {
globalSettings, err := db.GetGlobalSettings()
if err != nil {
return globalSettings, err
}
serverSettings, err := db.GetServerSettings(serverID)
if err == nil && serverSettings.EndpointAddress != "" {
globalSettings.EndpointAddress = serverSettings.EndpointAddress
}
return globalSettings, nil
}
// Health check handler
func Health() echo.HandlerFunc {
return func(c echo.Context) error {
@@ -396,6 +423,39 @@ func WireGuardClients(db store.IStore) echo.HandlerFunc {
}
}
// ServerClientsPage renders the client management page scoped to one
// server (step 4 of the multi-server extension). Access is gated by the
// RequireServerAccess middleware at the route level.
func ServerClientsPage(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
serverID := c.Param("id")
server, err := db.GetServerByID(serverID)
if err != nil {
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Server not found"})
}
clientDataList, err := db.GetClients(true)
if err != nil {
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{
false, fmt.Sprintf("Cannot get client list: %v", err),
})
}
var filtered []model.ClientData
for _, cd := range clientDataList {
if cd.Client.ServerID == serverID {
filtered = append(filtered, cd)
}
}
return c.Render(http.StatusOK, "server_clients.html", map[string]interface{}{
"baseData": model.BaseData{Active: "servers", CurrentUser: currentUser(c), Admin: isAdmin(c)},
"server": server,
"serverID": serverID,
"clientDataList": filtered,
})
}
}
// GetClients handler return a JSON list of Wireguard client data
func GetClients(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
@@ -438,6 +498,32 @@ func GetClient(db store.IStore) echo.HandlerFunc {
}
}
// GetServerClient handler returns a single client's JSON, scoped to a
// server: refuses (404) if the client does not belong to the :id server.
func GetServerClient(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
serverID := c.Param("id")
clientID := c.Param("cid")
if _, err := xid.FromString(clientID); err != nil {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid client ID"})
}
qrCodeSettings := model.QRCodeSettings{
Enabled: true,
IncludeDNS: true,
IncludeMTU: true,
}
clientData, err := db.GetClientByID(clientID, qrCodeSettings)
if err != nil || clientData.Client.ServerID != serverID {
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Client not found"})
}
return c.JSON(http.StatusOK, util.FillClientSubnetRange(clientData))
}
}
// ListServers handler returns a JSON list of registered servers (step 2 of
// the multi-server extension - read-only, additive alongside the existing
// single-server routes).
@@ -598,11 +684,13 @@ func NewClient(db store.IStore) echo.HandlerFunc {
}
// read server information
server, err := db.GetServer()
serverID := resolveServerID(c)
server, err := db.GetServerByID(serverID)
if err != nil {
log.Error("Cannot fetch server from database: ", err)
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
}
client.ServerID = serverID
// validate the input Allocation IPs
allocatedIPs, err := util.GetAllocatedIPs("")
@@ -718,11 +806,15 @@ func EmailClient(db store.IStore, mailer emailer.Emailer, emailSubject, emailCon
}
// build config
server, _ := db.GetServer()
globalSettings, _ := db.GetGlobalSettings()
clientServerID := clientData.Client.ServerID
if clientServerID == "" {
clientServerID = util.DefaultServerID
}
server, _ := db.GetServerByID(clientServerID)
globalSettings, _ := buildEffectiveSettings(db, clientServerID)
config := util.BuildClientConfig(*clientData.Client, server, globalSettings)
cfgAtt := emailer.Attachment{Name: "wg0.conf", Data: []byte(config)}
cfgAtt := emailer.Attachment{Name: fmt.Sprintf("%s.conf", clientServerID), Data: []byte(config)}
var attachments []emailer.Attachment
if clientData.Client.PrivateKey != "" {
qrdata, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(clientData.QRCode, "data:image/png;base64,"))
@@ -767,8 +859,12 @@ func SendTelegramClient(db store.IStore) echo.HandlerFunc {
}
// build config
server, _ := db.GetServer()
globalSettings, _ := db.GetGlobalSettings()
clientServerID := clientData.Client.ServerID
if clientServerID == "" {
clientServerID = util.DefaultServerID
}
server, _ := db.GetServerByID(clientServerID)
globalSettings, _ := buildEffectiveSettings(db, clientServerID)
config := util.BuildClientConfig(*clientData.Client, server, globalSettings)
configData := []byte(config)
var qrData []byte
@@ -811,6 +907,11 @@ func UpdateClient(db store.IStore) echo.HandlerFunc {
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Client not found"})
}
// if reached via a /servers/:id/... route, refuse cross-server edits
if routeServerID := c.Param("id"); routeServerID != "" && clientData.Client.ServerID != routeServerID {
return c.JSON(http.StatusForbidden, jsonHTTPResponse{false, "Client does not belong to this server"})
}
// Validate Telegram userid if provided
if _client.TgUserid != "" {
idNum, err := strconv.ParseInt(_client.TgUserid, 10, 64)
@@ -819,7 +920,11 @@ func UpdateClient(db store.IStore) echo.HandlerFunc {
}
}
server, err := db.GetServer()
clientServerID := clientData.Client.ServerID
if clientServerID == "" {
clientServerID = util.DefaultServerID
}
server, err := db.GetServerByID(clientServerID)
if err != nil {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{
false, fmt.Sprintf("Cannot fetch server config: %s", err),
@@ -929,6 +1034,10 @@ func SetClientStatus(db store.IStore) echo.HandlerFunc {
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, err.Error()})
}
if routeServerID := c.Param("id"); routeServerID != "" && clientData.Client.ServerID != routeServerID {
return c.JSON(http.StatusForbidden, jsonHTTPResponse{false, "Client does not belong to this server"})
}
client := *clientData.Client
client.Enabled = status
@@ -959,12 +1068,20 @@ func DownloadClient(db store.IStore) echo.HandlerFunc {
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Client not found"})
}
if routeServerID := c.Param("id"); routeServerID != "" && clientData.Client.ServerID != routeServerID {
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Client not found"})
}
// build config
server, err := db.GetServer()
clientServerID := clientData.Client.ServerID
if clientServerID == "" {
clientServerID = util.DefaultServerID
}
server, err := db.GetServerByID(clientServerID)
if err != nil {
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
}
globalSettings, err := db.GetGlobalSettings()
globalSettings, err := buildEffectiveSettings(db, clientServerID)
if err != nil {
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
}
@@ -989,6 +1106,16 @@ func RemoveClient(db store.IStore) echo.HandlerFunc {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid client ID"})
}
if routeServerID := c.Param("id"); routeServerID != "" {
existing, err := db.GetClientByID(client.ID, model.QRCodeSettings{Enabled: false})
if err != nil {
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Client not found"})
}
if existing.Client.ServerID != routeServerID {
return c.JSON(http.StatusForbidden, jsonHTTPResponse{false, "Client does not belong to this server"})
}
}
// delete client from database
if err := db.DeleteClient(client.ID); err != nil {
@@ -1031,11 +1158,21 @@ func WireGuardServerInterfaces(db store.IStore) echo.HandlerFunc {
serverInterface.UpdatedAt = time.Now().UTC()
// write config to the database
// write config to the database (legacy single-server collection)
if err := db.SaveServerInterface(serverInterface); err != nil {
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Interface IP address must be in CIDR format"})
}
// keep the new per-server registry record for the default server in
// sync, since this legacy route is the only way to edit it today
if serverInterface.Name == "" {
serverInterface.Name = util.DefaultServerID
}
if err := db.UpdateServerInterface(util.DefaultServerID, serverInterface); err != nil {
log.Warnf("Could not sync default server registry entry: %v", err)
}
log.Infof("Updated wireguard server interfaces settings: %v", serverInterface)
return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Updated interface addresses successfully"})
@@ -1060,6 +1197,13 @@ func WireGuardServerKeyPair(db store.IStore) echo.HandlerFunc {
if err := db.SaveServerKeyPair(serverKeyPair); err != nil {
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot generate Wireguard key pair"})
}
// keep the new per-server registry record for the default server in
// sync, since this legacy route is the only way to edit it today
if err := db.UpdateServerKeyPair(util.DefaultServerID, serverKeyPair); err != nil {
log.Warnf("Could not sync default server registry entry: %v", err)
}
log.Infof("Updated wireguard server interfaces settings: %v", serverKeyPair)
return c.JSON(http.StatusOK, serverKeyPair)
@@ -1195,11 +1339,26 @@ func GlobalSettingSubmit(db store.IStore) echo.HandlerFunc {
globalSettings.UpdatedAt = time.Now().UTC()
// write config to the database
// write config to the database (legacy global collection)
if err := db.SaveGlobalSettings(globalSettings); err != nil {
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot generate Wireguard key pair"})
}
// keep the new per-server registry's ServerSetting record for the
// default server in sync (EndpointAddress/Table/FirewallMark/
// ConfigFilePath are per-server concerns in the new model), since
// this legacy route is the only way to edit it today
serverSettings := model.ServerSetting{
EndpointAddress: globalSettings.EndpointAddress,
FirewallMark: globalSettings.FirewallMark,
Table: globalSettings.Table,
ConfigFilePath: globalSettings.ConfigFilePath,
UpdatedAt: globalSettings.UpdatedAt,
}
if err := db.SaveServerSettings(util.DefaultServerID, serverSettings); err != nil {
log.Warnf("Could not sync default server settings registry entry: %v", err)
}
log.Infof("Updated global settings: %v", globalSettings)
return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Updated global settings successfully"})
@@ -1305,17 +1464,40 @@ func SuggestIPAllocation(db store.IStore) echo.HandlerFunc {
// ApplyServerConfig handler to write config file and restart Wireguard server
func ApplyServerConfig(db store.IStore, tmplDir fs.FS) echo.HandlerFunc {
return func(c echo.Context) error {
server, err := db.GetServer()
serverID := resolveServerID(c)
var server model.Server
var err error
if serverID == util.DefaultServerID {
// legacy bare route: keep reading from the legacy single-server
// collection, which is still the source of truth an operator
// may have just edited via /wg-server
server, err = db.GetServer()
} else {
server, err = db.GetServerByID(serverID)
}
if err != nil {
log.Error("Cannot get server config: ", err)
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot get server config"})
}
clients, err := db.GetClients(false)
allClients, err := db.GetClients(false)
if err != nil {
log.Error("Cannot get client config: ", err)
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot get client config"})
}
// only include this server's own clients as peers - other servers'
// clients must never leak into this config file
clients := make([]model.ClientData, 0, len(allClients))
for _, cd := range allClients {
clientServerID := cd.Client.ServerID
if clientServerID == "" {
clientServerID = util.DefaultServerID
}
if clientServerID == serverID {
clients = append(clients, cd)
}
}
users, err := db.GetUsers()
if err != nil {
@@ -1323,11 +1505,16 @@ func ApplyServerConfig(db store.IStore, tmplDir fs.FS) echo.HandlerFunc {
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot get users config"})
}
settings, err := db.GetGlobalSettings()
settings, err := buildEffectiveSettings(db, serverID)
if err != nil {
log.Error("Cannot get global settings: ", err)
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot get global settings"})
}
if serverID != util.DefaultServerID {
if serverSettings, sErr := db.GetServerSettings(serverID); sErr == nil && serverSettings.ConfigFilePath != "" {
settings.ConfigFilePath = serverSettings.ConfigFilePath
}
}
// Write config file
err = util.WriteWireGuardServerConfig(tmplDir, server, clients, users, settings)