diff --git a/handler/routes.go b/handler/routes.go
index 6422ac8..cc8ae7d 100644
--- a/handler/routes.go
+++ b/handler/routes.go
@@ -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)
diff --git a/main.go b/main.go
index c5800b4..15ec91e 100644
--- a/main.go
+++ b/main.go
@@ -254,7 +254,15 @@ func main() {
app.GET(util.BasePath+"/servers-settings", handler.ServersPage(), handler.ValidSession, handler.RefreshSession, handler.NeedsAdmin)
app.GET(util.BasePath+"/servers", handler.ListServers(db), handler.ValidSession)
app.POST(util.BasePath+"/servers", handler.CreateServer(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin)
- app.GET(util.BasePath+"/servers/:id/clients", handler.GetServerClients(db), handler.ValidSession, handler.RequireServerAccess(db))
+ app.GET(util.BasePath+"/servers/:id/clients", handler.ServerClientsPage(db), handler.ValidSession, handler.RefreshSession, handler.RequireServerAccess(db))
+ app.GET(util.BasePath+"/servers/:id/api/clients", handler.GetServerClients(db), handler.ValidSession, handler.RequireServerAccess(db))
+ app.GET(util.BasePath+"/servers/:id/api/client/:cid", handler.GetServerClient(db), handler.ValidSession, handler.RequireServerAccess(db))
+ app.POST(util.BasePath+"/servers/:id/new-client", handler.NewClient(db), handler.ValidSession, handler.ContentTypeJson, handler.RequireServerAccess(db))
+ app.POST(util.BasePath+"/servers/:id/update-client", handler.UpdateClient(db), handler.ValidSession, handler.ContentTypeJson, handler.RequireServerAccess(db))
+ app.POST(util.BasePath+"/servers/:id/client/set-status", handler.SetClientStatus(db), handler.ValidSession, handler.ContentTypeJson, handler.RequireServerAccess(db))
+ app.POST(util.BasePath+"/servers/:id/remove-client", handler.RemoveClient(db), handler.ValidSession, handler.ContentTypeJson, handler.RequireServerAccess(db))
+ app.GET(util.BasePath+"/servers/:id/download", handler.DownloadClient(db), handler.ValidSession, handler.RequireServerAccess(db))
+ app.POST(util.BasePath+"/servers/:id/api/apply-wg-config", handler.ApplyServerConfig(db, tmplDir), handler.ValidSession, handler.ContentTypeJson, handler.RequireServerAccess(db))
app.GET(util.BasePath+"/api/clients", handler.GetClients(db), handler.ValidSession)
app.GET(util.BasePath+"/api/client/:id", handler.GetClient(db), handler.ValidSession)
app.GET(util.BasePath+"/api/machine-ips", handler.MachineIPAddresses(), handler.ValidSession)
diff --git a/router/router.go b/router/router.go
index aab48ec..7fbb70e 100644
--- a/router/router.go
+++ b/router/router.go
@@ -116,6 +116,11 @@ func New(tmplDir fs.FS, extraData map[string]interface{}, secret [64]byte) *echo
log.Fatal(err)
}
+ tmplServerClientsString, err := util.StringFromEmbedFile(tmplDir, "server_clients.html")
+ if err != nil {
+ log.Fatal(err)
+ }
+
// create template list
funcs := template.FuncMap{
"StringsJoin": strings.Join,
@@ -131,6 +136,7 @@ func New(tmplDir fs.FS, extraData map[string]interface{}, secret [64]byte) *echo
templates["wake_on_lan_hosts.html"] = template.Must(template.New("wake_on_lan_hosts").Funcs(funcs).Parse(tmplBaseString + tmplWakeOnLanHostsString))
templates["about.html"] = template.Must(template.New("about").Funcs(funcs).Parse(tmplBaseString + aboutPageString))
templates["servers.html"] = template.Must(template.New("servers").Funcs(funcs).Parse(tmplBaseString + tmplServersString))
+ templates["server_clients.html"] = template.Must(template.New("server_clients").Funcs(funcs).Parse(tmplBaseString + tmplServerClientsString))
lvl, err := util.ParseLogLevel(util.LookupEnvOrString(util.LogLevel, "INFO"))
if err != nil {
diff --git a/store/jsondb/jsondb.go b/store/jsondb/jsondb.go
index e755e82..5be7a2a 100644
--- a/store/jsondb/jsondb.go
+++ b/store/jsondb/jsondb.go
@@ -21,7 +21,7 @@ import (
// legacyDefaultServerID is the synthetic ID assigned to a pre-existing
// single-server installation when it is migrated to the multi-server layout.
-const legacyDefaultServerID = "wg0"
+const legacyDefaultServerID = util.DefaultServerID
type JsonDB struct {
conn *scribble.Driver
@@ -557,6 +557,38 @@ func (o *JsonDB) SaveHashes(hashes model.ClientServerHashes) error {
return output
}
+// UpdateServerInterface func updates the Interface of an existing server
+func (o *JsonDB) UpdateServerInterface(serverID string, serverInterface model.ServerInterface) error {
+ if err := validateServerID(serverID); err != nil {
+ return err
+ }
+ server, err := o.GetServerByID(serverID)
+ if err != nil {
+ return err
+ }
+ server.Interface = &serverInterface
+ if err := o.conn.Write("servers", serverID, server); err != nil {
+ return err
+ }
+ return util.ManagePerms(path.Join(o.dbPath, "servers", serverID+".json"))
+}
+
+// UpdateServerKeyPair func updates the KeyPair of an existing server
+func (o *JsonDB) UpdateServerKeyPair(serverID string, serverKeyPair model.ServerKeypair) error {
+ if err := validateServerID(serverID); err != nil {
+ return err
+ }
+ server, err := o.GetServerByID(serverID)
+ if err != nil {
+ return err
+ }
+ server.KeyPair = &serverKeyPair
+ if err := o.conn.Write("servers", serverID, server); err != nil {
+ return err
+ }
+ return util.ManagePerms(path.Join(o.dbPath, "servers", serverID+".json"))
+}
+
// GetServers func to get all servers from the database
func (o *JsonDB) GetServers() ([]model.Server, error) {
var servers []model.Server
diff --git a/store/store.go b/store/store.go
index 69e346c..f1f0b27 100644
--- a/store/store.go
+++ b/store/store.go
@@ -35,4 +35,6 @@ type IStore interface {
SaveServerSettings(serverID string, settings model.ServerSetting) error
GetServerHashes(serverID string) (model.ClientServerHashes, error)
SaveServerHashes(serverID string, hashes model.ClientServerHashes) error
+ UpdateServerInterface(serverID string, serverInterface model.ServerInterface) error
+ UpdateServerKeyPair(serverID string, serverKeyPair model.ServerKeypair) error
}
diff --git a/templates/base.html b/templates/base.html
index 26c4530..610f861 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -502,7 +502,7 @@
$.ajax({
cache: false,
method: 'POST',
- url: '{{.basePath}}/new-client',
+ url: '{{if .serverID}}{{.basePath}}/servers/{{.serverID}}/new-client{{else}}{{.basePath}}/new-client{{end}}',
dataType: 'json',
contentType: "application/json",
data: JSON.stringify(data),
@@ -512,6 +512,11 @@
// Update the home page (clients page) after adding successfully
if (window.location.pathname === "{{.basePath}}/") {
populateClient(resp.id);
+ } else if ("{{.serverID}}" !== "") {
+ // per-server clients page: no in-place row injection
+ // wired up (server-scoped client ids need a scoped
+ // GET too), just reload to show the new client
+ location.reload();
}
updateApplyConfigVisibility()
},
@@ -656,7 +661,7 @@
$.ajax({
cache: false,
method: 'POST',
- url: '{{.basePath}}/api/apply-wg-config',
+ url: '{{if .serverID}}{{.basePath}}/servers/{{.serverID}}/api/apply-wg-config{{else}}{{.basePath}}/api/apply-wg-config{{end}}',
dataType: 'json',
contentType: "application/json",
success: function(data) {
diff --git a/templates/server_clients.html b/templates/server_clients.html
new file mode 100644
index 0000000..d00b405
--- /dev/null
+++ b/templates/server_clients.html
@@ -0,0 +1,964 @@
+{{define "title"}}
+Wireguard Clients
+{{end}}
+
+{{define "top_css"}}
+
+{{end}}
+
+{{define "username"}}
+{{ .username }}
+{{end}}
+
+{{define "page_title"}}
+Wireguard Clients
+{{end}}
+
+{{define "page_content"}}
+Clients — {{.server.Name}} ({{.serverID}})
+