Files
wireguard-ui-multi/handler/routes.go
T
sysopsandClaude Sonnet 5 0000643187 Add missing server delete: handler, route, and UI button
DeleteServer already existed in the store layer (refuses if clients
still reference the server) but was never wired up anywhere, so there
was no way to actually remove a server from the UI or API.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 16:38:22 +02:00

1612 lines
54 KiB
Go

package handler
import (
"crypto/subtle"
"encoding/base64"
"encoding/json"
"fmt"
"io/fs"
"net/http"
"os"
"regexp"
"sort"
"strconv"
"strings"
"time"
"github.com/gorilla/sessions"
"github.com/labstack/echo-contrib/session"
"github.com/labstack/echo/v4"
"github.com/labstack/gommon/log"
"github.com/rs/xid"
"github.com/skip2/go-qrcode"
"golang.zx2c4.com/wireguard/wgctrl"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"github.com/ngoduykhanh/wireguard-ui/backup"
"github.com/ngoduykhanh/wireguard-ui/emailer"
"github.com/ngoduykhanh/wireguard-ui/model"
"github.com/ngoduykhanh/wireguard-ui/store"
"github.com/ngoduykhanh/wireguard-ui/telegram"
"github.com/ngoduykhanh/wireguard-ui/util"
)
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, ...) 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 {
return c.String(http.StatusOK, "ok")
}
}
func Favicon() echo.HandlerFunc {
return func(c echo.Context) error {
if favicon, ok := os.LookupEnv(util.FaviconFilePathEnvVar); ok {
return c.File(favicon)
}
return c.Redirect(http.StatusFound, util.BasePath+"/static/custom/img/favicon.ico")
}
}
// LoginPage handler
func LoginPage() echo.HandlerFunc {
return func(c echo.Context) error {
return c.Render(http.StatusOK, "login.html", map[string]interface{}{})
}
}
// Login for signing in handler
func Login(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
data := make(map[string]interface{})
err := json.NewDecoder(c.Request().Body).Decode(&data)
if err != nil {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"})
}
username := data["username"].(string)
password := data["password"].(string)
rememberMe := data["rememberMe"].(bool)
if !usernameRegexp.MatchString(username) {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid username"})
}
dbuser, err := db.GetUserByName(username)
if err != nil {
log.Infof("Cannot query user %s from DB", username)
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Invalid credentials"})
}
userCorrect := subtle.ConstantTimeCompare([]byte(username), []byte(dbuser.Username)) == 1
var passwordCorrect bool
if dbuser.PasswordHash != "" {
match, err := util.VerifyHash(dbuser.PasswordHash, password)
if err != nil {
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot verify password"})
}
passwordCorrect = match
} else {
passwordCorrect = subtle.ConstantTimeCompare([]byte(password), []byte(dbuser.Password)) == 1
}
if userCorrect && passwordCorrect {
ageMax := 0
if rememberMe {
ageMax = 86400 * 7
}
cookiePath := util.GetCookiePath()
sess, _ := session.Get("session", c)
sess.Options = &sessions.Options{
Path: cookiePath,
MaxAge: ageMax,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
}
// set session_token
tokenUID := xid.New().String()
now := time.Now().UTC().Unix()
sess.Values["username"] = dbuser.Username
sess.Values["user_hash"] = util.GetDBUserCRC32(dbuser)
sess.Values["admin"] = dbuser.Admin
sess.Values["session_token"] = tokenUID
sess.Values["max_age"] = ageMax
sess.Values["created_at"] = now
sess.Values["updated_at"] = now
sess.Save(c.Request(), c.Response())
// set session_token in cookie
cookie := new(http.Cookie)
cookie.Name = "session_token"
cookie.Path = cookiePath
cookie.Value = tokenUID
cookie.MaxAge = ageMax
cookie.HttpOnly = true
cookie.SameSite = http.SameSiteLaxMode
c.SetCookie(cookie)
return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Logged in successfully"})
}
return c.JSON(http.StatusUnauthorized, jsonHTTPResponse{false, "Invalid credentials"})
}
}
// GetUsers handler return a JSON list of all users
func GetUsers(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
usersList, err := db.GetUsers()
if err != nil {
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{
false, fmt.Sprintf("Cannot get user list: %v", err),
})
}
return c.JSON(http.StatusOK, usersList)
}
}
// GetUser handler returns a JSON object of single user
func GetUser(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
username := c.Param("username")
if !usernameRegexp.MatchString(username) {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid username"})
}
if !isAdmin(c) && (username != currentUser(c)) {
return c.JSON(http.StatusForbidden, jsonHTTPResponse{false, "Manager cannot access other user data"})
}
userData, err := db.GetUserByName(username)
if err != nil {
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "User not found"})
}
return c.JSON(http.StatusOK, userData)
}
}
// Logout to log a user out
func Logout() echo.HandlerFunc {
return func(c echo.Context) error {
clearSession(c)
return c.Redirect(http.StatusTemporaryRedirect, util.BasePath+"/login")
}
}
// LoadProfile to load user information
func LoadProfile() echo.HandlerFunc {
return func(c echo.Context) error {
return c.Render(http.StatusOK, "profile.html", map[string]interface{}{
"baseData": model.BaseData{Active: "profile", CurrentUser: currentUser(c), Admin: isAdmin(c)},
})
}
}
// UsersSettings handler
func UsersSettings() echo.HandlerFunc {
return func(c echo.Context) error {
return c.Render(http.StatusOK, "users_settings.html", map[string]interface{}{
"baseData": model.BaseData{Active: "users-settings", CurrentUser: currentUser(c), Admin: isAdmin(c)},
})
}
}
// ServersPage renders the server list/create page
func ServersPage() echo.HandlerFunc {
return func(c echo.Context) error {
return c.Render(http.StatusOK, "servers.html", map[string]interface{}{
"baseData": model.BaseData{Active: "servers", CurrentUser: currentUser(c), Admin: isAdmin(c)},
})
}
}
// UpdateUser to update user information
func UpdateUser(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
data := make(map[string]interface{})
err := json.NewDecoder(c.Request().Body).Decode(&data)
if err != nil {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"})
}
username := data["username"].(string)
password := data["password"].(string)
previousUsername := data["previous_username"].(string)
admin := data["admin"].(bool)
if !isAdmin(c) && (previousUsername != currentUser(c)) {
return c.JSON(http.StatusForbidden, jsonHTTPResponse{false, "Manager cannot access other user data"})
}
if !isAdmin(c) {
admin = false
}
if !usernameRegexp.MatchString(previousUsername) {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid username"})
}
user, err := db.GetUserByName(previousUsername)
if err != nil {
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, err.Error()})
}
if username == "" || !usernameRegexp.MatchString(username) {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid username"})
} else {
user.Username = username
}
if username != previousUsername {
_, err := db.GetUserByName(username)
if err == nil {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "This username is taken"})
}
}
if password != "" {
hash, err := util.HashPassword(password)
if err != nil {
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
}
user.PasswordHash = hash
}
if previousUsername != currentUser(c) {
user.Admin = admin
}
// only an admin may change which servers a user can access
if isAdmin(c) {
if rawIDs, ok := data["server_ids"].([]interface{}); ok {
serverIDs := make([]string, 0, len(rawIDs))
for _, v := range rawIDs {
if id, ok := v.(string); ok && util.ValidateRecordID(id) {
serverIDs = append(serverIDs, id)
}
}
user.ServerIDs = serverIDs
}
}
if err := db.DeleteUser(previousUsername); err != nil {
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
}
if err := db.SaveUser(user); err != nil {
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
}
log.Infof("Updated user information successfully")
if previousUsername == currentUser(c) {
setUser(c, user.Username, user.Admin, util.GetDBUserCRC32(user))
}
return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Updated user information successfully"})
}
}
// CreateUser to create new user
func CreateUser(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
data := make(map[string]interface{})
err := json.NewDecoder(c.Request().Body).Decode(&data)
if err != nil {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"})
}
var user model.User
username := data["username"].(string)
password := data["password"].(string)
admin := data["admin"].(bool)
if username == "" || !usernameRegexp.MatchString(username) {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid username"})
} else {
user.Username = username
}
{
_, err := db.GetUserByName(username)
if err == nil {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "This username is taken"})
}
}
hash, err := util.HashPassword(password)
if err != nil {
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
}
user.PasswordHash = hash
user.Admin = admin
if rawIDs, ok := data["server_ids"].([]interface{}); ok {
for _, v := range rawIDs {
if id, ok := v.(string); ok && util.ValidateRecordID(id) {
user.ServerIDs = append(user.ServerIDs, id)
}
}
}
if err := db.SaveUser(user); err != nil {
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
}
log.Infof("Created user successfully")
return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Created user successfully"})
}
}
// RemoveUser handler
func RemoveUser(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
data := make(map[string]interface{})
err := json.NewDecoder(c.Request().Body).Decode(&data)
if err != nil {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"})
}
username := data["username"].(string)
if !usernameRegexp.MatchString(username) {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid username"})
}
if username == currentUser(c) {
return c.JSON(http.StatusForbidden, jsonHTTPResponse{false, "User cannot delete itself"})
}
// delete user from database
if err := db.DeleteUser(username); err != nil {
log.Error("Cannot delete user: ", err)
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot delete user from database"})
}
log.Infof("Removed user: %s", username)
return c.JSON(http.StatusOK, jsonHTTPResponse{true, "User removed"})
}
}
// WireGuardClients handler
func WireGuardClients(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
clientDataList, err := db.GetClients(true)
if err != nil {
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{
false, fmt.Sprintf("Cannot get client list: %v", err),
})
}
return c.Render(http.StatusOK, "clients.html", map[string]interface{}{
"baseData": model.BaseData{Active: "", CurrentUser: currentUser(c), Admin: isAdmin(c)},
"clientDataList": clientDataList,
})
}
}
// 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 {
clientDataList, err := db.GetClients(true)
if err != nil {
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{
false, fmt.Sprintf("Cannot get client list: %v", err),
})
}
for i, clientData := range clientDataList {
clientDataList[i] = util.FillClientSubnetRange(clientData)
}
return c.JSON(http.StatusOK, clientDataList)
}
}
// GetClient handler returns a JSON object of Wireguard client data
func GetClient(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
clientID := c.Param("id")
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 {
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Client not found"})
}
return c.JSON(http.StatusOK, util.FillClientSubnetRange(clientData))
}
}
// 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).
func ListServers(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
servers, err := db.GetServers()
if err != nil {
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{
false, fmt.Sprintf("Cannot get server list: %v", err),
})
}
// non-admins only see servers they have been explicitly granted
if !util.DisableLogin && !isAdmin(c) {
user, err := db.GetUserByName(currentUser(c))
if err != nil {
return c.JSON(http.StatusForbidden, jsonHTTPResponse{false, "Access denied"})
}
allowed := make(map[string]bool, len(user.ServerIDs))
for _, id := range user.ServerIDs {
allowed[id] = true
}
var filtered []model.Server
for _, s := range servers {
if allowed[s.ID] {
filtered = append(filtered, s)
}
}
servers = filtered
}
return c.JSON(http.StatusOK, servers)
}
}
// GetServerClients handler returns a JSON list of clients belonging to a
// single server (step 2 of the multi-server extension). Filtering happens
// in-handler for now since db.GetClients has not yet been switched to a
// server-scoped signature (that's a later step).
func GetServerClients(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
serverID := c.Param("id")
if !util.ValidateRecordID(serverID) {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid server ID"})
}
if _, err := db.GetServerByID(serverID); err != nil {
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Server not found"})
}
clientDataList, err := db.GetClients(false)
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.JSON(http.StatusOK, filtered)
}
}
// GetServerSettings handler returns a single server's per-server settings
// (endpoint address, table, firewall mark, config file path) as JSON.
func GetServerSettings(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
serverID := c.Param("id")
settings, err := db.GetServerSettings(serverID)
if err != nil {
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Server settings not found"})
}
return c.JSON(http.StatusOK, settings)
}
}
// SaveServerSettingsHandler updates a single server's per-server settings.
// Admin-only (registered with handler.NeedsAdmin).
func SaveServerSettingsHandler(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
serverID := c.Param("id")
if _, err := db.GetServerByID(serverID); err != nil {
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Server not found"})
}
var settings model.ServerSetting
if err := c.Bind(&settings); err != nil {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"})
}
settings.UpdatedAt = time.Now().UTC()
if err := db.SaveServerSettings(serverID, settings); err != nil {
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, fmt.Sprintf("Cannot save server settings: %v", err)})
}
log.Infof("Updated settings for server %s", serverID)
return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Updated server settings successfully"})
}
}
// CreateServer handler creates a new WireGuard server (step 3 of the
// multi-server extension). Admin-only. Generates a fresh key pair,
// validates the ID/interface name/subnet, and stores the server plus its
// per-server settings.
func CreateServer(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
type createServerRequest struct {
ID string `json:"id"`
Name string `json:"name"`
Interface string `json:"interface"`
Addresses []string `json:"addresses"`
ListenPort int `json:"listen_port"`
}
var req createServerRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"})
}
if !util.ValidateRecordID(req.ID) {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid server ID (letters, digits, - and _ only)"})
}
if !util.ValidateInterfaceName(req.Interface) {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid interface name (max 15 chars, letters/digits/-/_ only)"})
}
if !util.ValidateServerAddresses(req.Addresses) {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Address ranges must be in CIDR format"})
}
if req.ListenPort <= 0 || req.ListenPort > 65535 {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid listen port"})
}
if req.Name == "" {
req.Name = req.ID
}
if _, err := db.GetServerByID(req.ID); err == nil {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "A server with this ID already exists"})
}
key, err := wgtypes.GeneratePrivateKey()
if err != nil {
log.Error("Cannot generate wireguard key pair: ", err)
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot generate Wireguard key pair"})
}
server := model.Server{
ID: req.ID,
Name: req.Name,
KeyPair: &model.ServerKeypair{
PrivateKey: key.String(),
PublicKey: key.PublicKey().String(),
UpdatedAt: time.Now().UTC(),
},
Interface: &model.ServerInterface{
Name: req.Interface,
Addresses: req.Addresses,
ListenPort: req.ListenPort,
UpdatedAt: time.Now().UTC(),
},
}
if err := db.CreateServer(server); err != nil {
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, fmt.Sprintf("Cannot create server: %v", err)})
}
settings := model.ServerSetting{
ConfigFilePath: fmt.Sprintf("/etc/wireguard/%s.conf", req.Interface),
FirewallMark: util.DefaultFirewallMark,
Table: util.DefaultTable,
UpdatedAt: time.Now().UTC(),
}
if err := db.SaveServerSettings(req.ID, settings); err != nil {
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, fmt.Sprintf("Server created but settings failed: %v", err)})
}
log.Infof("Created server %s (%s)", req.ID, req.Name)
return c.JSON(http.StatusOK, server)
}
}
// RemoveServer handler deletes a server, refusing if any client still
// references it (see store.DeleteServer). Admin-only.
func RemoveServer(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
serverID := c.Param("id")
if err := db.DeleteServer(serverID); err != nil {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, err.Error()})
}
log.Infof("Removed server %s", serverID)
return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Server removed successfully"})
}
}
// NewClient handler
func NewClient(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
var client model.Client
c.Bind(&client)
// Validate Telegram userid if provided
if client.TgUserid != "" {
idNum, err := strconv.ParseInt(client.TgUserid, 10, 64)
if err != nil || idNum == 0 {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Telegram userid must be a non-zero number"})
}
}
// read server information
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("")
check, err := util.ValidateIPAllocation(server.Interface.Addresses, allocatedIPs, client.AllocatedIPs)
if !check {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, fmt.Sprintf("%s", err)})
}
// validate the input AllowedIPs
if util.ValidateAllowedIPs(client.AllowedIPs) == false {
log.Warnf("Invalid Allowed IPs input from user: %v", client.AllowedIPs)
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Allowed IPs must be in CIDR format"})
}
// validate extra AllowedIPs
if util.ValidateExtraAllowedIPs(client.ExtraAllowedIPs) == false {
log.Warnf("Invalid Extra AllowedIPs input from user: %v", client.ExtraAllowedIPs)
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Extra AllowedIPs must be in CIDR format"})
}
// gen ID
guid := xid.New()
client.ID = guid.String()
// gen Wireguard key pair
if client.PublicKey == "" {
key, err := wgtypes.GeneratePrivateKey()
if err != nil {
log.Error("Cannot generate wireguard key pair: ", err)
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot generate Wireguard key pair"})
}
client.PrivateKey = key.String()
client.PublicKey = key.PublicKey().String()
} else {
_, err := wgtypes.ParseKey(client.PublicKey)
if err != nil {
log.Error("Cannot verify wireguard public key: ", err)
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot verify Wireguard public key"})
}
// check for duplicates
clients, err := db.GetClients(false)
if err != nil {
log.Error("Cannot get clients for duplicate check")
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot get clients for duplicate check"})
}
for _, other := range clients {
if other.Client.PublicKey == client.PublicKey {
log.Error("Duplicate Public Key")
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Duplicate Public Key"})
}
}
}
if client.PresharedKey == "" {
presharedKey, err := wgtypes.GenerateKey()
if err != nil {
log.Error("Cannot generated preshared key: ", err)
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{
false, "Cannot generate Wireguard preshared key",
})
}
client.PresharedKey = presharedKey.String()
} else if client.PresharedKey == "-" {
client.PresharedKey = ""
log.Infof("skipped PresharedKey generation for user: %v", client.Name)
} else {
_, err := wgtypes.ParseKey(client.PresharedKey)
if err != nil {
log.Error("Cannot verify wireguard preshared key: ", err)
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot verify Wireguard preshared key"})
}
}
client.CreatedAt = time.Now().UTC()
client.UpdatedAt = client.CreatedAt
// write client to the database
if err := db.SaveClient(client); err != nil {
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{
false, err.Error(),
})
}
log.Infof("Created wireguard client: %v", client)
return c.JSON(http.StatusOK, client)
}
}
// EmailClient handler to send the configuration via email
func EmailClient(db store.IStore, mailer emailer.Emailer, emailSubject, emailContent string) echo.HandlerFunc {
type clientIdEmailPayload struct {
ID string `json:"id"`
Email string `json:"email"`
}
return func(c echo.Context) error {
var payload clientIdEmailPayload
c.Bind(&payload)
// TODO validate email
if _, err := xid.FromString(payload.ID); 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(payload.ID, qrCodeSettings)
if err != nil {
log.Errorf("Cannot generate client id %s config file for downloading: %v", payload.ID, err)
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Client not found"})
}
// build config
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: 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,"))
if err != nil {
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "decoding: " + err.Error()})
}
qrAtt := emailer.Attachment{Name: "wg.png", Data: qrdata}
attachments = []emailer.Attachment{cfgAtt, qrAtt}
} else {
attachments = []emailer.Attachment{cfgAtt}
}
err = mailer.Send(
clientData.Client.Name,
payload.Email,
emailSubject,
emailContent,
attachments,
)
if err != nil {
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
}
return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Email sent successfully"})
}
}
// SendTelegramClient handler to send the configuration via Telegram
func SendTelegramClient(db store.IStore) echo.HandlerFunc {
type clientIdUseridPayload struct {
ID string `json:"id"`
Userid string `json:"userid"`
}
return func(c echo.Context) error {
var payload clientIdUseridPayload
c.Bind(&payload)
clientData, err := db.GetClientByID(payload.ID, model.QRCodeSettings{Enabled: false})
if err != nil {
log.Errorf("Cannot generate client id %s config file for downloading: %v", payload.ID, err)
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Client not found"})
}
// build config
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
if clientData.Client.PrivateKey != "" {
qrData, err = qrcode.Encode(config, qrcode.Medium, 512)
if err != nil {
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "qr gen: " + err.Error()})
}
}
userid, err := strconv.ParseInt(clientData.Client.TgUserid, 10, 64)
if err != nil {
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "userid: " + err.Error()})
}
err = telegram.SendConfig(userid, clientData.Client.Name, configData, qrData, false)
if err != nil {
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
}
return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Telegram message sent successfully"})
}
}
// UpdateClient handler to update client information
func UpdateClient(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
var _client model.Client
c.Bind(&_client)
if _, err := xid.FromString(_client.ID); err != nil {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid client ID"})
}
// validate client existence
clientData, err := db.GetClientByID(_client.ID, model.QRCodeSettings{Enabled: false})
if err != nil {
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)
if err != nil || idNum == 0 {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Telegram userid must be a non-zero number"})
}
}
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),
})
}
client := *clientData.Client
// validate the input Allocation IPs
allocatedIPs, err := util.GetAllocatedIPs(client.ID)
check, err := util.ValidateIPAllocation(server.Interface.Addresses, allocatedIPs, _client.AllocatedIPs)
if !check {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, fmt.Sprintf("%s", err)})
}
// validate the input AllowedIPs
if util.ValidateAllowedIPs(_client.AllowedIPs) == false {
log.Warnf("Invalid Allowed IPs input from user: %v", _client.AllowedIPs)
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Allowed IPs must be in CIDR format"})
}
if util.ValidateExtraAllowedIPs(_client.ExtraAllowedIPs) == false {
log.Warnf("Invalid Allowed IPs input from user: %v", _client.ExtraAllowedIPs)
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Extra Allowed IPs must be in CIDR format"})
}
// update Wireguard Client PublicKey
if client.PublicKey != _client.PublicKey && _client.PublicKey != "" {
_, err := wgtypes.ParseKey(_client.PublicKey)
if err != nil {
log.Error("Cannot verify provided Wireguard public key: ", err)
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot verify provided Wireguard public key"})
}
// check for duplicates
clients, err := db.GetClients(false)
if err != nil {
log.Error("Cannot get client list for duplicate public key check")
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot get client list for duplicate public key check"})
}
for _, other := range clients {
if other.Client.PublicKey == _client.PublicKey {
log.Error("Duplicate Public Key")
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Duplicate Public Key"})
}
}
// When replacing any PublicKey, discard any locally stored Wireguard Client PrivateKey
// Client PubKey no longer corresponds to locally stored PrivKey.
// QR code (needs PrivateKey) for this client is no longer possible now.
if client.PrivateKey != "" {
client.PrivateKey = ""
}
}
// update Wireguard Client PresharedKey
if client.PresharedKey != _client.PresharedKey && _client.PresharedKey != "" {
_, err := wgtypes.ParseKey(_client.PresharedKey)
if err != nil {
log.Error("Cannot verify provided Wireguard preshared key: ", err)
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot verify provided Wireguard preshared key"})
}
}
// map new data
client.Name = _client.Name
client.Email = _client.Email
client.TgUserid = _client.TgUserid
client.Enabled = _client.Enabled
client.UseServerDNS = _client.UseServerDNS
client.AllocatedIPs = _client.AllocatedIPs
client.AllowedIPs = _client.AllowedIPs
client.ExtraAllowedIPs = _client.ExtraAllowedIPs
client.Endpoint = _client.Endpoint
client.PublicKey = _client.PublicKey
client.PresharedKey = _client.PresharedKey
client.UpdatedAt = time.Now().UTC()
client.AdditionalNotes = strings.ReplaceAll(strings.Trim(_client.AdditionalNotes, "\r\n"), "\r\n", "\n")
// write to the database
if err := db.SaveClient(client); err != nil {
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
}
log.Infof("Updated client information successfully => %v", client)
return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Updated client successfully"})
}
}
// SetClientStatus handler to enable / disable a client
func SetClientStatus(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
data := make(map[string]interface{})
err := json.NewDecoder(c.Request().Body).Decode(&data)
if err != nil {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"})
}
clientID := data["id"].(string)
status := data["status"].(bool)
if _, err := xid.FromString(clientID); err != nil {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid client ID"})
}
clientData, err := db.GetClientByID(clientID, model.QRCodeSettings{Enabled: false})
if err != nil {
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
if err := db.SaveClient(client); err != nil {
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
}
log.Infof("Changed client %s enabled status to %v", client.ID, status)
return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Changed client status successfully"})
}
}
// DownloadClient handler
func DownloadClient(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
clientID := c.QueryParam("clientid")
if clientID == "" {
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Missing clientid parameter"})
}
if _, err := xid.FromString(clientID); err != nil {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid client ID"})
}
clientData, err := db.GetClientByID(clientID, model.QRCodeSettings{Enabled: false})
if err != nil {
log.Errorf("Cannot generate client id %s config file for downloading: %v", clientID, err)
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
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 := buildEffectiveSettings(db, clientServerID)
if err != nil {
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
}
config := util.BuildClientConfig(*clientData.Client, server, globalSettings)
// create io reader from string
reader := strings.NewReader(config)
// set response header for downloading
c.Response().Header().Set(echo.HeaderContentDisposition, fmt.Sprintf("attachment; filename=%s.conf", clientData.Client.Name))
return c.Stream(http.StatusOK, "text/conf", reader)
}
}
// RemoveClient handler
func RemoveClient(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
client := new(model.Client)
c.Bind(client)
if _, err := xid.FromString(client.ID); err != nil {
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 {
log.Error("Cannot delete wireguard client: ", err)
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot delete client from database"})
}
log.Infof("Removed wireguard client: %v", client)
return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Client removed"})
}
}
// UpdateServerInterfaceHandler updates the WireGuard interface settings
// (addresses, listen port, up/down scripts) for a single server, identified
// by :id. This is the multi-server-aware handler surfaced from the "All
// Servers" page; the per-server registry (servers/<id>.json) is the single
// source of truth for every server, including the default one.
func UpdateServerInterfaceHandler(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
serverID := c.Param("id")
if _, err := db.GetServerByID(serverID); err != nil {
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Server not found"})
}
var serverInterface model.ServerInterface
if err := c.Bind(&serverInterface); err != nil {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"})
}
// validate the input addresses
if util.ValidateServerAddresses(serverInterface.Addresses) == false {
log.Warnf("Invalid server interface addresses input from user: %v", serverInterface.Addresses)
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Interface IP address must be in CIDR format"})
}
serverInterface.UpdatedAt = time.Now().UTC()
if err := db.UpdateServerInterface(serverID, serverInterface); err != nil {
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, fmt.Sprintf("Cannot update server interface: %v", err)})
}
log.Infof("Updated wireguard server interface settings for server %s: %v", serverID, serverInterface)
return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Updated interface addresses successfully"})
}
}
// UpdateServerKeyPairHandler generates a fresh WireGuard key pair for a
// single server, identified by :id. The per-server registry
// (servers/<id>.json) is the single source of truth for every server,
// including the default one.
func UpdateServerKeyPairHandler(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
serverID := c.Param("id")
if _, err := db.GetServerByID(serverID); err != nil {
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Server not found"})
}
key, err := wgtypes.GeneratePrivateKey()
if err != nil {
log.Error("Cannot generate wireguard key pair: ", err)
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot generate Wireguard key pair"})
}
var serverKeyPair model.ServerKeypair
serverKeyPair.PrivateKey = key.String()
serverKeyPair.PublicKey = key.PublicKey().String()
serverKeyPair.UpdatedAt = time.Now().UTC()
if err := db.UpdateServerKeyPair(serverID, serverKeyPair); err != nil {
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, fmt.Sprintf("Cannot update server key pair: %v", err)})
}
log.Infof("Updated wireguard server key pair for server %s", serverID)
return c.JSON(http.StatusOK, serverKeyPair)
}
}
// GlobalSettings handler
func GlobalSettings(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
globalSettings, err := db.GetGlobalSettings()
if err != nil {
log.Error("Cannot get global settings: ", err)
}
return c.Render(http.StatusOK, "global_settings.html", map[string]interface{}{
"baseData": model.BaseData{Active: "global-settings", CurrentUser: currentUser(c), Admin: isAdmin(c)},
"globalSettings": globalSettings,
})
}
}
// Status handler
func Status(db store.IStore) echo.HandlerFunc {
type PeerVM struct {
Name string
Email string
PublicKey string
ReceivedBytes int64
TransmitBytes int64
LastHandshakeTime time.Time
LastHandshakeRel time.Duration
Connected bool
AllocatedIP string
Endpoint string
}
type DeviceVM struct {
Name string
Peers []PeerVM
}
return func(c echo.Context) error {
wgClient, err := wgctrl.New()
if err != nil {
return c.Render(http.StatusInternalServerError, "status.html", map[string]interface{}{
"baseData": model.BaseData{Active: "status", CurrentUser: currentUser(c), Admin: isAdmin(c)},
"error": err.Error(),
"devices": nil,
})
}
devices, err := wgClient.Devices()
if err != nil {
return c.Render(http.StatusInternalServerError, "status.html", map[string]interface{}{
"baseData": model.BaseData{Active: "status", CurrentUser: currentUser(c), Admin: isAdmin(c)},
"error": err.Error(),
"devices": nil,
})
}
devicesVm := make([]DeviceVM, 0, len(devices))
if len(devices) > 0 {
m := make(map[string]*model.Client)
clients, err := db.GetClients(false)
if err != nil {
return c.Render(http.StatusInternalServerError, "status.html", map[string]interface{}{
"baseData": model.BaseData{Active: "status", CurrentUser: currentUser(c), Admin: isAdmin(c)},
"error": err.Error(),
"devices": nil,
})
}
for i := range clients {
if clients[i].Client != nil {
m[clients[i].Client.PublicKey] = clients[i].Client
}
}
conv := map[bool]int{true: 1, false: 0}
for i := range devices {
devVm := DeviceVM{Name: devices[i].Name}
for j := range devices[i].Peers {
var allocatedIPs string
for _, ip := range devices[i].Peers[j].AllowedIPs {
if len(allocatedIPs) > 0 {
allocatedIPs += "</br>"
}
allocatedIPs += ip.String()
}
pVm := PeerVM{
PublicKey: devices[i].Peers[j].PublicKey.String(),
ReceivedBytes: devices[i].Peers[j].ReceiveBytes,
TransmitBytes: devices[i].Peers[j].TransmitBytes,
LastHandshakeTime: devices[i].Peers[j].LastHandshakeTime,
LastHandshakeRel: time.Since(devices[i].Peers[j].LastHandshakeTime),
AllocatedIP: allocatedIPs,
}
pVm.Connected = pVm.LastHandshakeRel.Minutes() < 3.
if isAdmin(c) {
pVm.Endpoint = devices[i].Peers[j].Endpoint.String()
}
if _client, ok := m[pVm.PublicKey]; ok {
pVm.Name = _client.Name
pVm.Email = _client.Email
}
devVm.Peers = append(devVm.Peers, pVm)
}
sort.SliceStable(devVm.Peers, func(i, j int) bool { return devVm.Peers[i].Name < devVm.Peers[j].Name })
sort.SliceStable(devVm.Peers, func(i, j int) bool { return conv[devVm.Peers[i].Connected] > conv[devVm.Peers[j].Connected] })
devicesVm = append(devicesVm, devVm)
}
}
return c.Render(http.StatusOK, "status.html", map[string]interface{}{
"baseData": model.BaseData{Active: "status", CurrentUser: currentUser(c), Admin: isAdmin(c)},
"devices": devicesVm,
"error": "",
})
}
}
// GlobalSettingSubmit handler to update the global settings
func GlobalSettingSubmit(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
var globalSettings model.GlobalSetting
c.Bind(&globalSettings)
// validate the input dns server list
if util.ValidateIPAddressList(globalSettings.DNSServers) == false {
log.Warnf("Invalid DNS server list input from user: %v", globalSettings.DNSServers)
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Invalid DNS server address"})
}
globalSettings.UpdatedAt = time.Now().UTC()
// write the app-wide settings (DNS/MTU/PersistentKeepalive); per-server
// concerns (EndpointAddress/Table/FirewallMark/ConfigFilePath) live
// solely in the per-server registry's ServerSetting record, edited via
// the per-server settings editor, and are not duplicated here.
if err := db.SaveGlobalSettings(globalSettings); err != nil {
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot generate Wireguard key pair"})
}
log.Infof("Updated global settings: %v", globalSettings)
return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Updated global settings successfully"})
}
}
// MachineIPAddresses handler to get local interface ip addresses
func MachineIPAddresses() echo.HandlerFunc {
return func(c echo.Context) error {
// get private ip addresses
interfaceList, err := util.GetInterfaceIPs()
if err != nil {
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot get machine ip addresses"})
}
// get public ip address
// TODO: Remove the go-external-ip dependency
publicInterface, err := util.GetPublicIP()
if err != nil {
log.Warn("Cannot get machine public ip address: ", err)
} else {
// prepend public ip to the list
interfaceList = append([]model.Interface{publicInterface}, interfaceList...)
}
return c.JSON(http.StatusOK, interfaceList)
}
}
// GetOrderedSubnetRanges handler to get the ordered list of subnet ranges
func GetOrderedSubnetRanges() echo.HandlerFunc {
return func(c echo.Context) error {
return c.JSON(http.StatusOK, util.SubnetRangesOrder)
}
}
// SuggestIPAllocation handler to get the list of ip address for client
func SuggestIPAllocation(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
server, err := db.GetServerByID(util.DefaultServerID)
if err != nil {
log.Error("Cannot fetch server config from database: ", err)
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, err.Error()})
}
// return the list of suggestedIPs
// we take the first available ip address from
// each server's network addresses.
suggestedIPs := make([]string, 0)
allocatedIPs, err := util.GetAllocatedIPs("")
if err != nil {
log.Error("Cannot suggest ip allocation. Failed to get list of allocated ip addresses: ", err)
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{
false, "Cannot suggest ip allocation: failed to get list of allocated ip addresses",
})
}
sr := c.QueryParam("sr")
searchCIDRList := make([]string, 0)
found := false
// Use subnet range or default to interface addresses
if util.SubnetRanges[sr] != nil {
for _, cidr := range util.SubnetRanges[sr] {
searchCIDRList = append(searchCIDRList, cidr.String())
}
} else {
searchCIDRList = append(searchCIDRList, server.Interface.Addresses...)
}
// Save only unique IPs
ipSet := make(map[string]struct{})
for _, cidr := range searchCIDRList {
ip, err := util.GetAvailableIP(cidr, allocatedIPs, server.Interface.Addresses)
if err != nil {
log.Error("Failed to get available ip from a CIDR: ", err)
continue
}
found = true
if strings.Contains(ip, ":") {
ipSet[fmt.Sprintf("%s/128", ip)] = struct{}{}
} else {
ipSet[fmt.Sprintf("%s/32", ip)] = struct{}{}
}
}
if !found {
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{
false,
"Cannot suggest ip allocation: failed to get available ip. Try a different subnet or deallocate some ips.",
})
}
for ip := range ipSet {
suggestedIPs = append(suggestedIPs, ip)
}
return c.JSON(http.StatusOK, suggestedIPs)
}
}
// 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 {
serverID := resolveServerID(c)
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"})
}
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 {
log.Error("Cannot get users config: ", err)
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot get users config"})
}
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)
if err != nil {
log.Error("Cannot apply server config: ", err)
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{
false, fmt.Sprintf("Cannot apply server config: %v", err),
})
}
err = util.UpdateHashes(db)
if err != nil {
log.Error("Cannot update hashes: ", err)
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{
false, fmt.Sprintf("Cannot update hashes: %v", err),
})
}
return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Applied server config successfully"})
}
}
// GetHashesChanges handler returns if database hashes have changed
func GetHashesChanges(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
if util.HashesChanged(db) {
return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Hashes changed"})
} else {
return c.JSON(http.StatusOK, jsonHTTPResponse{false, "Hashes not changed"})
}
}
}
// DownloadBackup handler builds a tar.gz snapshot of the entire jsondb
// directory (all servers/clients/users/settings, everything needed to
// restore this installation) and returns it as a file download. If a
// non-empty "passphrase" is given in the JSON body, the archive is
// encrypted (AES-256-GCM via backup.Encrypt) before being returned.
// Admin-only. The archive is never transmitted anywhere automatically -
// it only ever goes out as this one HTTP response to the requesting admin.
func DownloadBackup(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
var payload struct {
Passphrase string `json:"passphrase"`
}
// best-effort bind; an empty/absent body just means "no encryption"
c.Bind(&payload)
archiveData, err := backup.BuildArchive(db.GetPath())
if err != nil {
log.Error("Cannot build backup archive: ", err)
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot build backup archive"})
}
filename := fmt.Sprintf("wireguard-ui-multi-backup-%s.tar.gz", time.Now().UTC().Format("20060102-150405"))
if payload.Passphrase != "" {
archiveData, err = backup.Encrypt(archiveData, payload.Passphrase)
if err != nil {
log.Error("Cannot encrypt backup archive: ", err)
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot encrypt backup archive"})
}
filename += ".enc"
}
c.Response().Header().Set(echo.HeaderContentDisposition, fmt.Sprintf("attachment; filename=%s", filename))
return c.Blob(http.StatusOK, "application/octet-stream", archiveData)
}
}
// AboutPage handler
func AboutPage() echo.HandlerFunc {
return func(c echo.Context) error {
return c.Render(http.StatusOK, "about.html", map[string]interface{}{
"baseData": model.BaseData{Active: "about", CurrentUser: currentUser(c), Admin: isAdmin(c)},
})
}
}