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

DigitalTolk / wireguard-ui / 24857421461

23 Apr 2026 08:33PM UTC coverage: 90.138% (+0.007%) from 90.131%
24857421461

Pull #19

github

web-flow
Merge 7dc75bafe into 95e767a85
Pull Request #19: Fix status page bugs

534 of 598 branches covered (89.3%)

Branch coverage included in aggregate %.

4 of 6 new or added lines in 2 files covered. (66.67%)

39 existing lines in 1 file now uncovered.

3259 of 3610 relevant lines covered (90.28%)

44.31 hits per line

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

82.53
/handler/api_v1_clients.go
1
package handler
2

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

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

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

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

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

28
func isConnected(lastHandshake time.Time) bool {
4✔
29
        return !lastHandshake.IsZero() && time.Since(lastHandshake) < connectedThreshold
4✔
30
}
4✔
31

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

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

47
        for _, dev := range devices {
×
48
                for _, peer := range dev.Peers {
×
NEW
49
                        if isConnected(peer.LastHandshakeTime) {
×
50
                                keys[peer.PublicKey.String()] = true
×
UNCOV
51
                        }
×
52
                }
53
        }
UNCOV
54
        return keys
×
55
}
56

57
// currentUserEmail returns the email of the currently logged-in user by looking up
58
// the session username in the database. Returns "" if unavailable.
59
func currentUserEmail(c echo.Context, db store.IStore) string {
11✔
60
        username := currentUser(c)
11✔
61
        if username == "" {
12✔
62
                return ""
1✔
63
        }
1✔
64
        user, err := db.GetUserByName(username)
10✔
65
        if err != nil {
11✔
66
                return ""
1✔
67
        }
1✔
68
        return user.Email
9✔
69
}
70

71
// APIListClients returns all WireGuard clients
72
func APIListClients(db store.IStore) echo.HandlerFunc {
10✔
73
        return func(c echo.Context) error {
20✔
74
                clientDataList, err := db.GetClients(false)
10✔
75
                if err != nil {
11✔
76
                        return apiInternalError(c, fmt.Sprintf("Cannot get client list: %v", err))
1✔
77
                }
1✔
78

79
                admin := isAdmin(c)
9✔
80
                var userEmail string
9✔
81
                if !admin {
10✔
82
                        userEmail = currentUserEmail(c, db)
1✔
83
                }
1✔
84

85
                search := strings.ToLower(c.QueryParam("search"))
9✔
86
                status := c.QueryParam("status")
9✔
87

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

94
                filtered := make([]model.ClientData, 0, len(clientDataList))
9✔
95
                for _, clientData := range clientDataList {
25✔
96
                        clientData = util.FillClientSubnetRange(clientData)
16✔
97
                        cl := clientData.Client
16✔
98

16✔
99
                        // Non-admin users can only see clients matching their email
16✔
100
                        if !admin && !strings.EqualFold(cl.Email, userEmail) {
17✔
101
                                continue
1✔
102
                        }
103

104
                        // filter by status
105
                        if status == "enabled" && !cl.Enabled {
16✔
106
                                continue
1✔
107
                        }
108
                        if status == "disabled" && cl.Enabled {
15✔
109
                                continue
1✔
110
                        }
111
                        if status == "connected" && !connKeys[cl.PublicKey] {
13✔
UNCOV
112
                                continue
×
113
                        }
114
                        if status == "disconnected" && connKeys[cl.PublicKey] {
13✔
UNCOV
115
                                continue
×
116
                        }
117

118
                        // filter by search
119
                        if search != "" {
22✔
120
                                nameLower := strings.ToLower(cl.Name)
9✔
121
                                emailLower := strings.ToLower(cl.Email)
9✔
122
                                ipsLower := strings.ToLower(strings.Join(cl.AllocatedIPs, " "))
9✔
123
                                if !strings.Contains(nameLower, search) && !strings.Contains(emailLower, search) && !strings.Contains(ipsLower, search) {
13✔
124
                                        continue
4✔
125
                                }
126
                        }
127

128
                        filtered = append(filtered, clientData)
9✔
129
                }
130
                return c.JSON(http.StatusOK, filtered)
9✔
131
        }
132
}
133

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

142
                clientData, err := db.GetClientByID(clientID, util.DefaultQRCodeSettings)
