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\\-.]*$") 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 // Health check handler
func Health() echo.HandlerFunc { func Health() echo.HandlerFunc {
return func(c echo.Context) error { 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 // GetClients handler return a JSON list of Wireguard client data
func GetClients(db store.IStore) echo.HandlerFunc { func GetClients(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error { 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 // ListServers handler returns a JSON list of registered servers (step 2 of
// the multi-server extension - read-only, additive alongside the existing // the multi-server extension - read-only, additive alongside the existing
// single-server routes). // single-server routes).
@@ -598,11 +684,13 @@ func NewClient(db store.IStore) echo.HandlerFunc {
} }
// read server information // read server information
server, err := db.GetServer() serverID := resolveServerID(c)
server, err := db.GetServerByID(serverID)
if err != nil { if err != nil {
log.Error("Cannot fetch server from database: ", err) log.Error("Cannot fetch server from database: ", err)
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()}) return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
} }
client.ServerID = serverID
// validate the input Allocation IPs // validate the input Allocation IPs
allocatedIPs, err := util.GetAllocatedIPs("") allocatedIPs, err := util.GetAllocatedIPs("")
@@ -718,11 +806,15 @@ func EmailClient(db store.IStore, mailer emailer.Emailer, emailSubject, emailCon
} }
// build config // build config
server, _ := db.GetServer() clientServerID := clientData.Client.ServerID
globalSettings, _ := db.GetGlobalSettings() if clientServerID == "" {
clientServerID = util.DefaultServerID
}
server, _ := db.GetServerByID(clientServerID)
globalSettings, _ := buildEffectiveSettings(db, clientServerID)
config := util.BuildClientConfig(*clientData.Client, server, globalSettings) 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 var attachments []emailer.Attachment
if clientData.Client.PrivateKey != "" { if clientData.Client.PrivateKey != "" {
qrdata, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(clientData.QRCode, "data:image/png;base64,")) 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 // build config
server, _ := db.GetServer() clientServerID := clientData.Client.ServerID
globalSettings, _ := db.GetGlobalSettings() if clientServerID == "" {
clientServerID = util.DefaultServerID
}
server, _ := db.GetServerByID(clientServerID)
globalSettings, _ := buildEffectiveSettings(db, clientServerID)
config := util.BuildClientConfig(*clientData.Client, server, globalSettings) config := util.BuildClientConfig(*clientData.Client, server, globalSettings)
configData := []byte(config) configData := []byte(config)
var qrData []byte var qrData []byte
@@ -811,6 +907,11 @@ func UpdateClient(db store.IStore) echo.HandlerFunc {
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Client not found"}) 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 // Validate Telegram userid if provided
if _client.TgUserid != "" { if _client.TgUserid != "" {
idNum, err := strconv.ParseInt(_client.TgUserid, 10, 64) 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 { if err != nil {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{ return c.JSON(http.StatusBadRequest, jsonHTTPResponse{
false, fmt.Sprintf("Cannot fetch server config: %s", err), 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()}) 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 := *clientData.Client
client.Enabled = status client.Enabled = status
@@ -959,12 +1068,20 @@ func DownloadClient(db store.IStore) echo.HandlerFunc {
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Client not found"}) 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 // build config
server, err := db.GetServer() clientServerID := clientData.Client.ServerID
if clientServerID == "" {
clientServerID = util.DefaultServerID
}
server, err := db.GetServerByID(clientServerID)
if err != nil { if err != nil {
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()}) return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
} }
globalSettings, err := db.GetGlobalSettings() globalSettings, err := buildEffectiveSettings(db, clientServerID)
if err != nil { if err != nil {
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()}) 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"}) 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 // delete client from database
if err := db.DeleteClient(client.ID); err != nil { if err := db.DeleteClient(client.ID); err != nil {
@@ -1031,11 +1158,21 @@ func WireGuardServerInterfaces(db store.IStore) echo.HandlerFunc {
serverInterface.UpdatedAt = time.Now().UTC() 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 { if err := db.SaveServerInterface(serverInterface); err != nil {
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Interface IP address must be in CIDR format"}) 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) log.Infof("Updated wireguard server interfaces settings: %v", serverInterface)
return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Updated interface addresses successfully"}) 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 { if err := db.SaveServerKeyPair(serverKeyPair); err != nil {
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot generate Wireguard key pair"}) 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) log.Infof("Updated wireguard server interfaces settings: %v", serverKeyPair)
return c.JSON(http.StatusOK, serverKeyPair) return c.JSON(http.StatusOK, serverKeyPair)
@@ -1195,11 +1339,26 @@ func GlobalSettingSubmit(db store.IStore) echo.HandlerFunc {
globalSettings.UpdatedAt = time.Now().UTC() 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 { if err := db.SaveGlobalSettings(globalSettings); err != nil {
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot generate Wireguard key pair"}) 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) log.Infof("Updated global settings: %v", globalSettings)
return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Updated global settings successfully"}) 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 // ApplyServerConfig handler to write config file and restart Wireguard server
func ApplyServerConfig(db store.IStore, tmplDir fs.FS) echo.HandlerFunc { func ApplyServerConfig(db store.IStore, tmplDir fs.FS) echo.HandlerFunc {
return func(c echo.Context) error { 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 { if err != nil {
log.Error("Cannot get server config: ", err) log.Error("Cannot get server config: ", err)
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot get server config"}) return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot get server config"})
} }
clients, err := db.GetClients(false) allClients, err := db.GetClients(false)
if err != nil { if err != nil {
log.Error("Cannot get client config: ", err) log.Error("Cannot get client config: ", err)
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot get client config"}) 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() users, err := db.GetUsers()
if err != nil { 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"}) return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot get users config"})
} }
settings, err := db.GetGlobalSettings() settings, err := buildEffectiveSettings(db, serverID)
if err != nil { if err != nil {
log.Error("Cannot get global settings: ", err) log.Error("Cannot get global settings: ", err)
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot get global settings"}) 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 // Write config file
err = util.WriteWireGuardServerConfig(tmplDir, server, clients, users, settings) err = util.WriteWireGuardServerConfig(tmplDir, server, clients, users, settings)
+9 -1
View File
@@ -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-settings", handler.ServersPage(), handler.ValidSession, handler.RefreshSession, handler.NeedsAdmin)
app.GET(util.BasePath+"/servers", handler.ListServers(db), handler.ValidSession) 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.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/clients", handler.GetClients(db), handler.ValidSession)
app.GET(util.BasePath+"/api/client/:id", handler.GetClient(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) app.GET(util.BasePath+"/api/machine-ips", handler.MachineIPAddresses(), handler.ValidSession)
+6
View File
@@ -116,6 +116,11 @@ func New(tmplDir fs.FS, extraData map[string]interface{}, secret [64]byte) *echo
log.Fatal(err) log.Fatal(err)
} }
tmplServerClientsString, err := util.StringFromEmbedFile(tmplDir, "server_clients.html")
if err != nil {
log.Fatal(err)
}
// create template list // create template list
funcs := template.FuncMap{ funcs := template.FuncMap{
"StringsJoin": strings.Join, "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["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["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["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")) lvl, err := util.ParseLogLevel(util.LookupEnvOrString(util.LogLevel, "INFO"))
if err != nil { if err != nil {
+33 -1
View File
@@ -21,7 +21,7 @@ import (
// legacyDefaultServerID is the synthetic ID assigned to a pre-existing // legacyDefaultServerID is the synthetic ID assigned to a pre-existing
// single-server installation when it is migrated to the multi-server layout. // single-server installation when it is migrated to the multi-server layout.
const legacyDefaultServerID = "wg0" const legacyDefaultServerID = util.DefaultServerID
type JsonDB struct { type JsonDB struct {
conn *scribble.Driver conn *scribble.Driver
@@ -557,6 +557,38 @@ func (o *JsonDB) SaveHashes(hashes model.ClientServerHashes) error {
return output 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 // GetServers func to get all servers from the database
func (o *JsonDB) GetServers() ([]model.Server, error) { func (o *JsonDB) GetServers() ([]model.Server, error) {
var servers []model.Server var servers []model.Server
+2
View File
@@ -35,4 +35,6 @@ type IStore interface {
SaveServerSettings(serverID string, settings model.ServerSetting) error SaveServerSettings(serverID string, settings model.ServerSetting) error
GetServerHashes(serverID string) (model.ClientServerHashes, error) GetServerHashes(serverID string) (model.ClientServerHashes, error)
SaveServerHashes(serverID string, hashes model.ClientServerHashes) error SaveServerHashes(serverID string, hashes model.ClientServerHashes) error
UpdateServerInterface(serverID string, serverInterface model.ServerInterface) error
UpdateServerKeyPair(serverID string, serverKeyPair model.ServerKeypair) error
} }
+7 -2
View File
@@ -502,7 +502,7 @@
$.ajax({ $.ajax({
cache: false, cache: false,
method: 'POST', method: 'POST',
url: '{{.basePath}}/new-client', url: '{{if .serverID}}{{.basePath}}/servers/{{.serverID}}/new-client{{else}}{{.basePath}}/new-client{{end}}',
dataType: 'json', dataType: 'json',
contentType: "application/json", contentType: "application/json",
data: JSON.stringify(data), data: JSON.stringify(data),
@@ -512,6 +512,11 @@
// Update the home page (clients page) after adding successfully // Update the home page (clients page) after adding successfully
if (window.location.pathname === "{{.basePath}}/") { if (window.location.pathname === "{{.basePath}}/") {
populateClient(resp.id); 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() updateApplyConfigVisibility()
}, },
@@ -656,7 +661,7 @@
$.ajax({ $.ajax({
cache: false, cache: false,
method: 'POST', 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', dataType: 'json',
contentType: "application/json", contentType: "application/json",
success: function(data) { success: function(data) {
+964
View File
@@ -0,0 +1,964 @@
{{define "title"}}
Wireguard Clients
{{end}}
{{define "top_css"}}
<style>
.paused-client {
transition: transform .2s;
cursor: pointer;
}
i[class^="paused-client"]:hover { transform: scale(1.5); }
</style>
{{end}}
{{define "username"}}
{{ .username }}
{{end}}
{{define "page_title"}}
Wireguard Clients
{{end}}
{{define "page_content"}}
<section class="content">
<div class="container-fluid">
<h5 class="mt-4 mb-2">Clients &mdash; {{.server.Name}} ({{.serverID}})</h5>
<div class="row" id="client-list">
</div>
<!-- /.row -->
</div>
</section>
<div class="modal fade" id="modal_email_client">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Email Configuration</h4>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<form name="frm_email_client" id="frm_email_client">
<div class="modal-body">
<input type="hidden" id="e_client_id" name="e_client_id">
<div class="form-group">
<label for="e_client_email" class="control-label">Email address</label>
<input type="text" class="form-control" id="e_client_email" name="e_client_email">
</div>
</div>
<div class="modal-footer justify-content-between">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-success">Send</button>
</div>
</form>
</div>
<!-- /.modal-content -->
</div>
<!-- /.modal-dialog -->
</div>
<!-- /.modal -->
<div class="modal fade" id="modal_qr_client">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">QR Code</h4>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<input type="hidden" id="qr_client_id" name="qr_client_id">
<img id="qr_code" class="w-100" style="image-rendering: pixelated;" src="" alt="QR code" />
<!-- do not include FwMark in any client configs: it is INVALID. -->
</div>
</div>
<!-- /.modal-content -->
</div>
<!-- /.modal-dialog -->
</div>
<!-- /.modal -->
<div class="modal fade" id="modal_telegram_client">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Telegram Configuration</h4>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<form name="frm_telegram_client" id="frm_telegram_client">
<div class="modal-body">
<input type="hidden" id="tg_client_id" name="tg_client_id">
<div class="form-group">
<label for="tg_client_userid" class="control-label">Telegram userid</label>
<input type="text" class="form-control" id="tg_client_userid" name="tg_client_userid">
</div>
</div>
<div class="modal-footer justify-content-between">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-success">Send</button>
</div>
</form>
</div>
<!-- /.modal-content -->
</div>
<!-- /.modal-dialog -->
</div>
<!-- /.modal -->
<div class="modal fade" id="modal_edit_client">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Edit Client</h4>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<form name="frm_edit_client" id="frm_edit_client">
<div class="modal-body">
<input type="hidden" id="_client_id" name="_client_id">
<div class="form-group">
<label for="_client_name" class="control-label">Name</label>
<input type="text" class="form-control" id="_client_name" name="_client_name">
</div>
<div class="form-group">
<label for="_client_email" class="control-label">Email</label>
<input type="text" class="form-control" id="_client_email" name="client_email">
</div>
<div class="form-group">
<label for="_subnet_ranges" class="control-label">Subnet range</label>
<select id="_subnet_ranges" class="select2"
data-placeholder="Select a subnet range" style="width: 100%;">
</select>
</div>
<div class="form-group">
<label for="_client_allocated_ips" class="control-label">IP Allocation</label>
<input type="text" data-role="tagsinput" class="form-control" id="_client_allocated_ips">
</div>
<div class="form-group">
<label for="_client_allowed_ips" class="control-label">Allowed IPs</label>
<input type="text" data-role="tagsinput" class="form-control" id="_client_allowed_ips">
</div>
<div class="form-group">
<label for="_client_extra_allowed_ips" class="control-label">Extra Allowed IPs</label>
<input type="text" data-role="tagsinput" class="form-control"
id="_client_extra_allowed_ips">
</div>
<div class="form-group">
<label for="_client_endpoint" class="control-label">Endpoint</label>
<input type="text" class="form-control" id="_client_endpoint" name="client_endpoint">
</div>
<div class="form-group">
<div class="icheck-primary d-inline">
<input type="checkbox" id="_use_server_dns">
<label for="_use_server_dns">
Use server DNS
</label>
</div>
</div>
<div class="form-group">
<div class="icheck-primary d-inline">
<input type="checkbox" id="_enabled">
<label for="_enabled">
Enable this client
</label>
</div>
</div>
<details>
<summary><strong>Public and Preshared Keys</strong>
<i class="fas fa-info-circle" data-toggle="tooltip"
data-original-title="Update the server stored
client Public and Preshared keys.">
</i>
</summary>
<div class="form-group" style="margin-top: 1rem">
<label for="_client_public_key" class="control-label">
Public Key
</label>
<input type="text" class="form-control" id="_client_public_key" name="_client_public_key" aria-invalid="false">
</div>
<div class="form-group">
<label for="_client_preshared_key" class="control-label">
Preshared Key
</label>
<input type="text" class="form-control" id="_client_preshared_key" name="_client_preshared_key">
</div>
</details>
<details style="margin-top: 0.5rem;">
<summary><strong>Additional configuration</strong>
</summary>
<div class="form-group" style="margin-top: 0.5rem;">
<label for="_client_telegram_userid" class="control-label">Telegram userid</label>
<input type="text" class="form-control" id="_client_telegram_userid" name="_client_telegram_userid">
</div>
<div class="form-group">
<label for="_additional_notes" class="control-label">Notes</label>
<textarea class="form-control" style="min-height: 6rem;" id="_additional_notes" name="_additional_notes" placeholder="Additional notes about this client"></textarea>
</div>
</details>
</div>
<div class="modal-footer justify-content-between">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-success">Save</button>
</div>
</form>
</div>
<!-- /.modal-content -->
</div>
<!-- /.modal-dialog -->
</div>
<!-- /.modal -->
<div class="modal fade" id="modal_pause_client">
<div class="modal-dialog">
<div class="modal-content bg-warning">
<div class="modal-header">
<h4 class="modal-title">Disable</h4>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
</div>
<div class="modal-footer justify-content-between">
<button type="button" class="btn btn-outline-dark" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-outline-dark" id="pause_client_confirm">Apply</button>
</div>
</div>
<!-- /.modal-content -->
</div>
<!-- /.modal-dialog -->
</div>
<!-- /.modal -->
<div class="modal fade" id="modal_remove_client">
<div class="modal-dialog">
<div class="modal-content bg-danger">
<div class="modal-header">
<h4 class="modal-title">Remove</h4>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
</div>
<div class="modal-footer justify-content-between">
<button type="button" class="btn btn-outline-dark" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-outline-dark" id="remove_client_confirm">Apply</button>
</div>
</div>
<!-- /.modal-content -->
</div>
<!-- /.modal-dialog -->
</div>
<!-- /.modal -->
{{end}}
{{define "bottom_js"}}
<script>
function populateClientList() {
$.ajax({
cache: false,
method: 'GET',
url: '{{.basePath}}/servers/{{.serverID}}/api/clients',
dataType: 'json',
contentType: "application/json",
success: function (data) {
renderClientList(data);
},
error: function (jqXHR, exception) {
const responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson['message']);
}
});
}
function setClientStatus(clientID, status) {
const data = {"id": clientID, "status": status};
$.ajax({
cache: false,
method: 'POST',
url: '{{.basePath}}/servers/{{.serverID}}/client/set-status',
dataType: 'json',
contentType: "application/json",
data: JSON.stringify(data),
success: function (data) {
console.log("Set client " + clientID + " status to " + status);
},
error: function (jqXHR, exception) {
const responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson['message']);
}
});
}
function resumeClient(clientID) {
setClientStatus(clientID, true);
const divElement = document.getElementById("paused_" + clientID);
divElement.style.visibility = "hidden";
updateApplyConfigVisibility()
}
function pauseClient(clientID) {
setClientStatus(clientID, false);
const divElement = document.getElementById("paused_" + clientID);
divElement.style.visibility = "visible";
updateApplyConfigVisibility()
}
// updateIPAllocationSuggestion function for automatically fill
// the IP Allocation input with suggested ip addresses
// FOR CHANGING A SUBNET OF AN EXISTING CLIENT
function updateIPAllocationSuggestionExisting() {
let subnetRange = $("#_subnet_ranges").select2('val');
if (!subnetRange || subnetRange.length === 0) {
subnetRange = '__default_any__'
}
$.ajax({
cache: false,
method: 'GET',
url: `{{.basePath}}/api/suggest-client-ips?sr=${subnetRange}`,
dataType: 'json',
contentType: "application/json",
success: function(data) {
const allocated_ips = $("#_client_allocated_ips").val().split(",");
allocated_ips.forEach(function (item, index) {
$('#_client_allocated_ips').removeTag(escape(item));
})
data.forEach(function (item, index) {
$('#_client_allocated_ips').addTag(item);
})
},
error: function(jqXHR, exception) {
const allocated_ips = $("#_client_allocated_ips").val().split(",");
allocated_ips.forEach(function (item, index) {
$('#_client_allocated_ips').removeTag(escape(item));
})
const responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson['message']);
}
});
}
function updateSubnetRangesList(elementID, preselectedVal) {
$.getJSON("{{.basePath}}/api/subnet-ranges", null, function(data) {
$(`${elementID} option`).remove();
$(elementID).append(
$("<option></option>")
.text("Any")
.val("__default_any__")
);
$.each(data, function(index, item) {
$(elementID).append(
$("<option></option>")
.text(item)
.val(item)
);
if (item === preselectedVal) {
console.log(preselectedVal);
$(elementID).val(preselectedVal).trigger('change')
}
});
});
}
function updateSearchList() {
$.getJSON("{{.basePath}}/api/subnet-ranges", null, function(data) {
$("#status-selector option").remove();
$("#status-selector").append(
$("<option></option>")
.text("All")
.val("All"),
$("<option></option>")
.text("Enabled")
.val("Enabled"),
$("<option></option>")
.text("Disabled")
.val("Disabled"),
$("<option></option>")
.text("Connected")
.val("Connected"),
$("<option></option>")
.text("Disconnected")
.val("Disconnected")
);
$.each(data, function(index, item) {
$("#status-selector").append(
$("<option></option>")
.text(item)
.val(item)
);
});
});
}
</script>
<script>
// load client list
$(document).ready(function () {
updateSearchList();
populateClientList();
})
// show search bar and override :contains to be case-insensitive
$(document).ready(function () {
$("#search-form").show();
jQuery.expr[':'].contains = function(a, i, m) {
return jQuery(a).text().toUpperCase()
.indexOf(m[3].toUpperCase()) >= 0;
};
})
// hide all clients and display only the ones that meet the search criteria (name, email, IP)
$('#search-input').keyup(function () {
$("#status-selector").val("All");
let query = $(this).val().trim();
$('.col-lg-4').hide();
$(".info-box-text").each(function() {
if($(this).children('i.fa-user').length > 0 || $(this).children('i.fa-envelope').length > 0)
{
$(this).filter(':contains("' + query + '")').parent().parent().parent().show();
}
})
$(".badge-secondary").filter(':contains("' + query + '")').parent().parent().parent().show();
$(".fa-tguserid").each(function () {
if ($(this).parent().text().trim().indexOf(query) != -1) {
$(this).closest('.col-lg-4').show();
}
})
let upperQuery = query.toUpperCase()
$(".fa-additional_notes").each(function () {
if ($(this).parent().text().trim().indexOf(upperQuery) != -1) {
$(this).closest('.col-lg-4').show();
}
})
})
$("#status-selector").on('change', function () {
$('#search-input').val("");
switch ($("#status-selector").val()) {
case "All":
$('.col-lg-4').show();
break;
case "Enabled":
$('.col-lg-4').hide();
$('[id^="paused_"]').each(function () {
if ($(this).css("visibility") === "hidden") {
$(this).parent().parent().show();
}
});
break;
case "Disabled":
$('.col-lg-4').hide();
$('[id^="paused_"]').each(function () {
if ($(this).css("visibility") !== "hidden") {
$(this).parent().parent().show();
}
});
break;
case "Connected":
$('.col-lg-4').hide();
$.ajax({
cache: false,
method: 'GET',
url: '{{.basePath}}/status',
success: function (data) {
const returnedHTML = $(data).find(".table-success").get();
var returnedString = "";
returnedHTML.forEach(entry => returnedString += entry.outerHTML);
$(".fa-key").each(function () {
if (returnedString.indexOf($(this).parent().text().trim()) != -1) {
$(this).closest('.col-lg-4').show();
}
})
}
});
break;
case "Disconnected":
$('.col-lg-4').show();
$.ajax({
cache: false,
method: 'GET',
url: '{{.basePath}}/status',
success: function (data) {
const returnedHTML = $(data).find(".table-success").get();
var returnedString = "";
returnedHTML.forEach(entry => returnedString += entry.outerHTML);
$(".fa-key").each(function () {
if (returnedString.indexOf($(this).parent().text().trim()) != -1) {
$(this).closest('.col-lg-4').hide();
}
})
}
});
break;
default:
$('.col-lg-4').hide();
const selectedSR = $("#status-selector").val()
$(".fa-subnetrange").each(function () {
const srs = $(this).parent().text().trim().split(',')
for (const sr of srs) {
if (sr === selectedSR) {
$(this).closest('.col-lg-4').show();
break
}
}
})
// $('.col-lg-4').show();
break;
}
});
// modal_pause_client modal event
$("#modal_pause_client").on('show.bs.modal', function (event) {
const button = $(event.relatedTarget);
const client_id = button.data('clientid');
const client_name = button.data('clientname');
const modal = $(this);
modal.find('.modal-body').text("You are about to disable client " + client_name);
modal.find('#pause_client_confirm').val(client_id);
})
// pause_client_confirm button event
$(document).ready(function () {
$("#pause_client_confirm").click(function () {
const client_id = $(this).val();
pauseClient(client_id);
$("#modal_pause_client").modal('hide');
});
});
// modal_remove_client modal event
$("#modal_remove_client").on('show.bs.modal', function (event) {
const button = $(event.relatedTarget);
const client_id = button.data('clientid');
const client_name = button.data('clientname');
const modal = $(this);
modal.find('.modal-body').text("You are about to remove client " + client_name);
modal.find('#remove_client_confirm').val(client_id);
})
// remove_client_confirm button event
$(document).ready(function () {
$("#remove_client_confirm").click(function () {
const client_id = $(this).val();
const data = {"id": client_id};
$.ajax({
cache: false,
method: 'POST',
url: '{{.basePath}}/servers/{{.serverID}}/remove-client',
dataType: 'json',
contentType: "application/json",
data: JSON.stringify(data),
success: function(data) {
$("#modal_remove_client").modal('hide');
toastr.success('Removed client successfully');
const divElement = document.getElementById('client_' + client_id);
divElement.style.display = "none";
updateApplyConfigVisibility()
},
error: function(jqXHR, exception) {
const responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson['message']);
}
});
});
});
// Edit client modal event
// This fills the modal dialogue with data from the DB when we open the edit menu
$(document).ready(function () {
$("#modal_edit_client").on('show.bs.modal', function (event) {
let modal = $(this);
const button = $(event.relatedTarget);
const client_id = button.data('clientid');
// IP Allocation tag input
modal.find("#_client_allocated_ips").tagsInput({
'width': '100%',
'height': '75%',
'interactive': true,
'defaultText': 'Add More',
'removeWithBackspace': true,
'minChars': 0,
'minInputWidth': '100%',
'placeholderColor': '#666666'
});
// AllowedIPs tag input
modal.find("#_client_allowed_ips").tagsInput({
'width': '100%',
'height': '75%',
'interactive': true,
'defaultText': 'Add More',
'removeWithBackspace': true,
'minChars': 0,
'minInputWidth': '100%',
'placeholderColor': '#666666'
});
modal.find("#_client_extra_allowed_ips").tagsInput({
'width': '100%',
'height': '75%',
'interactive': true,
'defaultText': 'Add More',
'removeWithBackspace' : true,
'minChars': 0,
'minInputWidth': '100%',
'placeholderColor': '#666666'
})
// update client modal data
$.ajax({
cache: false,
method: 'GET',
url: '{{.basePath}}/servers/{{.serverID}}/api/client/' + client_id,
dataType: 'json',
contentType: "application/json",
success: function (resp) {
const client = resp.Client;
modal.find(".modal-title").text("Edit Client " + client.name);
modal.find("#_client_id").val(client.id);
modal.find("#_client_telegram_userid").val(client.telegram_userid);
modal.find("#_client_name").val(client.name);
modal.find("#_client_email").val(client.email);
let preselectedEl
if (client.subnet_ranges && client.subnet_ranges.length > 0) {
preselectedEl = client.subnet_ranges[0]
}
updateSubnetRangesList("#_subnet_ranges", preselectedEl);
modal.find("#_client_allocated_ips").importTags('');
client.allocated_ips.forEach(function (obj) {
modal.find("#_client_allocated_ips").addTag(obj);
});
modal.find("#_client_allowed_ips").importTags('');
client.allowed_ips.forEach(function (obj) {
modal.find("#_client_allowed_ips").addTag(obj);
});
modal.find("#_client_extra_allowed_ips").importTags('');
client.extra_allowed_ips.forEach(function (obj) {
modal.find("#_client_extra_allowed_ips").addTag(obj);
});
modal.find("#_client_endpoint").val(client.endpoint);
modal.find("#_use_server_dns").prop("checked", client.use_server_dns);
modal.find("#_enabled").prop("checked", client.enabled);
modal.find("#_client_public_key").val(client.public_key);
modal.find("#_client_preshared_key").val(client.preshared_key);
modal.find("#_additional_notes").val(client.additional_notes);
// handle subnet range select
$('#_subnet_ranges').on('select2:select', function (e) {
updateIPAllocationSuggestionExisting();
});
},
error: function (jqXHR, exception) {
const responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson['message']);
}
});
});
});
// regenerateQRCode function for regenerating QR Code adding/removing some parts of configuration because of compatibility issues with some clients
function regenerateQRCode() {
const client_id = $("#qr_client_id").val();
const QRCodeImg = $("#qr_code");
const QRCodeA = $("#qr_code_a");
QRCodeImg.hide();
$.ajax({
cache: false,
method: 'GET',
url: '{{.basePath}}/servers/{{.serverID}}/api/client/' + client_id,
data: {
},
dataType: 'json',
contentType: "application/json",
success: function (resp) {
const client = resp.Client;
$(".modal-title").text("Scan QR Code for " + client.name + " profile");
QRCodeImg.attr('src', resp.QRCode).show();
QRCodeA.attr('download', resp.Client.name);
QRCodeA.attr('href', resp.QRCode).show();
},
error: function (jqXHR, exception) {
const responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson['message']);
}
});
}
// submitEmailClient function for sending an email with the configuration to the client
function submitEmailClient() {
const client_id = $("#e_client_id").val();
const email = $("#e_client_email").val();
const data = {"id": client_id, "email": email};
$.ajax({
cache: false,
method: 'POST',
url: '{{.basePath}}/email-client',
dataType: 'json',
contentType: "application/json",
data: JSON.stringify(data),
success: function(resp) {
$("#modal_email_client").modal('hide');
toastr.success('Sent email to client successfully');
},
error: function(jqXHR, exception) {
const responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson['message']);
}
});
}
// submitTelegramClient function for sending a telegram message with the configuration to the client
function submitTelegramClient() {
const client_id = $("#tg_client_id").val();
const userid = $("#tg_client_userid").val();
const data = {"id": client_id, "userid": userid};
$.ajax({
cache: false,
method: 'POST',
url: '{{.basePath}}/send-telegram-client',
dataType: 'json',
contentType: "application/json",
data: JSON.stringify(data),
success: function(resp) {
$("#modal_telegram_client").modal('hide');
toastr.success('Sent config via telegram to client successfully');
},
error: function(jqXHR, exception) {
const responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson['message']);
}
});
}
// submitEditClient function for updating an existing client
// This sends dialogue data to the back-end when user presses "Save"
// See e.g. routes.go:UpdateClient for where data is processed/verified.
function submitEditClient() {
const client_id = $("#_client_id").val();
const name = $("#_client_name").val();
const email = $("#_client_email").val();
const telegram_userid = $("#_client_telegram_userid").val();
const allocated_ips = $("#_client_allocated_ips").val().split(",");
const allowed_ips = $("#_client_allowed_ips").val().split(",");
let use_server_dns = false;
let extra_allowed_ips = [];
const public_key = $("#_client_public_key").val();
const preshared_key = $("#_client_preshared_key").val();
if( $("#_client_extra_allowed_ips").val() !== "" ) {
extra_allowed_ips = $("#_client_extra_allowed_ips").val().split(",");
}
const endpoint = $("#_client_endpoint").val();
if ($("#_use_server_dns").is(':checked')){
use_server_dns = true;
}
let enabled = false;
if ($("#_enabled").is(':checked')){
enabled = true;
}
const additional_notes = $("#_additional_notes").val();
const data = {"id": client_id, "name": name, "email": email, "telegram_userid": telegram_userid, "allocated_ips": allocated_ips,
"allowed_ips": allowed_ips, "extra_allowed_ips": extra_allowed_ips, "endpoint": endpoint,
"use_server_dns": use_server_dns, "enabled": enabled, "public_key": public_key, "preshared_key": preshared_key, "additional_notes": additional_notes};
$.ajax({
cache: false,
method: 'POST',
url: '{{.basePath}}/servers/{{.serverID}}/update-client',
dataType: 'json',
contentType: "application/json",
data: JSON.stringify(data),
success: function(resp) {
$("#modal_edit_client").modal('hide');
toastr.success('Updated client successfully');
// Refresh the home page (clients page) after updating successfully
location.reload();
},
error: function(jqXHR, exception) {
const responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson['message']);
}
});
}
// submitHandler
function submitHandler(form) {
const formId = $(form).attr('id');
if (formId === "frm_edit_client") {
submitEditClient();
} else if (formId === "frm_email_client") {
submitEmailClient();
} else if (formId === "frm_telegram_client") {
submitTelegramClient();
}
}
$("#modal_email_client").on('show.bs.modal', function (event) {
let modal = $(this);
const button = $(event.relatedTarget);
const client_id = button.data('clientid');
$.ajax({
cache: false,
method: 'GET',
url: '{{.basePath}}/servers/{{.serverID}}/api/client/' + client_id,
dataType: 'json',
contentType: "application/json",
success: function (resp) {
const client = resp.Client;
modal.find(".modal-title").text("Send config to client " + client.name);
modal.find("#e_client_id").val(client.id);
modal.find("#e_client_email").val(client.email);
},
error: function (jqXHR, exception) {
const responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson['message']);
}
});
});
$("#modal_qr_client").on('show.bs.modal', function (event) {
let modal = $(this);
const button = $(event.relatedTarget);
const client_id = button.data('clientid');
modal.find("#qr_client_id").val(client_id);
regenerateQRCode();
});
$("#modal_telegram_client").on('show.bs.modal', function (event) {
let modal = $(this);
const button = $(event.relatedTarget);
const client_id = button.data('clientid');
$.ajax({
cache: false,
method: 'GET',
url: '{{.basePath}}/servers/{{.serverID}}/api/client/' + client_id,
dataType: 'json',
contentType: "application/json",
success: function (resp) {
const client = resp.Client;
modal.find(".modal-title").text("Send config to client " + client.name);
modal.find("#tg_client_id").val(client.id);
modal.find("#tg_client_userid").val(client.telegram_userid);
},
error: function (jqXHR, exception) {
const responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson['message']);
}
});
});
$(document).ready(function () {
$.validator.setDefaults({
submitHandler: function (form) {
submitHandler(form);
}
});
// Edit client form validation
$("#frm_edit_client").validate({
rules: {
client_name: {
required: true,
},
},
messages: {
client_name: {
required: "Please enter a name"
},
},
errorElement: 'span',
errorPlacement: function (error, element) {
error.addClass('invalid-feedback');
element.closest('.form-group').append(error);
},
highlight: function (element, errorClass, validClass) {
$(element).addClass('is-invalid');
},
unhighlight: function (element, errorClass, validClass) {
$(element).removeClass('is-invalid');
}
});
// Email client form validation
$("#frm_email_client").validate({
rules: {
e_client_email: {
required: true,
email: true,
},
},
messages: {
e_client_email: {
required: "Please enter an email"
},
},
errorElement: 'span',
errorPlacement: function (error, element) {
error.addClass('invalid-feedback');
element.closest('.form-group').append(error);
},
highlight: function (element, errorClass, validClass) {
$(element).addClass('is-invalid');
},
unhighlight: function (element, errorClass, validClass) {
$(element).removeClass('is-invalid');
}
});
// Telegram client form validation
$("#frm_telegram_client").validate({
rules: {
tg_client_userid: {
required: true,
number: true,
},
},
messages: {
tg_client_userid: {
required: "Please enter a telegram userid",
number: "Please enter a valid telegram userid"
},
},
errorElement: 'span',
errorPlacement: function (error, element) {
error.addClass('invalid-feedback');
element.closest('.form-group').append(error);
},
highlight: function (element, errorClass, validClass) {
$(element).addClass('is-invalid');
},
unhighlight: function (element, errorClass, validClass) {
$(element).removeClass('is-invalid');
}
});
//
});
</script>
{{end}}
+4
View File
@@ -42,6 +42,10 @@ const (
DefaultFirewallMark = "0xca6c" // i.e. 51820 DefaultFirewallMark = "0xca6c" // i.e. 51820
DefaultTable = "auto" DefaultTable = "auto"
DefaultConfigFilePath = "/etc/wireguard/wg0.conf" 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" UsernameEnvVar = "WGUI_USERNAME"
PasswordEnvVar = "WGUI_PASSWORD" PasswordEnvVar = "WGUI_PASSWORD"
PasswordFileEnvVar = "WGUI_PASSWORD_FILE" PasswordFileEnvVar = "WGUI_PASSWORD_FILE"