• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

DigitalTolk / wireguard-ui / 24847022216

23 Apr 2026 04:37PM UTC coverage: 81.636% (-0.7%) from 82.32%
24847022216

Pull #16

github

web-flow
Merge ddd9d4228 into 0c50253d1
Pull Request #16: Fix auth

462 of 590 branches covered (78.31%)

Branch coverage included in aggregate %.

38 of 74 new or added lines in 5 files covered. (51.35%)

201 existing lines in 8 files now uncovered.

2832 of 3445 relevant lines covered (82.21%)

14.17 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

69.95
/handler/api_v1_clients.go
1
package handler
2

3
import (
4
        "encoding/base64"
5
        "fmt"
6
        "io/fs"
7
        "net/http"
8
        "sort"
9
        "strings"
10
        "time"
11

12
        "github.com/labstack/echo/v4"
13
        "github.com/labstack/gommon/log"
14
        "github.com/rs/xid"
15
        "golang.zx2c4.com/wireguard/wgctrl"
16
        "golang.zx2c4.com/wireguard/wgctrl/wgtypes"
17

18
        "github.com/xuri/excelize/v2"
19

20
        "github.com/DigitalTolk/wireguard-ui/emailer"
21
        "github.com/DigitalTolk/wireguard-ui/model"
22
        "github.com/DigitalTolk/wireguard-ui/store"
23
        "github.com/DigitalTolk/wireguard-ui/util"
24
)
25

26
// connectedThreshold defines how recently a peer must have handshaked to be considered connected
27
var connectedThreshold = 3 * time.Minute
28

29
func connectedPeerKeys() map[string]bool {
×
30
        keys := make(map[string]bool)
×
31
        wgClient, err := wgctrl.New()
×
32
        if err != nil {
×
33
                log.Warnf("Cannot create wgctrl client: %v", err)
×
34
                return keys
×
35
        }
×
36
        defer wgClient.Close()
×
37

×
38
        devices, err := wgClient.Devices()
×
39
        if err != nil {
×
40
                log.Warnf("Cannot list WireGuard devices: %v", err)
×
41
                return keys
×
UNCOV
42
        }
×
43

44
        for _, dev := range devices {
×
45
                for _, peer := range dev.Peers {
×
46
                        if time.Since(peer.LastHandshakeTime) < connectedThreshold {
×
47
                                keys[peer.PublicKey.String()] = true
×
UNCOV
48
                        }
×
49
                }
50
        }
UNCOV
51
        return keys
×
52
}
53

54
// currentUserEmail returns the email of the currently logged-in user by looking up
55
// the session username in the database. Returns "" if unavailable.
NEW
56
func currentUserEmail(c echo.Context, db store.IStore) string {
×
NEW
57
        username := currentUser(c)
×
NEW
58
        if username == "" {
×
NEW
59
                return ""
×
NEW
60
        }
×
NEW
61
        user, err := db.GetUserByName(username)
×
NEW
62
        if err != nil {
×
NEW
63
                return ""
×
NEW
64
        }
×
NEW
65
        return user.Email
×
66
}
67

68
// APIListClients returns all WireGuard clients
69
func APIListClients(db store.IStore) echo.HandlerFunc {
4✔
70
        return func(c echo.Context) error {
8✔
71
                clientDataList, err := db.GetClients(false)
4✔
72
                if err != nil {
4✔
73
                        return apiInternalError(c, fmt.Sprintf("Cannot get client list: %v", err))
×
UNCOV
74
                }
×
75

76
                admin := isAdmin(c)
4✔
77
                var userEmail string
4✔
78
                if !admin {
4✔
NEW
79
                        userEmail = currentUserEmail(c, db)
×
NEW
80
                }
×
81

82
                search := strings.ToLower(c.QueryParam("search"))
4✔
83
                status := c.QueryParam("status")
4✔
84

4✔
85
                // Only query WireGuard when connected/disconnected filter is active
4✔
86
                var connKeys map[string]bool
4✔
87
                if status == "connected" || status == "disconnected" {
4✔
88
                        connKeys = connectedPeerKeys()
×
UNCOV
89
                }
×
90

91
                filtered := make([]model.ClientData, 0, len(clientDataList))
4✔
92
                for _, clientData := range clientDataList {
11✔
93
                        clientData = util.FillClientSubnetRange(clientData)
7✔
94
                        cl := clientData.Client
7✔
95

7✔
96
                        // Non-admin users can only see clients matching their email
7✔
97
                        if !admin && !strings.EqualFold(cl.Email, userEmail) {
7✔
NEW
98
                                continue
×
99
                        }
100

101
                        // filter by status
102
                        if status == "enabled" && !cl.Enabled {
8✔
103
                                continue
1✔
104
                        }
105
                        if status == "disabled" && cl.Enabled {
7✔
106
                                continue
1✔
107
                        }
108
                        if status == "connected" && !connKeys[cl.PublicKey] {
5✔
UNCOV
109
                                continue
×
110
                        }
111
                        if status == "disconnected" && connKeys[cl.PublicKey] {
5✔
UNCOV
112
                                continue
×
113
                        }
114

115
                        // filter by search
116
                        if search != "" {
7✔
117
                                nameLower := strings.ToLower(cl.Name)
2✔
118
                                emailLower := strings.ToLower(cl.Email)
2✔
119
                                ipsLower := strings.ToLower(strings.Join(cl.AllocatedIPs, " "))
2✔
120
                                if !strings.Contains(nameLower, search) && !strings.Contains(emailLower, search) && !strings.Contains(ipsLower, search) {
3✔
121
                                        continue
1✔
122
                                }
123
                        }
124

125
                        filtered = append(filtered, clientData)
4✔
126
                }
127
                return c.JSON(http.StatusOK, filtered)
4✔
128
        }
129
}
130