4✔
143
                if err != nil {
5✔
144
                        return apiNotFound(c, "Client not found")
1✔
145
                }
1✔
146

147
                // Non-admin users can only access their own clients
148
                if !isAdmin(c) {
5✔
149
                        userEmail := currentUserEmail(c, db)
2✔
150
                        if !strings.EqualFold(clientData.Client.Email, userEmail) {
3✔
151
                                return apiForbidden(c, "Access denied")
1✔
152
                        }
1✔
153
                }
154

155
                return c.JSON(http.StatusOK, util.FillClientSubnetRange(clientData))
2✔
156
        }
157
}
158

159
// APICreateClient creates a new WireGuard client
160
func APICreateClient(db store.IStore, cw *ConfigWriter) echo.HandlerFunc {
18✔
161
        return func(c echo.Context) error {
36✔
162
                var client model.Client
18✔
163
                if err := c.Bind(&client); err != nil {
19✔
164
                        return apiBadRequest(c, "Invalid request body")
1✔
165
                }
1✔
166

167
                // validate email is required
168
                if strings.TrimSpace(client.Email) == "" {
18✔
169
                        return apiBadRequest(c, "Email is required")
1✔
170
                }
1✔
171

172
                if strings.TrimSpace(client.Name) == "" {
17✔
173
                        return apiBadRequest(c, "Name is required")
1✔
174
                }
1✔
175

176
                server, err := db.GetServer()
15✔
177
                if err != nil {
15✔
UNCOV
178
                        return apiInternalError(c, "Cannot fetch server config")
×
UNCOV
179
                }
×
180

181
                // validate allocated IPs
182
                allocatedIPs, err := db.GetAllocatedIPs("")
15✔
183
                if err != nil {
15✔
UNCOV
184
                        return apiInternalError(c, "Cannot get allocated IPs")
×
UNCOV
185
                }
×
186
                check, err := util.ValidateIPAllocation(server.Interface.Addresses, allocatedIPs, client.AllocatedIPs)
15✔
187
                if !check {
17✔
188
                        return apiBadRequest(c, err.Error())
2✔
189
                }
2✔
190

191
                if !util.ValidateAllowedIPs(client.AllowedIPs) {
14✔
192
                        return apiBadRequest(c, "Allowed IPs must be in CIDR format")
1✔
193
                }
1✔
194

195
                if !util.ValidateExtraAllowedIPs(client.ExtraAllowedIPs) {
13✔
196
                        return apiBadRequest(c, "Extra AllowedIPs must be in CIDR format")
1✔
197
                }
1✔
198

199
                // validate name + public key uniqueness in one pass
200
                existingClients, _ := db.GetClients(false)
11✔
201
                for _, ec := range existingClients {
13✔
202
                        if strings.EqualFold(ec.Client.Name, client.Name) {
3✔
203
                                return apiBadRequest(c, "A client with this name already exists")
1✔
204
                        }
1✔
205
                        if client.PublicKey != "" && ec.Client.PublicKey == client.PublicKey {
2✔
206
                                return apiBadRequest(c, "Duplicate public key")
1✔
207
                        }
1✔
208
                }
209

210
                // generate ID
211
                client.ID = xid.New().String()
9✔
212

9✔
213
                // generate keypair
9✔
214
                if client.PublicKey == "" {
15✔
215
                        key, err := wgtypes.GeneratePrivateKey()
6✔
216
                        if err != nil {
6✔
UNCOV
217
                                return apiInternalError(c, "Cannot generate WireGuard key pair")
×
218
                        }
×
219
                        client.PrivateKey = key.String()
6✔
220
                        client.PublicKey = key.PublicKey().String()
6✔
221
                } else {
3✔
222
                        if _, err := wgtypes.ParseKey(client.PublicKey); err != nil {
4✔
223
                                return apiBadRequest(c, "Cannot verify WireGuard public key")
1✔
224
                        }
1✔
225
                }
226

227
                // generate preshared key
228
                switch client.PresharedKey {
8✔
229
                case "":
5✔
230
                        psk, err := wgtypes.GenerateKey()
5✔
231
                        if err != nil {
5✔
UNCOV
232
                                return apiInternalError(c, "Cannot generate preshared key")
×
233
                        }
×
234
                        client.PresharedKey = psk.String()
5✔
235
                case "-":
1✔
236
                        client.PresharedKey = ""
1✔
237
                default:
2✔
238
                        if _, err := wgtypes.ParseKey(client.PresharedKey); err != nil {
3✔
239
                                return apiBadRequest(c, "Cannot verify preshared key")
1✔
240
                        }
1✔
241
                }
242

243
                client.Enabled = true
7✔
244
                client.CreatedAt = time.Now().UTC()
7✔
245
                client.UpdatedAt = client.CreatedAt
7✔
246

7✔
247
                if err := db.SaveClient(client); err != nil {
7✔
UNCOV
248
                        return apiInternalError(c, err.Error())
×
249
                }
×
250

251
                cw.Trigger()
7✔
252
                log.Infof("Created wireguard client: %v", client.Name)
7✔
253
                auditLogEvent(c, "client.create", "client", client.ID, map[string]string{"name": client.Name, "email": client.Email})
7✔
254
                return c.JSON(http.StatusCreated, client)
7✔
255
        }
256
}
257

