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}})
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + +{{end}} + +{{define "bottom_js"}} + + +{{end}} diff --git a/util/config.go b/util/config.go index 4af6bd2..0c13c89 100644 --- a/util/config.go +++ b/util/config.go @@ -42,6 +42,10 @@ const ( DefaultFirewallMark = "0xca6c" // i.e. 51820 DefaultTable = "auto" DefaultConfigFilePath = "/etc/wireguard/wg0.conf" + // DefaultServerID is the server ID used by every route/handler that + // isn't explicitly scoped to a server (i.e. the legacy bare routes), + // and the ID a pre-multi-server install is migrated to. + DefaultServerID = "wg0" UsernameEnvVar = "WGUI_USERNAME" PasswordEnvVar = "WGUI_PASSWORD" PasswordFileEnvVar = "WGUI_PASSWORD_FILE"