131
// APIGetClient returns a single client by ID
132
func APIGetClient(db store.IStore) echo.HandlerFunc {
3✔
133
        return func(c echo.Context) error {
6✔
134
                clientID := c.Param("id")
3✔
135
                if _, err := xid.FromString(clientID); err != nil {
4✔
136
                        return apiBadRequest(c, "Invalid client ID")
1✔
137
                }
1✔
138

139
                clientData, err := db.GetClientByID(clientID, util.DefaultQRCodeSettings)
2✔
140
                if err != nil {
3✔
141
                        return apiNotFound(c, "Client not found")
1✔
142
                }
1✔
143

144
                // Non-admin users can only access their own clients
145
                if !isAdmin(c) {
1✔
NEW
146
                        userEmail := currentUserEmail(c, db)
×
NEW
147
                        if !strings.EqualFold(clientData.Client.Email, userEmail) {
×
NEW
148
                                return apiForbidden(c, "Access denied")
×
NEW
149
                        }
×
150
                }
151

152
                return c.JSON(http.StatusOK, util.FillClientSubnetRange(clientData))
1✔
153
        }
154
}
155

156
// APICreateClient creates a new WireGuard client
157
func APICreateClient(db store.IStore) echo.HandlerFunc {
11✔
158
        return func(c echo.Context) error {
22✔
159
                var client model.Client
11✔
160
                if err := c.Bind(&client); err != nil {
12✔
161
                        return apiBadRequest(c, "Invalid request body")
1✔
162
                }
1✔
163

164
                // validate email is required
165
                if strings.TrimSpace(client.Email) == "" {
11✔
166
                        return apiBadRequest(c, "Email is required")
1✔
167
                }
1✔
168

169
                server, err := db.GetServer()
9✔
170
                if err != nil {
9✔
171
                        return apiInternalError(c, "Cannot fetch server config")
×
UNCOV
172
                }
×
173

174
                // validate allocated IPs
175
                allocatedIPs, err := db.GetAllocatedIPs("")
9✔
176
                if err != nil {
9✔
UNCOV
177
                        return apiInternalError(c, "Cannot get allocated IPs")
×
UNCOV
178
                }
×
179
                check, err := util.ValidateIPAllocation(server.Interface.Addresses, allocatedIPs, client.AllocatedIPs)
9✔
180
                if !check {
11✔
181
                        return apiBadRequest(c, err.Error())
2✔
182
                }
2✔
183

184
                if !util.ValidateAllowedIPs(client.AllowedIPs) {
8✔
185
                        return apiBadRequest(c, "Allowed IPs must be in CIDR format")
1✔
186
                }
1✔
187

188
                if !util.ValidateExtraAllowedIPs(client.ExtraAllowedIPs) {
7✔
189
                        return apiBadRequest(c, "Extra AllowedIPs must be in CIDR format")
1✔
190
                }
1✔
191

192
                // generate ID
193
                client.ID = xid.New().String()
5✔
194

5✔
195
                // generate keypair
5✔
196
                if client.PublicKey == "" {
9✔
197
                        key, err := wgtypes.GeneratePrivateKey()
4✔
198
                        if err != nil {
4✔
199
                                return apiInternalError(c, "Cannot generate WireGuard key pair")
×
200
                        }
×
201
                        client.PrivateKey = key.String()
4✔
202
                        client.PublicKey = key.PublicKey().String()
4✔
203
                } else {
1✔
204
                        if _, err := wgtypes.ParseKey(client.PublicKey); err != nil {
2✔
205
                                return apiBadRequest(c, "Cannot verify WireGuard public key")
1✔
206
                        }
1✔
207
                        // check duplicates
UNCOV
208
                        clients, err := db.GetClients(false)
×
UNCOV
209
                        if err != nil {
×
UNCOV
210
                                return apiInternalError(c, "Cannot check for duplicate keys")
×
UNCOV
211
                        }
×
UNCOV
212
                        for _, other := range clients {
×
213
                                if other.Client.PublicKey == client.PublicKey {
×
214
                                        return apiBadRequest(c, "Duplicate public key")
×
UNCOV
215
                                }
×
216
                        }
217
                }
218

219
                // generate preshared key
220
                switch client.PresharedKey {
4✔
221
                case "":
2✔
222
                        psk, err := wgtypes.GenerateKey()
2✔
223
                        if err != nil {
2✔
UNCOV
224
                                return apiInternalError(c, "Cannot generate preshared key")
×
UNCOV
225
                        }
×
226
                        client.PresharedKey = psk.String()
2✔
227
                case "-":
1✔
228
                        client.PresharedKey = ""
1✔
229
                default:
1✔
230
                        if _, err := wgtypes.ParseKey(client.PresharedKey); err != nil {
2✔
231
                                return apiBadRequest(c, "Cannot verify preshared key")
1✔
232
                        }
1✔
233
                }
234

235
                client.CreatedAt = time.Now().UTC()
3✔
236
                client.UpdatedAt = client.CreatedAt
3✔
237

3✔
238
                if err := db.SaveClient(client); err != nil {
3✔
239
                        return apiInternalError(c, err.Error())
×
UNCOV
240
                }
×
241

242
                log.Infof("Created wireguard client: %v", client.Name)
3✔
243
                auditLogEvent(c, "client.create", "client", client.ID, map[string]string{"name": client.Name, "email": client.Email})
3✔
244
                return c.JSON(http.StatusCreated, client)
3✔
245
        }
246
}
247