258
// APIUpdateClient updates an existing client
259
func APIUpdateClient(db store.IStore, cw *ConfigWriter) echo.HandlerFunc {
15✔
260
        return func(c echo.Context) error {
30✔
261
                clientID := c.Param("id")
15✔
262
                if _, err := xid.FromString(clientID); err != nil {
16✔
263
                        return apiBadRequest(c, "Invalid client ID")
1✔
264
                }
1✔
265

266
                var _client model.Client
14✔
267
                if err := c.Bind(&_client); err != nil {
15✔
268
                        return apiBadRequest(c, "Invalid request body")
1✔
269
                }
1✔
270

271
                clientData, err := db.GetClientByID(clientID, model.QRCodeSettings{Enabled: false})
13✔
272
                if err != nil {
14✔
273
                        return apiNotFound(c, "Client not found")
1✔
274
                }
1✔
275

276
                if strings.TrimSpace(_client.Name) == "" {
13✔
277
                        return apiBadRequest(c, "Name is required")
1✔
278
                }
1✔
279

280
                server, err := db.GetServer()
11✔
281
                if err != nil {
11✔
UNCOV
282
                        return apiInternalError(c, "Cannot fetch server config")
×
283
                }
×
284

285
                client := *clientData.Client
11✔
286

11✔
287
                allocatedIPs, err := db.GetAllocatedIPs(client.ID)
11✔
288
                if err != nil {
11✔
UNCOV
289
                        return apiInternalError(c, "Cannot get allocated IPs")
×
290
                }
×
291
                check, err := util.ValidateIPAllocation(server.Interface.Addresses, allocatedIPs, _client.AllocatedIPs)
11✔
292
                if !check {
12✔
293
                        return apiBadRequest(c, err.Error())
1✔
294
                }
1✔
295

296
                if !util.ValidateAllowedIPs(_client.AllowedIPs) {
11✔
297
                        return apiBadRequest(c, "Allowed IPs must be in CIDR format")
1✔
298
                }
1✔
299
                if !util.ValidateExtraAllowedIPs(_client.ExtraAllowedIPs) {
10✔
300
                        return apiBadRequest(c, "Extra Allowed IPs must be in CIDR format")
1✔
301
                }
1✔
302

303
                // validate name + public key uniqueness in one pass (skip self)
304
                nameChanged := !strings.EqualFold(_client.Name, client.Name)
8✔
305
                pubKeyChanged := _client.PublicKey != "" && client.PublicKey != _client.PublicKey
8✔
306
                if nameChanged || pubKeyChanged {
14✔
307
                        existingClients, _ := db.GetClients(false)
6✔
308
                        for _, ec := range existingClients {
12✔
309
                                if ec.Client.ID == client.ID {
10✔
310
                                        continue
4✔
311
                                }
312
                                if nameChanged && strings.EqualFold(ec.Client.Name, _client.Name) {
3✔
313
                                        return apiBadRequest(c, "A client with this name already exists")
1✔
314
                                }
1✔
315
                                if pubKeyChanged && ec.Client.PublicKey == _client.PublicKey {
2✔
316
                                        return apiBadRequest(c, "Duplicate public key")
1✔
317
                                }
1✔
318
                        }
319
                }
320

321
                // validate public key format if changed
322
                if pubKeyChanged {
8✔
323
                        if _, err := wgtypes.ParseKey(_client.PublicKey); err != nil {
3✔
324
                                return apiBadRequest(c, "Cannot verify WireGuard public key")
1✔
325
                        }
1✔
326
                        client.PrivateKey = ""
1✔
327
                }
328

329
                // handle preshared key change
330
                if client.PresharedKey != _client.PresharedKey && _client.PresharedKey != "" {
7✔
331
                        if _, err := wgtypes.ParseKey(_client.PresharedKey); err != nil {
3✔
332
                                return apiBadRequest(c, "Cannot verify preshared key")
1✔
333
                        }
1✔
334
                }
335

336
                client.Name = _client.Name
4✔
337
                // email and enabled are not editable here — use PATCH /status for enabled
4✔
338
                client.UseServerDNS = _client.UseServerDNS
4✔
339
                client.AllocatedIPs = _client.AllocatedIPs
4✔
340
                client.AllowedIPs = _client.AllowedIPs
4✔
341
                client.ExtraAllowedIPs = _client.ExtraAllowedIPs
4✔
342
                client.Endpoint = _client.Endpoint
4✔
343
                client.PublicKey = _client.PublicKey
4✔
344
                client.PresharedKey = _client.PresharedKey
4✔
345
                client.UpdatedAt = time.Now().UTC()
4✔
346
                client.AdditionalNotes = strings.ReplaceAll(strings.Trim(_client.AdditionalNotes, "\r\n"), "\r\n", "\n")
4✔
347

4✔
348
                if err := db.SaveClient(client); err != nil {
4✔
UNCOV
349
                        return apiInternalError(c, err.Error())
×
UNCOV
350
                }
×
351

352
                cw.Trigger()
4✔
353
                log.Infof("Updated client: %v", client.Name)
4✔
354
                auditLogEvent(c, "client.update", "client", client.ID, map[string]string{"name": client.Name, "email": client.Email})
4✔
355
                return c.JSON(http.StatusOK, client)
4✔
356
        }
357
}
358

