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:
+201
-14
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user