248
// APIUpdateClient updates an existing client
249
func APIUpdateClient(db store.IStore) echo.HandlerFunc {
9✔
250
        return func(c echo.Context) error {
18✔
251
                clientID := c.Param("id")
9✔
252
                if _, err := xid.FromString(clientID); err != nil {
10✔
253
                        return apiBadRequest(c, "Invalid client ID")
1✔
254
                }
1✔
255

256
                var _client model.Client
8✔
257
                if err := c.Bind(&_client); err != nil {
9✔
258
                        return apiBadRequest(c, "Invalid request body")
1✔
259
                }
1✔
260

261
                clientData, err := db.GetClientByID(clientID, model.QRCodeSettings{Enabled: false})
7✔
262
                if err != nil {
8✔
263
                        return apiNotFound(c, "Client not found")
1✔
264
                }
1✔
265

266
                server, err := db.GetServer()
6✔
267
                if err != nil {
6✔
UNCOV
268
                        return apiInternalError(c, "Cannot fetch server config")
×
UNCOV
269
                }
×
270

271
                client := *clientData.Client
6✔
272

6✔
273
                allocatedIPs, err := db.GetAllocatedIPs(client.ID)
6✔
274
                if err != nil {
6✔
275
                        return apiInternalError(c, "Cannot get allocated IPs")
×
UNCOV
276
                }
×
277
                check, err := util.ValidateIPAllocation(server.Interface.Addresses, allocatedIPs, _client.AllocatedIPs)
6✔
278
                if !check {
7✔
279
                        return apiBadRequest(c, err.Error())
1✔
280
                }
1✔
281

282
                if !util.ValidateAllowedIPs(_client.AllowedIPs) {
6✔
283
                        return apiBadRequest(c, "Allowed IPs must be in CIDR format")
1✔
284
                }
1✔
285
                if !util.ValidateExtraAllowedIPs(_client.ExtraAllowedIPs) {
5✔
286
                        return apiBadRequest(c, "Extra Allowed IPs must be in CIDR format")
1✔
287
                }
1✔
288

289
                // handle public key change
290
                if client.PublicKey != _client.PublicKey && _client.PublicKey != "" {
4✔
291
                        if _, err := wgtypes.ParseKey(_client.PublicKey); err != nil {
2✔
292
                                return apiBadRequest(c, "Cannot verify WireGuard public key")
1✔
293
                        }
1✔
UNCOV
294
                        clients, err := db.GetClients(false)
×
UNCOV
295
                        if err != nil {
×
UNCOV
296
                                return apiInternalError(c, "Cannot check for duplicate keys")
×
UNCOV
297
                        }
×
UNCOV
298
                        for _, other := range clients {
×
299
                                if other.Client.PublicKey == _client.PublicKey {
×
300
                                        return apiBadRequest(c, "Duplicate public key")
×
301
                                }
×
302
                        }
303
                        if client.PrivateKey != "" {
×
304
                                client.PrivateKey = ""
×
305
                        }
×
306
                }
307

308
                // handle preshared key change
309
                if client.PresharedKey != _client.PresharedKey && _client.PresharedKey != "" {
3✔
310
                        if _, err := wgtypes.ParseKey(_client.PresharedKey); err != nil {
2✔
311
                                return apiBadRequest(c, "Cannot verify preshared key")
1✔
312
                        }
1✔
313
                }
314

315
                client.Name = _client.Name
1✔
316
                // email is immutable after creation — preserve original
1✔
317
                client.Enabled = _client.Enabled
1✔
318
                client.UseServerDNS = _client.UseServerDNS
1✔
319
                client.AllocatedIPs = _client.AllocatedIPs
1✔
320
                client.AllowedIPs = _client.AllowedIPs
1✔
321
                client.ExtraAllowedIPs = _client.ExtraAllowedIPs
1✔
322
                client.Endpoint = _client.Endpoint
1✔
323
                client.PublicKey = _client.PublicKey
1✔
324
                client.PresharedKey = _client.PresharedKey
1✔
325
                client.UpdatedAt = time.Now().UTC()
1✔
326
                client.AdditionalNotes = strings.ReplaceAll(strings.Trim(_client.AdditionalNotes, "\r\n"), "\r\n", "\n")
1✔
327

1✔
328
                if err := db.SaveClient(client); err != nil {
1✔
UNCOV
329
                        return apiInternalError(c, err.Error())
×
UNCOV
330
                }
×
331

332
                log.Infof("Updated client: %v", client.Name)
1✔
333
                auditLogEvent(c, "client.update", "client", client.ID, map[string]string{"name": client.Name, "email": client.Email})
1✔
334
                return c.JSON(http.StatusOK, client)
1✔
335
        }
336
}
337