359
// APIPatchClientStatus enables/disables a client
360
func APIPatchClientStatus(db store.IStore, cw *ConfigWriter) echo.HandlerFunc {
5✔
361
        return func(c echo.Context) error {
10✔
362
                clientID := c.Param("id")
5✔
363
                if _, err := xid.FromString(clientID); err != nil {
6✔
364
                        return apiBadRequest(c, "Invalid client ID")
1✔
365
                }
1✔
366

367
                var body struct {
4✔
368
                        Enabled bool `json:"enabled"`
4✔
369
                }
4✔
370
                if err := c.Bind(&body); err != nil {
5✔
371
                        return apiBadRequest(c, "Invalid request body")
1✔
372
                }
1✔
373

374
                clientData, err := db.GetClientByID(clientID, model.QRCodeSettings{Enabled: false})
3✔
375
                if err != nil {
4✔
376
                        return apiNotFound(c, "Client not found")
1✔
377
                }
1✔
378

379
                client := *clientData.Client
2✔
380
                client.Enabled = body.Enabled
2✔
381
                if err := db.SaveClient(client); err != nil {
2✔
UNCOV
382
                        return apiInternalError(c, err.Error())
×
UNCOV
383
                }
×
384

385
                action := "client.disable"
2✔
386
                if body.Enabled {
3✔
387
                        action = "client.enable"
1✔
388
                }
1✔
389
                cw.Trigger()
2✔
390
                log.Infof("Changed client %s enabled status to %v", client.ID, body.Enabled)
2✔
391
                auditLogEvent(c, action, "client", client.ID, map[string]string{"name": client.Name, "email": client.Email})
2✔
392
                return c.JSON(http.StatusOK, client)
2✔
393
        }
394
}
395

396
// APIDeleteClient deletes a client
397
func APIDeleteClient(db store.IStore, cw *ConfigWriter) echo.HandlerFunc {
3✔
398
        return func(c echo.Context) error {
6✔
399
                clientID := c.Param("id")
3✔
400
                if _, err := xid.FromString(clientID); err != nil {
4✔
401
                        return apiBadRequest(c, "Invalid client ID")
1✔
402
                }
1✔
403

404
                if err := db.DeleteClient(clientID); err != nil {
3✔
405
                        return apiInternalError(c, "Cannot delete client")
1✔
406
                }
1✔
407

408
                cw.Trigger()
1✔
409
                log.Infof("Deleted wireguard client: %s", clientID)
1✔
410
                auditLogEvent(c, "client.delete", "client", clientID, nil)
1✔
411
                return c.NoContent(http.StatusNoContent)
1✔
412
        }
413
}
414

