wireguard-ui/handler/api_v1_clients.go

703 lines
21 KiB
Go

package handler
import (
"encoding/base64"
"fmt"
"io/fs"
"net/http"
"sort"
"strings"
"time"
"github.com/labstack/echo/v4"
"github.com/labstack/gommon/log"
"github.com/rs/xid"
"golang.zx2c4.com/wireguard/wgctrl"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"github.com/xuri/excelize/v2"
"github.com/DigitalTolk/wireguard-ui/emailer"
"github.com/DigitalTolk/wireguard-ui/model"
"github.com/DigitalTolk/wireguard-ui/store"
"github.com/DigitalTolk/wireguard-ui/util"
)
// connectedThreshold defines how recently a peer must have handshaked to be considered connected
var connectedThreshold = 3 * time.Minute
func connectedPeerKeys() map[string]bool {
keys := make(map[string]bool)
wgClient, err := wgctrl.New()
if err != nil {
log.Warnf("Cannot create wgctrl client: %v", err)
return keys
}
defer wgClient.Close()
devices, err := wgClient.Devices()
if err != nil {
log.Warnf("Cannot list WireGuard devices: %v", err)
return keys
}
for _, dev := range devices {
for _, peer := range dev.Peers {
if time.Since(peer.LastHandshakeTime) < connectedThreshold {
keys[peer.PublicKey.String()] = true
}
}
}
return keys
}
// APIListClients returns all WireGuard clients
func APIListClients(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
clientDataList, err := db.GetClients(false)
if err != nil {
return apiInternalError(c, fmt.Sprintf("Cannot get client list: %v", err))
}
search := strings.ToLower(c.QueryParam("search"))
status := c.QueryParam("status")
// Only query WireGuard when connected/disconnected filter is active
var connKeys map[string]bool
if status == "connected" || status == "disconnected" {
connKeys = connectedPeerKeys()
}
filtered := make([]model.ClientData, 0, len(clientDataList))
for _, clientData := range clientDataList {
clientData = util.FillClientSubnetRange(clientData)
cl := clientData.Client
// filter by status
if status == "enabled" && !cl.Enabled {
continue
}
if status == "disabled" && cl.Enabled {
continue
}
if status == "connected" && !connKeys[cl.PublicKey] {
continue
}
if status == "disconnected" && connKeys[cl.PublicKey] {
continue
}
// filter by search
if search != "" {
nameLower := strings.ToLower(cl.Name)
emailLower := strings.ToLower(cl.Email)
ipsLower := strings.ToLower(strings.Join(cl.AllocatedIPs, " "))
if !strings.Contains(nameLower, search) && !strings.Contains(emailLower, search) && !strings.Contains(ipsLower, search) {
continue
}
}
filtered = append(filtered, clientData)
}
return c.JSON(http.StatusOK, filtered)
}
}
// APIGetClient returns a single client by ID
func APIGetClient(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
clientID := c.Param("id")
if _, err := xid.FromString(clientID); err != nil {
return apiBadRequest(c, "Invalid client ID")
}
clientData, err := db.GetClientByID(clientID, util.DefaultQRCodeSettings)
if err != nil {
return apiNotFound(c, "Client not found")
}
return c.JSON(http.StatusOK, util.FillClientSubnetRange(clientData))
}
}
// APICreateClient creates a new WireGuard client
func APICreateClient(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
var client model.Client
if err := c.Bind(&client); err != nil {
return apiBadRequest(c, "Invalid request body")
}
// validate email is required
if strings.TrimSpace(client.Email) == "" {
return apiBadRequest(c, "Email is required")
}
server, err := db.GetServer()
if err != nil {
return apiInternalError(c, "Cannot fetch server config")
}
// validate allocated IPs
allocatedIPs, err := db.GetAllocatedIPs("")
if err != nil {
return apiInternalError(c, "Cannot get allocated IPs")
}
check, err := util.ValidateIPAllocation(server.Interface.Addresses, allocatedIPs, client.AllocatedIPs)
if !check {
return apiBadRequest(c, err.Error())
}
if !util.ValidateAllowedIPs(client.AllowedIPs) {
return apiBadRequest(c, "Allowed IPs must be in CIDR format")
}
if !util.ValidateExtraAllowedIPs(client.ExtraAllowedIPs) {
return apiBadRequest(c, "Extra AllowedIPs must be in CIDR format")
}
// generate ID
client.ID = xid.New().String()
// generate keypair
if client.PublicKey == "" {
key, err := wgtypes.GeneratePrivateKey()
if err != nil {
return apiInternalError(c, "Cannot generate WireGuard key pair")
}
client.PrivateKey = key.String()
client.PublicKey = key.PublicKey().String()
} else {
if _, err := wgtypes.ParseKey(client.PublicKey); err != nil {
return apiBadRequest(c, "Cannot verify WireGuard public key")
}
// check duplicates
clients, err := db.GetClients(false)
if err != nil {
return apiInternalError(c, "Cannot check for duplicate keys")
}
for _, other := range clients {
if other.Client.PublicKey == client.PublicKey {
return apiBadRequest(c, "Duplicate public key")
}
}
}
// generate preshared key
switch client.PresharedKey {
case "":
psk, err := wgtypes.GenerateKey()
if err != nil {
return apiInternalError(c, "Cannot generate preshared key")
}
client.PresharedKey = psk.String()
case "-":
client.PresharedKey = ""
default:
if _, err := wgtypes.ParseKey(client.PresharedKey); err != nil {
return apiBadRequest(c, "Cannot verify preshared key")
}
}
client.CreatedAt = time.Now().UTC()
client.UpdatedAt = client.CreatedAt
if err := db.SaveClient(client); err != nil {
return apiInternalError(c, err.Error())
}
log.Infof("Created wireguard client: %v", client.Name)
auditLogEvent(c, "client.create", "client", client.ID, map[string]string{"name": client.Name, "email": client.Email})
return c.JSON(http.StatusCreated, client)
}
}
// APIUpdateClient updates an existing client
func APIUpdateClient(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
clientID := c.Param("id")
if _, err := xid.FromString(clientID); err != nil {
return apiBadRequest(c, "Invalid client ID")
}
var _client model.Client
if err := c.Bind(&_client); err != nil {
return apiBadRequest(c, "Invalid request body")
}
clientData, err := db.GetClientByID(clientID, model.QRCodeSettings{Enabled: false})
if err != nil {
return apiNotFound(c, "Client not found")
}
server, err := db.GetServer()
if err != nil {
return apiInternalError(c, "Cannot fetch server config")
}
client := *clientData.Client
allocatedIPs, err := db.GetAllocatedIPs(client.ID)
if err != nil {
return apiInternalError(c, "Cannot get allocated IPs")
}
check, err := util.ValidateIPAllocation(server.Interface.Addresses, allocatedIPs, _client.AllocatedIPs)
if !check {
return apiBadRequest(c, err.Error())
}
if !util.ValidateAllowedIPs(_client.AllowedIPs) {
return apiBadRequest(c, "Allowed IPs must be in CIDR format")
}
if !util.ValidateExtraAllowedIPs(_client.ExtraAllowedIPs) {
return apiBadRequest(c, "Extra Allowed IPs must be in CIDR format")
}
// handle public key change
if client.PublicKey != _client.PublicKey && _client.PublicKey != "" {
if _, err := wgtypes.ParseKey(_client.PublicKey); err != nil {
return apiBadRequest(c, "Cannot verify WireGuard public key")
}
clients, err := db.GetClients(false)
if err != nil {
return apiInternalError(c, "Cannot check for duplicate keys")
}
for _, other := range clients {
if other.Client.PublicKey == _client.PublicKey {
return apiBadRequest(c, "Duplicate public key")
}
}
if client.PrivateKey != "" {
client.PrivateKey = ""
}
}
// handle preshared key change
if client.PresharedKey != _client.PresharedKey && _client.PresharedKey != "" {
if _, err := wgtypes.ParseKey(_client.PresharedKey); err != nil {
return apiBadRequest(c, "Cannot verify preshared key")
}
}
client.Name = _client.Name
// email is immutable after creation — preserve original
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")
if err := db.SaveClient(client); err != nil {
return apiInternalError(c, err.Error())
}
log.Infof("Updated client: %v", client.Name)
auditLogEvent(c, "client.update", "client", client.ID, map[string]string{"name": client.Name, "email": client.Email})
return c.JSON(http.StatusOK, client)
}
}
// APIPatchClientStatus enables/disables a client
func APIPatchClientStatus(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
clientID := c.Param("id")
if _, err := xid.FromString(clientID); err != nil {
return apiBadRequest(c, "Invalid client ID")
}
var body struct {
Enabled bool `json:"enabled"`
}
if err := c.Bind(&body); err != nil {
return apiBadRequest(c, "Invalid request body")
}
clientData, err := db.GetClientByID(clientID, model.QRCodeSettings{Enabled: false})
if err != nil {
return apiNotFound(c, "Client not found")
}
client := *clientData.Client
client.Enabled = body.Enabled
if err := db.SaveClient(client); err != nil {
return apiInternalError(c, err.Error())
}
action := "client.disable"
if body.Enabled {
action = "client.enable"
}
log.Infof("Changed client %s enabled status to %v", client.ID, body.Enabled)
auditLogEvent(c, action, "client", client.ID, map[string]string{"name": client.Name, "email": client.Email})
return c.JSON(http.StatusOK, client)
}
}
// APIDeleteClient deletes a client
func APIDeleteClient(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
clientID := c.Param("id")
if _, err := xid.FromString(clientID); err != nil {
return apiBadRequest(c, "Invalid client ID")
}
if err := db.DeleteClient(clientID); err != nil {
return apiInternalError(c, "Cannot delete client")
}
log.Infof("Deleted wireguard client: %s", clientID)
auditLogEvent(c, "client.delete", "client", clientID, nil)
return c.NoContent(http.StatusNoContent)
}
}
// APIDownloadClientConfig returns the .conf file for a client
func APIDownloadClientConfig(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
clientID := c.Param("id")
if _, err := xid.FromString(clientID); err != nil {
return apiBadRequest(c, "Invalid client ID")
}
clientData, err := db.GetClientByID(clientID, model.QRCodeSettings{Enabled: false})
if err != nil {
return apiNotFound(c, "Client not found")
}
server, err := db.GetServer()
if err != nil {
return apiInternalError(c, "Cannot get server config")
}
globalSettings, err := db.GetGlobalSettings()
if err != nil {
return apiInternalError(c, "Cannot get global settings")
}
config := util.BuildClientConfig(*clientData.Client, server, globalSettings)
c.Response().Header().Set(echo.HeaderContentDisposition, fmt.Sprintf("attachment; filename=%s.conf", clientData.Client.Name))
auditLogEvent(c, "client.config.download", "client", clientID, map[string]string{"name": clientData.Client.Name, "email": clientData.Client.Email})
return c.Stream(http.StatusOK, "text/conf", strings.NewReader(config))
}
}
// APIGetClientQRCode returns the QR code for a client
func APIGetClientQRCode(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
clientID := c.Param("id")
if _, err := xid.FromString(clientID); err != nil {
return apiBadRequest(c, "Invalid client ID")
}
clientData, err := db.GetClientByID(clientID, util.DefaultQRCodeSettings)
if err != nil {
return apiNotFound(c, "Client not found")
}
return c.JSON(http.StatusOK, map[string]string{
"qr_code": clientData.QRCode,
})
}
}
// APIEmailClient sends the client config via email
func APIEmailClient(db store.IStore, mailer emailer.Emailer, emailSubject, emailContent string) echo.HandlerFunc {
return func(c echo.Context) error {
clientID := c.Param("id")
if _, err := xid.FromString(clientID); err != nil {
return apiBadRequest(c, "Invalid client ID")
}
var body struct {
Email string `json:"email"`
}
if err := c.Bind(&body); err != nil {
return apiBadRequest(c, "Invalid request body")
}
qrCodeSettings := model.QRCodeSettings{Enabled: true, IncludeDNS: true, IncludeMTU: true}
clientData, err := db.GetClientByID(clientID, qrCodeSettings)
if err != nil {
return apiNotFound(c, "Client not found")
}
server, _ := db.GetServer()
globalSettings, _ := db.GetGlobalSettings()
config := util.BuildClientConfig(*clientData.Client, server, globalSettings)
cfgAtt := emailer.Attachment{Name: "wg0.conf", 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 apiInternalError(c, "Cannot decode QR code")
}
attachments = []emailer.Attachment{cfgAtt, {Name: "wg.png", Data: qrdata}}
} else {
attachments = []emailer.Attachment{cfgAtt}
}
err = mailer.Send(clientData.Client.Name, body.Email, emailSubject, emailContent, attachments)
if err != nil {
return apiInternalError(c, err.Error())
}
auditLogEvent(c, "client.config.email", "client", clientID, map[string]string{"name": clientData.Client.Name, "email": clientData.Client.Email, "sent_to": body.Email})
return c.JSON(http.StatusOK, map[string]string{"message": "Email sent successfully"})
}
}
// APISuggestClientIPs suggests available IP addresses
func APISuggestClientIPs(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
server, err := db.GetServer()
if err != nil {
return apiInternalError(c, "Cannot fetch server config")
}
allocatedIPs, err := db.GetAllocatedIPs("")
if err != nil {
return apiInternalError(c, "Cannot get allocated IPs")
}
sr := c.QueryParam("sr")
searchCIDRList := make([]string, 0)
if util.SubnetRanges[sr] != nil {
for _, cidr := range util.SubnetRanges[sr] {
searchCIDRList = append(searchCIDRList, cidr.String())
}
} else {
searchCIDRList = append(searchCIDRList, server.Interface.Addresses...)
}
ipSet := make(map[string]struct{})
found := false
for _, cidr := range searchCIDRList {
ip, err := util.GetAvailableIP(cidr, allocatedIPs, server.Interface.Addresses)
if err != nil {
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 apiInternalError(c, "No available IPs. Try a different subnet or deallocate some IPs.")
}
suggestedIPs := make([]string, 0, len(ipSet))
for ip := range ipSet {
suggestedIPs = append(suggestedIPs, ip)
}
return c.JSON(http.StatusOK, suggestedIPs)
}
}
// APIMachineIPs returns local machine IP addresses
func APIMachineIPs() echo.HandlerFunc {
return func(c echo.Context) error {
interfaceList, err := util.GetInterfaceIPs()
if err != nil {
return apiInternalError(c, "Cannot get machine IP addresses")
}
publicInterface, err := util.GetPublicIP()
if err == nil {
interfaceList = append([]model.Interface{publicInterface}, interfaceList...)
}
return c.JSON(http.StatusOK, interfaceList)
}
}
// APISubnetRanges returns the ordered list of subnet ranges
func APISubnetRanges() echo.HandlerFunc {
return func(c echo.Context) error {
return c.JSON(http.StatusOK, util.SubnetRangesOrder)
}
}
// APIServerStatus returns WireGuard status with connected peers
func APIServerStatus(db store.IStore) echo.HandlerFunc {
type PeerStatus struct {
Name string `json:"name"`
Email string `json:"email"`
PublicKey string `json:"public_key"`
ReceivedBytes int64 `json:"received_bytes"`
TransmitBytes int64 `json:"transmit_bytes"`
LastHandshakeTime time.Time `json:"last_handshake_time"`
LastHandshakeRel time.Duration `json:"last_handshake_rel"`
Connected bool `json:"connected"`
AllocatedIP string `json:"allocated_ip"`
Endpoint string `json:"endpoint,omitempty"`
}
type DeviceStatus struct {
Name string `json:"name"`
Peers []PeerStatus `json:"peers"`
}
return func(c echo.Context) error {
wgClient, err := wgctrl.New()
if err != nil {
return apiInternalError(c, err.Error())
}
defer wgClient.Close()
devices, err := wgClient.Devices()
if err != nil {
return apiInternalError(c, err.Error())
}
devicesStatus := make([]DeviceStatus, 0, len(devices))
if len(devices) > 0 {
m := make(map[string]*model.Client)
clients, _ := db.GetClients(false)
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 {
dev := DeviceStatus{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 += ", "
}
allocatedIPs += ip.String()
}
p := PeerStatus{
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,
}
p.Connected = p.LastHandshakeRel < connectedThreshold
if isAdmin(c) && devices[i].Peers[j].Endpoint != nil {
p.Endpoint = devices[i].Peers[j].Endpoint.String()
}
if cl, ok := m[p.PublicKey]; ok {
p.Name = cl.Name
p.Email = cl.Email
}
dev.Peers = append(dev.Peers, p)
}
sort.SliceStable(dev.Peers, func(a, b int) bool { return dev.Peers[a].Name < dev.Peers[b].Name })
sort.SliceStable(dev.Peers, func(a, b int) bool { return conv[dev.Peers[a].Connected] > conv[dev.Peers[b].Connected] })
devicesStatus = append(devicesStatus, dev)
}
}
return c.JSON(http.StatusOK, devicesStatus)
}
}
// APIApplyServerConfig writes the wg0.conf and updates hashes
func APIApplyServerConfig(db store.IStore, tmplDir fs.FS) echo.HandlerFunc {
return func(c echo.Context) error {
server, err := db.GetServer()
if err != nil {
return apiInternalError(c, "Cannot get server config")
}
clients, err := db.GetClients(false)
if err != nil {
return apiInternalError(c, "Cannot get client config")
}
users, err := db.GetUsers()
if err != nil {
return apiInternalError(c, "Cannot get users config")
}
settings, err := db.GetGlobalSettings()
if err != nil {
return apiInternalError(c, "Cannot get global settings")
}
if err := util.WriteWireGuardServerConfig(tmplDir, server, clients, users, settings); err != nil {
return apiInternalError(c, fmt.Sprintf("Cannot apply config: %v", err))
}
if err := util.UpdateHashes(db); err != nil {
return apiInternalError(c, fmt.Sprintf("Cannot update hashes: %v", err))
}
auditLogEvent(c, "server.config.apply", "server", "config", nil)
return c.JSON(http.StatusOK, map[string]string{"message": "Config applied successfully"})
}
}
// APIExportClients exports all clients as an Excel file
func APIExportClients(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
clientDataList, err := db.GetClients(false)
if err != nil {
return apiInternalError(c, "Cannot get client list")
}
f := excelize.NewFile()
sheet := "Clients"
f.SetSheetName("Sheet1", sheet)
headers := []string{"Name", "Email", "Allocated IPs", "Allowed IPs", "Extra Allowed IPs", "Enabled", "Created", "Updated"}
for i, h := range headers {
cell, _ := excelize.CoordinatesToCellName(i+1, 1)
f.SetCellValue(sheet, cell, h)
}
style, _ := f.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true},
})
f.SetCellStyle(sheet, "A1", fmt.Sprintf("%s1", string(rune('A'+len(headers)-1))), style)
for row, cd := range clientDataList {
cl := cd.Client
r := row + 2
f.SetCellValue(sheet, fmt.Sprintf("A%d", r), cl.Name)
f.SetCellValue(sheet, fmt.Sprintf("B%d", r), cl.Email)
f.SetCellValue(sheet, fmt.Sprintf("C%d", r), strings.Join(cl.AllocatedIPs, ", "))
f.SetCellValue(sheet, fmt.Sprintf("D%d", r), strings.Join(cl.AllowedIPs, ", "))
f.SetCellValue(sheet, fmt.Sprintf("E%d", r), strings.Join(cl.ExtraAllowedIPs, ", "))
enabled := "No"
if cl.Enabled {
enabled = "Yes"
}
f.SetCellValue(sheet, fmt.Sprintf("F%d", r), enabled)
f.SetCellValue(sheet, fmt.Sprintf("G%d", r), cl.CreatedAt.Format("2006-01-02 15:04:05"))
f.SetCellValue(sheet, fmt.Sprintf("H%d", r), cl.UpdatedAt.Format("2006-01-02 15:04:05"))
}
for i := range headers {
col, _ := excelize.ColumnNumberToName(i + 1)
f.SetColWidth(sheet, col, col, 25)
}
c.Response().Header().Set("Content-Disposition", "attachment; filename=clients.xlsx")
c.Response().Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
return f.Write(c.Response())
}
}
// APIConfigStatus checks if the config has changed
func APIConfigStatus(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
changed := util.HashesChanged(db)
return c.JSON(http.StatusOK, map[string]bool{"changed": changed})
}
}