338
// APIPatchClientStatus enables/disables a client
339
func APIPatchClientStatus(db store.IStore) echo.HandlerFunc {
5✔
340
        return func(c echo.Context) error {
10✔
341
                clientID := c.Param("id")
5✔
342
                if _, err := xid.FromString(clientID); err != nil {
6✔
343
                        return apiBadRequest(c, "Invalid client ID")
1✔
344
                }
1✔
345

346
                var body struct {
4✔
347
                        Enabled bool `json:"enabled"`
4✔
348
                }
4✔
349
                if err := c.Bind(&body); err != nil {
5✔
350
                        return apiBadRequest(c, "Invalid request body")
1✔
351
                }
1✔
352

353
                clientData, err := db.GetClientByID(clientID, model.QRCodeSettings{Enabled: false})
3✔
354
                if err != nil {
4✔
355
                        return apiNotFound(c, "Client not found")
1✔
356
                }
1✔
357

358
                client := *clientData.Client
2✔
359
                client.Enabled = body.Enabled
2✔
360
                if err := db.SaveClient(client); err != nil {
2✔
UNCOV
361
                        return apiInternalError(c, err.Error())
×
UNCOV
362
                }
×
363

364
                action := "client.disable"
2✔
365
                if body.Enabled {
3✔
366
                        action = "client.enable"
1✔
367
                }
1✔
368
                log.Infof("Changed client %s enabled status to %v", client.ID, body.Enabled)
2✔
369
                auditLogEvent(c, action, "client", client.ID, map[string]string{"name": client.Name, "email": client.Email})
2✔
370
                return c.JSON(http.StatusOK, client)
2✔
371
        }
372
}
373

374
// APIDeleteClient deletes a client
375
func APIDeleteClient(db store.IStore) echo.HandlerFunc {
2✔
376
        return func(c echo.Context) error {
4✔
377
                clientID := c.Param("id")
2✔
378
                if _, err := xid.FromString(clientID); err != nil {
3✔
379
                        return apiBadRequest(c, "Invalid client ID")
1✔
380
                }
1✔
381

382
                if err := db.DeleteClient(clientID); err != nil {
1✔
UNCOV
383
                        return apiInternalError(c, "Cannot delete client")
×
UNCOV
384
                }
×
385

386
                log.Infof("Deleted wireguard client: %s", clientID)
1✔
387
                auditLogEvent(c, "client.delete", "client", clientID, nil)
1✔
388
                return c.NoContent(http.StatusNoContent)
1✔
389
        }
390
}
391