415
// APIDownloadClientConfig returns the .conf file for a client
416
func APIDownloadClientConfig(db store.IStore) echo.HandlerFunc {
4✔
417
        return func(c echo.Context) error {
9✔
418
                clientID := c.Param("id")
5✔
419
                if _, err := xid.FromString(clientID); err != nil {
6✔
420
                        return apiBadRequest(c, "Invalid client ID")
1✔
421
                }
1✔
422

423
                clientData, err := db.GetClientByID(clientID, model.QRCodeSettings{Enabled: false})
4✔
424
                if err != nil {
5✔
425
                        return apiNotFound(c, "Client not found")
1✔
426
                }
1✔
427

428
                // Non-admin users can only download their own configs
429
                if !isAdmin(c) {
5✔
430
                        userEmail := currentUserEmail(c, db)
2✔
431
                        if !strings.EqualFold(clientData.Client.Email, userEmail) {
3✔
432
                                return apiForbidden(c, "Access denied")
1✔
433
                        }
1✔
434
                }
435

436
                server, err := db.GetServer()
2✔
437
                if err != nil {
2✔
UNCOV
438
                        return apiInternalError(c, "Cannot get server config")
×
UNCOV
439
                }
×
440
                globalSettings, err := db.GetGlobalSettings()
2✔
441
                if err != nil {
2✔
442
                        return apiInternalError(c, "Cannot get global settings")
×
443
                }
×
444

445
                config := util.BuildClientConfig(*clientData.Client, server, globalSettings)
2✔
446
                c.Response().Header().Set(echo.HeaderContentDisposition, fmt.Sprintf("attachment; filename=%s.conf", clientData.Client.Name))
2✔
447
                auditLogEvent(c, "client.config.download", "client", clientID, map[string]string{"name": clientData.Client.Name, "email": clientData.Client.Email})
2✔
448
                return c.Stream(http.StatusOK, "text/conf", strings.NewReader(config))
2✔
449
        }
450
}
451

452
// APIGetClientQRCode returns the QR code for a client
453
func APIGetClientQRCode(db store.IStore) echo.HandlerFunc {
4✔
454
        return func(c echo.Context) error {
9✔
455
                clientID := c.Param("id")
5✔
456
                if _, err := xid.FromString(clientID); err != nil {
6✔
457
                        return apiBadRequest(c, "Invalid client ID")
1✔
458
                }
1✔
459

460
                clientData, err := db.GetClientByID(clientID, util.DefaultQRCodeSettings)
4✔
461
                if err != nil {
5✔
462
                        return apiNotFound(c, "Client not found")
1✔
463
                }
1✔
464

465
                // Non-admin users can only view their own QR codes
466
                if !isAdmin(c) {
5✔
467
                        userEmail := currentUserEmail(c, db)
2✔
468
                        if !strings.EqualFold(clientData.Client.Email, userEmail) {
3✔
469
                                return apiForbidden(c, "Access denied")
1✔
470
                        }
1✔
471
                }
472

473
                return c.JSON(http.StatusOK, map[string]string{
2✔
474
                        "qr_code": clientData.QRCode,
2✔
475
                })
2✔
476
        }
477
}
478