392
// APIDownloadClientConfig returns the .conf file for a client
393
func APIDownloadClientConfig(db store.IStore) echo.HandlerFunc {
3✔
394
        return func(c echo.Context) error {
6✔
395
                clientID := c.Param("id")
3✔
396
                if _, err := xid.FromString(clientID); err != nil {
4✔
397
                        return apiBadRequest(c, "Invalid client ID")
1✔
398
                }
1✔
399

400
                clientData, err := db.GetClientByID(clientID, model.QRCodeSettings{Enabled: false})
2✔
401
                if err != nil {
3✔
402
                        return apiNotFound(c, "Client not found")
1✔
403
                }
1✔
404

405
                // Non-admin users can only download their own configs
406
                if !isAdmin(c) {
1✔
NEW
UNCOV
407
                        userEmail := currentUserEmail(c, db)
×
NEW
UNCOV
408
                        if !strings.EqualFold(clientData.Client.Email, userEmail) {
×
NEW
UNCOV
409
                                return apiForbidden(c, "Access denied")
×
NEW
UNCOV
410
                        }
×
411
                }
412

413
                server, err := db.GetServer()
1✔
414
                if err != nil {
1✔
UNCOV
415
                        return apiInternalError(c, "Cannot get server config")
×
UNCOV
416
                }
×
417
                globalSettings, err := db.GetGlobalSettings()
1✔
418
                if err != nil {
1✔
UNCOV
419
                        return apiInternalError(c, "Cannot get global settings")
×
UNCOV
420
                }
×
421

422
                config := util.BuildClientConfig(*clientData.Client, server, globalSettings)
1✔
423
                c.Response().Header().Set(echo.HeaderContentDisposition, fmt.Sprintf("attachment; filename=%s.conf", clientData.Client.Name))
1✔
424
                auditLogEvent(c, "client.config.download", "client", clientID, map[string]string{"name": clientData.Client.Name, "email": clientData.Client.Email})
1✔
425
                return c.Stream(http.StatusOK, "text/conf", strings.NewReader(config))
1✔
426
        }
427
}
428

429
// APIGetClientQRCode returns the QR code for a client
430
func APIGetClientQRCode(db store.IStore) echo.HandlerFunc {
3✔
431
        return func(c echo.Context) error {
6✔
432
                clientID := c.Param("id")
3✔
433
                if _, err := xid.FromString(clientID); err != nil {
4✔
434
                        return apiBadRequest(c, "Invalid client ID")
1✔
435
                }
1✔
436

437
                clientData, err := db.GetClientByID(clientID, util.DefaultQRCodeSettings)
2✔
438
                if err != nil {
3✔
439
                        return apiNotFound(c, "Client not found")
1✔
440
                }
1✔
441

442
                // Non-admin users can only view their own QR codes
443
                if !isAdmin(c) {
1✔
NEW
UNCOV
444
                        userEmail := currentUserEmail(c, db)
×
NEW
UNCOV
445
                        if !strings.EqualFold(clientData.Client.Email, userEmail) {
×
NEW
UNCOV
446
                                return apiForbidden(c, "Access denied")
×
NEW
UNCOV
447
                        }
×
448
                }
449

450
                return c.JSON(http.StatusOK, map[string]string{
1✔
451
                        "qr_code": clientData.QRCode,
1✔
452
                })
1✔
453
        }
454
}
455

456
// APIEmailClient sends the client config via email
457
func APIEmailClient(db store.IStore, mailer emailer.Emailer, emailSubject, emailContent string) echo.HandlerFunc {
6✔
458
        return func(c echo.Context) error {
12✔
459
                clientID := c.Param("id")
6✔
460
                if _, err := xid.FromString(clientID); err != nil {
7✔
461
                        return apiBadRequest(c, "Invalid client ID")
1✔
462
                }
1✔
463

464
                var body struct {
5✔
465
                        Email string `json:"email"`
5✔
466
                }
5✔
467
                if err := c.Bind(&body); err != nil {
6✔
468
                        return apiBadRequest(c, "Invalid request body")
1✔
469
                }
1✔
470

471
                qrCodeSettings := model.QRCodeSettings{Enabled: true, IncludeDNS: true, IncludeMTU: true}
4✔
472
                clientData, err := db.GetClientByID(clientID, qrCodeSettings)
4✔
473
                if err != nil {
5✔
474
                        return apiNotFound(c, "Client not found")
1✔
475
                }
1✔
476

477
                // Non-admin users can only email their own configs
478
                if !isAdmin(c) {
3✔
NEW
UNCOV
479
                        userEmail := currentUserEmail(c, db)
×
NEW
UNCOV
480
                        if !strings.EqualFold(clientData.Client.Email, userEmail) {
×
NEW
UNCOV
481
                                return apiForbidden(c, "Access denied")
×
NEW
UNCOV
482
                        }
×
483
                }
484

485
                server, _ := db.GetServer()
3✔
486
                globalSettings, _ := db.GetGlobalSettings()
3✔
487
                config := util.BuildClientConfig(*clientData.Client, server, globalSettings)
3✔
488

3✔
489
                cfgAtt := emailer.Attachment{Name: "wg0.conf", Data: []byte(config)}
3✔
490
                var attachments []emailer.Attachment
3✔
491
                if clientData.Client.PrivateKey != "" {
5✔
492
                        qrdata, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(clientData.QRCode, "data:image/png;base64,"))
2✔
493
                        if err != nil {
2✔
UNCOV
494
                                return apiInternalError(c, "Cannot decode QR code")
×
UNCOV
495
                        }
×
496
                        attachments = []emailer.Attachment{cfgAtt, {Name: "wg.png", Data: qrdata}}
2✔
497
                } else {
1✔
498
                        attachments = []emailer.Attachment{cfgAtt}
1✔
499
                }
1✔
500

501
                err = mailer.Send(clientData.Client.Name, body.Email, emailSubject, emailContent, attachments)
3✔
502
                if err != nil {
4✔
503
                        return apiInternalError(c, err.Error())
1✔
504
                }
1✔
505

506
                auditLogEvent(c, "client.config.email", "client", clientID, map[string]string{"name": clientData.Client.Name, "email": clientData.Client.Email, "sent_to": body.Email})
2✔
507
                return c.JSON(http.StatusOK, map[string]string{"message": "Email sent successfully"})
2✔
508
        }
509
}
510

511
// APISuggestClientIPs suggests available IP addresses
512
func APISuggestClientIPs(db store.IStore) echo.HandlerFunc {
1✔
513
        return func(c echo.Context) error {
2✔
514
                server, err := db.GetServer()
1✔
515
                if err != nil {
1✔
UNCOV
516
                        return apiInternalError(c, "Cannot fetch server config")
×
UNCOV
517
                }
×
518

519
                allocatedIPs, err := db.GetAllocatedIPs("")
1✔
520
                if err != nil {
1✔
UNCOV
521
                        return apiInternalError(c, "Cannot get allocated IPs")
×
UNCOV
522
                }
×
523

524
                sr := c.QueryParam("sr")
1✔
525
                searchCIDRList := make([]string, 0)
1✔
526

1✔
527
                if util.SubnetRanges[sr] != nil {
1✔
UNCOV
528
                        for _, cidr := range util.SubnetRanges[sr] {
×
UNCOV
529
                                searchCIDRList = append(searchCIDRList, cidr.String())
×
UNCOV
530
                        }
×
531
                } else {
1✔
532
                        searchCIDRList = append(searchCIDRList, server.Interface.Addresses...)
1✔
533
                }
1✔
534

535
                ipSet := make(map[string]struct{})
1✔
536
                found := false
1✔
537

1✔
538
                for _, cidr := range searchCIDRList {
2✔
539
                        ip, err := util.GetAvailableIP(cidr, allocatedIPs, server.Interface.Addresses)
1✔
540
                        if err != nil {
1✔
541
                                continue
×
542
                        }
543
                        found = true
1✔
544
                        if strings.Contains(ip, ":") {
1✔
UNCOV
545
                                ipSet[fmt.Sprintf("%s/128", ip)] = struct{}{}
×
546
                        } else {
1✔
547
                                ipSet[fmt.Sprintf("%s/32", ip)] = struct{}{}
1✔
548
                        }
1✔
549
                }
550

551
                if !found {
1✔
UNCOV
552
                        return apiInternalError(c, "No available IPs. Try a different subnet or deallocate some IPs.")
×
UNCOV
553
                }
×
554

555
                suggestedIPs := make([]string, 0, len(ipSet))
1✔
556
                for ip := range ipSet {
2✔
557
                        suggestedIPs = append(suggestedIPs, ip)
1✔
558
                }
1✔
559
                return c.JSON(http.StatusOK, suggestedIPs)
1✔
560
        }
561
}
562

563
// APIMachineIPs returns local machine IP addresses
564
func APIMachineIPs() echo.HandlerFunc {
1✔
565
        return func(c echo.Context) error {
2✔
566
                interfaceList, err := util.GetInterfaceIPs()
1✔
567
                if err != nil {
1✔
UNCOV
568
                        return apiInternalError(c, "Cannot get machine IP addresses")
×
UNCOV
569
                }
×
570

571
                publicInterface, err := util.GetPublicIP()
1✔
572
                if err == nil {
2✔
573
                        interfaceList = append([]model.Interface{publicInterface}, interfaceList...)
1✔
574
                }
1✔
575

576
                return c.JSON(http.StatusOK, interfaceList)
1✔
577
        }
578
}
579

580
// APISubnetRanges returns the ordered list of subnet ranges
581
func APISubnetRanges() echo.HandlerFunc {
1✔
582
        return func(c echo.Context) error {
2✔
583
                return c.JSON(http.StatusOK, util.SubnetRangesOrder)
1✔
584
        }
1✔
585
}
586