479
// APIEmailClient sends the client config via email
480
func APIEmailClient(db store.IStore, mailer emailer.Emailer, emailSubject, emailContent string) echo.HandlerFunc {
7✔
481
        return func(c echo.Context) error {
14✔
482
                clientID := c.Param("id")
7✔
483
                if _, err := xid.FromString(clientID); err != nil {
8✔
484
                        return apiBadRequest(c, "Invalid client ID")
1✔
485
                }
1✔
486

487
                var body struct {
6✔
488
                        Email string `json:"email"`
6✔
489
                }
6✔
490
                if err := c.Bind(&body); err != nil {
7✔
491
                        return apiBadRequest(c, "Invalid request body")
1✔
492
                }
1✔
493

494
                qrCodeSettings := model.QRCodeSettings{Enabled: true, IncludeDNS: true, IncludeMTU: true}
5✔
495
                clientData, err := db.GetClientByID(clientID, qrCodeSettings)
5✔
496
                if err != nil {
6✔
497
                        return apiNotFound(c, "Client not found")
1✔
498
                }
1✔
499

500
                // Non-admin users can only email their own configs
501
                if !isAdmin(c) {
5✔
502
                        userEmail := currentUserEmail(c, db)
1✔
503
                        if !strings.EqualFold(clientData.Client.Email, userEmail) {
2✔
504
                                return apiForbidden(c, "Access denied")
1✔
505
                        }
1✔
506
                }
507

508
                server, _ := db.GetServer()
3✔
509
                globalSettings, _ := db.GetGlobalSettings()
3✔
510
                config := util.BuildClientConfig(*clientData.Client, server, globalSettings)
3✔
511

3✔
512
                cfgAtt := emailer.Attachment{Name: "wg0.conf", Data: []byte(config)}
3✔
513
                var attachments []emailer.Attachment
3✔
514
                if clientData.Client.PrivateKey != "" {
5✔
515
                        qrdata, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(clientData.QRCode, "data:image/png;base64,"))
2✔
516
                        if err != nil {
2✔
UNCOV
517
                                return apiInternalError(c, "Cannot decode QR code")
×
UNCOV
518
                        }
×
519
                        attachments = []emailer.Attachment{cfgAtt, {Name: "wg.png", Data: qrdata}}
2✔
520
                } else {
1✔
521
                        attachments = []emailer.Attachment{cfgAtt}
1✔
522
                }
1✔
523

524
                err = mailer.Send(clientData.Client.Name, body.Email, emailSubject, emailContent, attachments)
3✔
525
                if err != nil {
4✔
526
                        return apiInternalError(c, err.Error())
1✔
527
                }
1✔
528

529
                auditLogEvent(c, "client.config.email", "client", clientID, map[string]string{"name": clientData.Client.Name, "email": clientData.Client.Email, "sent_to": body.Email})
2✔
530
                return c.JSON(http.StatusOK, map[string]string{"message": "Email sent successfully"})
2✔
531
        }
532
}
533

534
// APISuggestClientIPs suggests available IP addresses
535
func APISuggestClientIPs(db store.IStore) echo.HandlerFunc {
7✔
536
        return func(c echo.Context) error {
14✔
537
                server, err := db.GetServer()
7✔
538
                if err != nil {
8✔
539
                        return apiInternalError(c, "Cannot fetch server config")
1✔
540
                }
1✔
541

542
                allocatedIPs, err := db.GetAllocatedIPs("")
6✔
543
                if err != nil {
6✔
UNCOV
544
                        return apiInternalError(c, "Cannot get allocated IPs")
×
UNCOV
545
                }
×
546

547
                sr := c.QueryParam("sr")
6✔
548
                searchCIDRList := make([]string, 0)
6✔
549

6✔
550
                if util.SubnetRanges[sr] != nil {
7✔
551
                        for _, cidr := range util.SubnetRanges[sr] {
2✔
552
                                searchCIDRList = append(searchCIDRList, cidr.String())
1✔
553
                        }
1✔
554
                } else {
5✔
555
                        searchCIDRList = append(searchCIDRList, server.Interface.Addresses...)
5✔
556
                }
5✔
557

558
                ipSet := make(map[string]struct{})
6✔
559
                found := false
6✔
560

6✔
561
                for _, cidr := range searchCIDRList {
13✔
562
                        ip, err := util.GetAvailableIP(cidr, allocatedIPs, server.Interface.Addresses)
7✔
563
                        if err != nil {
8✔
564
                                continue
1✔
565
                        }
566
                        found = true
6✔
567
                        if strings.Contains(ip, ":") {
7✔
568
                                ipSet[fmt.Sprintf("%s/128", ip)] = struct{}{}
1✔
569
                        } else {
6✔
570
                                ipSet[fmt.Sprintf("%s/32", ip)] = struct{}{}
5✔
571
                        }
5✔
572
                }
573

574
                if !found {
7✔
575
                        return apiInternalError(c, "No available IPs. Try a different subnet or deallocate some IPs.")
1✔
576
                }
1✔
577

578
                suggestedIPs := make([]string, 0, len(ipSet))
5✔
579
                for ip := range ipSet {
11✔
580
                        suggestedIPs = append(suggestedIPs, ip)
6✔
581
                }
6✔
582
                return c.JSON(http.StatusOK, suggestedIPs)
5✔
583
        }
584
}
585

586
// APIMachineIPs returns local machine IP addresses
587
func APIMachineIPs() echo.HandlerFunc {
1✔
588
        return func(c echo.Context) error {
2✔
589
                interfaceList, err := util.GetInterfaceIPs()
1✔
590
                if err != nil {
1✔
UNCOV
591
                        return apiInternalError(c, "Cannot get machine IP addresses")
×
UNCOV
592
                }
×
593

594
                publicInterface, err := util.GetPublicIP()
1✔
595
                if err == nil {
2✔
596
                        interfaceList = append([]model.Interface{publicInterface}, interfaceList...)
1✔
597
                }
1✔
598

599
                return c.JSON(http.StatusOK, interfaceList)
1✔
600
        }
601
}
602

603
// APISubnetRanges returns the ordered list of subnet ranges
604
func APISubnetRanges() echo.HandlerFunc {
1✔
605
        return func(c echo.Context) error {
2✔
606
                return c.JSON(http.StatusOK, util.SubnetRangesOrder)
1✔
607
        }
1✔
608
}
609

610
// APIServerStatus returns WireGuard status with connected peers
611
func APIServerStatus(db store.IStore) echo.HandlerFunc {
1✔
612
        type PeerStatus struct {
1✔
613
                Name              string        `json:"name"`
1✔
614
                Email             string        `json:"email"`
1✔
615
                PublicKey         string        `json:"public_key"`
1✔
616
                ReceivedBytes     int64         `json:"received_bytes"`
1✔
617
                TransmitBytes     int64         `json:"transmit_bytes"`
1✔
618
                LastHandshakeTime time.Time     `json:"last_handshake_time"`
1✔
619
                LastHandshakeRel  time.Duration `json:"last_handshake_rel"`
1✔
620
                Connected         bool          `json:"connected"`
1✔
621
                AllocatedIP       string        `json:"allocated_ip"`
1✔
622
                Endpoint          string        `json:"endpoint"`
1✔
623
        }
1✔
624

1✔
625
        type DeviceStatus struct {
1✔
626
                Name  string       `json:"name"`
1✔
627
                Peers []PeerStatus `json:"peers"`
1✔
628
        }
1✔
629

1✔
630
        return func(c echo.Context) error {
2✔
631
                wgClient, err := wgctrl.New()
1✔
632
                if err != nil {
1✔
UNCOV
633
                        return apiInternalError(c, err.Error())
×
UNCOV
634
                }
×
635
                defer wgClient.Close()
1✔
636

1✔
637
                devices, err := wgClient.Devices()
1✔
638
                if err != nil {
1✔
UNCOV
639
                        return apiInternalError(c, err.Error())
×
UNCOV
640
                }
×
641

642
                devicesStatus := make([]DeviceStatus, 0, len(devices))
1✔
643
                if len(devices) > 0 {
1✔
644
                        m := make(map[string]*model.Client)
×
UNCOV
645
                        clients, _ := db.GetClients(false)
×
UNCOV
646
                        for i := range clients {
×
UNCOV
647
                                if clients[i].Client != nil {
×
648
                                        m[clients[i].Client.PublicKey] = clients[i].Client
×
649
                                }
×
650
                        }
651

652
                        conv := map[bool]int{true: 1, false: 0}
×
653
                        for i := range devices {
×
UNCOV
654
                                dev := DeviceStatus{Name: devices[i].Name}
×
UNCOV
655
                                for j := range devices[i].Peers {
×
656
                                        var allocatedIPs string
×
657
                                        for _, ip := range devices[i].Peers[j].AllowedIPs {
×
658
                                                if len(allocatedIPs) > 0 {
×
659
                                                        allocatedIPs += ", "
×
660
                                                }
×
661
                                                allocatedIPs += ip.String()
×
662
                                        }
663
                                        handshakeTime := devices[i].Peers[j].LastHandshakeTime
×
664
                                        var handshakeRel time.Duration
×
UNCOV
665
                                        if !handshakeTime.IsZero() {
×
666
                                                handshakeRel = time.Since(handshakeTime)
×
667
                                        }
×
668
                                        p := PeerStatus{
×
669
                                                PublicKey:         devices[i].Peers[j].PublicKey.String(),
×
670
                                                ReceivedBytes:     devices[i].Peers[j].ReceiveBytes,
×
671
                                                TransmitBytes:     devices[i].Peers[j].TransmitBytes,
×
672
                                                LastHandshakeTime: handshakeTime,
×
673
                                                LastHandshakeRel:  handshakeRel,
×
674
                                                AllocatedIP:       allocatedIPs,
×
675
                                        }
×
NEW
676
                                        p.Connected = isConnected(handshakeTime)
×
677

×
678
                                        if devices[i].Peers[j].Endpoint != nil {
×
679
                                                p.Endpoint = devices[i].Peers[j].Endpoint.String()
×
680
                                        }
×
681

682
                                        if cl, ok := m[p.PublicKey]; ok {
×
683
                                                p.Name = cl.Name
×
UNCOV
684
                                                p.Email = cl.Email
×
685
                                        }
×
686
                                        dev.Peers = append(dev.Peers, p)
×
687
                                }
688
                                sort.SliceStable(dev.Peers, func(a, b int) bool { return dev.Peers[a].Name < dev.Peers[b].Name })
×
689
                                sort.SliceStable(dev.Peers, func(a, b int) bool { return conv[dev.Peers[a].Connected] > conv[dev.Peers[b].Connected] })
×
UNCOV
690
                                devicesStatus = append(devicesStatus, dev)
×
691
                        }
692
                }
693

694
                return c.JSON(http.StatusOK, devicesStatus)
1✔
695
        }
696
}
697

698
// APIApplyServerConfig forces an immediate config write, bypassing debounce
699
func APIApplyServerConfig(cw *ConfigWriter) echo.HandlerFunc {
2✔
700
        return func(c echo.Context) error {
4✔
701
                if err := cw.ApplyNow(); err != nil {
3✔
702
                        return apiInternalError(c, fmt.Sprintf("Cannot apply config: %v", err))
1✔
703
                }
1✔
704

705
                auditLogEvent(c, "server.config.apply", "server", "config", nil)
1✔
706
                return c.JSON(http.StatusOK, map[string]string{"message": "Config applied successfully"})
1✔
707
        }
708
}
709

710
// APIExportClients exports all clients as an Excel file
711
func APIExportClients(db store.IStore) echo.HandlerFunc {
2✔
712
        return func(c echo.Context) error {
4✔
713
                clientDataList, err := db.GetClients(false)
2✔
714
                if err != nil {
3✔
715
                        return apiInternalError(c, "Cannot get client list")
1✔
716
                }
1✔
717

718
                f := excelize.NewFile()
1✔
719
                sheet := "Clients"
1✔
720
                f.SetSheetName("Sheet1", sheet)
1✔
721

1✔
722
                headers := []string{"Name", "Email", "Allocated IPs", "Allowed IPs", "Extra Allowed IPs", "Enabled", "Created", "Updated"}
1✔
723
                for i, h := range headers {
9✔
724
                        cell, _ := excelize.CoordinatesToCellName(i+1, 1)
8✔
725
                        f.SetCellValue(sheet, cell, h)
8✔
726
                }
8✔
727

728
                style, _ := f.NewStyle(&excelize.Style{
1✔
729
                        Font: &excelize.Font{Bold: true},
1✔
730
                })
1✔
731
                f.SetCellStyle(sheet, "A1", fmt.Sprintf("%s1", string(rune('A'+len(headers)-1))), style)
1✔
732

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

750
                for i := range headers {
9✔
751
                        col, _ := excelize.ColumnNumberToName(i + 1)
8✔
752
                        f.SetColWidth(sheet, col, col, 25)
8✔
753
                }
8✔
754

755
                c.Response().Header().Set("Content-Disposition", "attachment; filename=clients.xlsx")
1✔
756
                c.Response().Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
1✔
757
                return f.Write(c.Response())
1✔
758
        }
759
}
760

761
// APIConfigStatus checks if the config has changed
762
func APIConfigStatus(db store.IStore) echo.HandlerFunc {
2✔
763
        return func(c echo.Context) error {
4✔
764
                changed := util.HashesChanged(db)
2✔
765
                return c.JSON(http.StatusOK, map[string]bool{"changed": changed})
2✔
766
        }
2✔
767
}
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