587
// APIServerStatus returns WireGuard status with connected peers
588
func APIServerStatus(db store.IStore) echo.HandlerFunc {
1✔
589
        type PeerStatus struct {
1✔
590
                Name              string        `json:"name"`
1✔
591
                Email             string        `json:"email"`
1✔
592
                PublicKey         string        `json:"public_key"`
1✔
593
                ReceivedBytes     int64         `json:"received_bytes"`
1✔
594
                TransmitBytes     int64         `json:"transmit_bytes"`
1✔
595
                LastHandshakeTime time.Time     `json:"last_handshake_time"`
1✔
596
                LastHandshakeRel  time.Duration `json:"last_handshake_rel"`
1✔
597
                Connected         bool          `json:"connected"`
1✔
598
                AllocatedIP       string        `json:"allocated_ip"`
1✔
599
                Endpoint          string        `json:"endpoint"`
1✔
600
        }
1✔
601

1✔
602
        type DeviceStatus struct {
1✔
603
                Name  string       `json:"name"`
1✔
604
                Peers []PeerStatus `json:"peers"`
1✔
605
        }
1✔
606

1✔
607
        return func(c echo.Context) error {
2✔
608
                wgClient, err := wgctrl.New()
1✔
609
                if err != nil {
1✔
UNCOV
610
                        return apiInternalError(c, err.Error())
×
UNCOV
611
                }
×
612
                defer wgClient.Close()
1✔
613

1✔
614
                devices, err := wgClient.Devices()
1✔
615
                if err != nil {
1✔
UNCOV
616
                        return apiInternalError(c, err.Error())
×
UNCOV
617
                }
×
618

619
                devicesStatus := make([]DeviceStatus, 0, len(devices))
1✔
620
                if len(devices) > 0 {
1✔
UNCOV
621
                        m := make(map[string]*model.Client)
×
UNCOV
622
                        clients, _ := db.GetClients(false)
×
UNCOV
623
                        for i := range clients {
×
UNCOV
624
                                if clients[i].Client != nil {
×
UNCOV
625
                                        m[clients[i].Client.PublicKey] = clients[i].Client
×
UNCOV
626
                                }
×
627
                        }
628

UNCOV
629
                        conv := map[bool]int{true: 1, false: 0}
×
630
                        for i := range devices {
×
631
                                dev := DeviceStatus{Name: devices[i].Name}
×
UNCOV
632
                                for j := range devices[i].Peers {
×
UNCOV
633
                                        var allocatedIPs string
×
UNCOV
634
                                        for _, ip := range devices[i].Peers[j].AllowedIPs {
×
UNCOV
635
                                                if len(allocatedIPs) > 0 {
×
636
                                                        allocatedIPs += ", "
×
637
                                                }
×
UNCOV
638
                                                allocatedIPs += ip.String()
×
639
                                        }
UNCOV
640
                                        p := PeerStatus{
×
641
                                                PublicKey:         devices[i].Peers[j].PublicKey.String(),
×
642
                                                ReceivedBytes:     devices[i].Peers[j].ReceiveBytes,
×
643
                                                TransmitBytes:     devices[i].Peers[j].TransmitBytes,
×
644
                                                LastHandshakeTime: devices[i].Peers[j].LastHandshakeTime,
×
645
                                                LastHandshakeRel:  time.Since(devices[i].Peers[j].LastHandshakeTime),
×
646
                                                AllocatedIP:       allocatedIPs,
×
UNCOV
647
                                        }
×
UNCOV
648
                                        p.Connected = p.LastHandshakeRel < connectedThreshold
×
649

×
NEW
650
                                        if devices[i].Peers[j].Endpoint != nil {
×
651
                                                p.Endpoint = devices[i].Peers[j].Endpoint.String()
×
652
                                        }
×
653

654
                                        if cl, ok := m[p.PublicKey]; ok {
×
655
                                                p.Name = cl.Name
×
656
                                                p.Email = cl.Email
×
657
                                        }
×
658
                                        dev.Peers = append(dev.Peers, p)
×
659
                                }
660
                                sort.SliceStable(dev.Peers, func(a, b int) bool { return dev.Peers[a].Name < dev.Peers[b].Name })
×
661
                                sort.SliceStable(dev.Peers, func(a, b int) bool { return conv[dev.Peers[a].Connected] > conv[dev.Peers[b].Connected] })
×
662
                                devicesStatus = append(devicesStatus, dev)
×
663
                        }
664
                }
665

666
                return c.JSON(http.StatusOK, devicesStatus)
1✔
667
        }
668
}
669

670
// APIApplyServerConfig writes the wg0.conf and updates hashes
671
func APIApplyServerConfig(db store.IStore, tmplDir fs.FS) echo.HandlerFunc {
1✔
672
        return func(c echo.Context) error {
2✔
673
                server, err := db.GetServer()
1✔
674
                if err != nil {
1✔
675
                        return apiInternalError(c, "Cannot get server config")
×
676
                }
×
677
                clients, err := db.GetClients(false)
1✔
678
                if err != nil {
1✔
UNCOV
679
                        return apiInternalError(c, "Cannot get client config")
×
680
                }
×
681
                users, err := db.GetUsers()
1✔
682
                if err != nil {
1✔
UNCOV
683
                        return apiInternalError(c, "Cannot get users config")
×
UNCOV
684
                }
×
685
                settings, err := db.GetGlobalSettings()
1✔
686
                if err != nil {
1✔
UNCOV
687
                        return apiInternalError(c, "Cannot get global settings")
×
UNCOV
688
                }
×
689

690
                if err := util.WriteWireGuardServerConfig(tmplDir, server, clients, users, settings); err != nil {
1✔
691
                        return apiInternalError(c, fmt.Sprintf("Cannot apply config: %v", err))
×
UNCOV
692
                }
×
693

694
                if err := util.UpdateHashes(db); err != nil {
1✔
695
                        return apiInternalError(c, fmt.Sprintf("Cannot update hashes: %v", err))
×
UNCOV
696
                }
×
697

698
                auditLogEvent(c, "server.config.apply", "server", "config", nil)
1✔
699
                return c.JSON(http.StatusOK, map[string]string{"message": "Config applied successfully"})
1✔
700
        }
701
}
702

703
// APIExportClients exports all clients as an Excel file
704
func APIExportClients(db store.IStore) echo.HandlerFunc {
1✔
705
        return func(c echo.Context) error {
2✔
706
                clientDataList, err := db.GetClients(false)
1✔
707
                if err != nil {
1✔
708
                        return apiInternalError(c, "Cannot get client list")
×
UNCOV
709
                }
×
710

711
                f := excelize.NewFile()
1✔
712
                sheet := "Clients"
1✔
713
                f.SetSheetName("Sheet1", sheet)
1✔
714

1✔
715
                headers := []string{"Name", "Email", "Allocated IPs", "Allowed IPs", "Extra Allowed IPs", "Enabled", "Created", "Updated"}
1✔
716
                for i, h := range headers {
9✔
717
                        cell, _ := excelize.CoordinatesToCellName(i+1, 1)
8✔
718
                        f.SetCellValue(sheet, cell, h)
8✔
719
                }
8✔
720

721
                style, _ := f.NewStyle(&excelize.Style{
1✔
722
                        Font: &excelize.Font{Bold: true},
1✔
723
                })
1✔
724
                f.SetCellStyle(sheet, "A1", fmt.Sprintf("%s1", string(rune('A'+len(headers)-1))), style)
1✔
725

1✔
726
                for row, cd := range clientDataList {
2✔
727
                        cl := cd.Client
1✔
728
                        r := row + 2
1✔
729
                        f.SetCellValue(sheet, fmt.Sprintf("A%d", r), cl.Name)
1✔
730
                        f.SetCellValue(sheet, fmt.Sprintf("B%d", r), cl.Email)
1✔
731
                        f.SetCellValue(sheet, fmt.Sprintf("C%d", r), strings.Join(cl.AllocatedIPs, ", "))
1✔
732
                        f.SetCellValue(sheet, fmt.Sprintf("D%d", r), strings.Join(cl.AllowedIPs, ", "))
1✔
733
                        f.SetCellValue(sheet, fmt.Sprintf("E%d", r), strings.Join(cl.ExtraAllowedIPs, ", "))
1✔
734
                        enabled := "No"
1✔
735
                        if cl.Enabled {
2✔
736
                                enabled = "Yes"
1✔
737
                        }
1✔
738
                        f.SetCellValue(sheet, fmt.Sprintf("F%d", r), enabled)
1✔
739
                        f.SetCellValue(sheet, fmt.Sprintf("G%d", r), cl.CreatedAt.Format("2006-01-02 15:04:05"))
1✔
740
                        f.SetCellValue(sheet, fmt.Sprintf("H%d", r), cl.UpdatedAt.Format("2006-01-02 15:04:05"))
1✔
741
                }
742

743
                for i := range headers {
9✔
744
                        col, _ := excelize.ColumnNumberToName(i + 1)
8✔
745
                        f.SetColWidth(sheet, col, col, 25)
8✔
746
                }
8✔
747

748
                c.Response().Header().Set("Content-Disposition", "attachment; filename=clients.xlsx")
1✔
749
                c.Response().Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
1✔
750
                return f.Write(c.Response())
1✔
751
        }
752
}
753

754
// APIConfigStatus checks if the config has changed
755
func APIConfigStatus(db store.IStore) echo.HandlerFunc {
1✔
756
        return func(c echo.Context) error {
2✔
757
                changed := util.HashesChanged(db)
1✔
758
                return c.JSON(http.StatusOK, map[string]bool{"changed": changed})
1✔
759
        }
1✔
760
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc