Improve WireGuard apply flow and shell UI

- Respect tunnel running state when saving config; avoid implicit restart when down
- Optional pending wg.conf when tunnel stopped (WGUI_WGCONF_PENDING_WHEN_TUNNEL_STOPPED)
- wg-quick: best-effort down before up with retry on transient failures
- Dashboard: explicit green/red badges for server active/inactive (light theme)
- Client cards: metadata chips row uses full card width below header row
- Locales and README updates for behavior and troubleshooting
This commit is contained in:
Skyline 2026-05-01 16:56:06 -06:00
parent 1b07fe442d
commit ee0399b26c
16 changed files with 486 additions and 196 deletions

View File

@ -158,11 +158,16 @@ Gate optional privileged actions invoked from the **Servidor** page (binary or D
| `WGUI_WG_SYNCCONF_AFTER_APPLY` | When `true`, **Aplicar config** runs **`wg-quick strip <conf> \| wg syncconf <iface>`** on Linux so the running WireGuard matches the written file (e.g. disabling a client removes its peer from the server without `wg-quick down/up`). Requires `wg` and `wg-quick` on `$PATH`. If unset or `false`, Apply only writes the file/hash and does not reload kernel state. | `false` |
| `WGUI_ALLOW_WG_QUICK` | When `true`, **Apply** can run `wg-quick` down/up and **Servidor** shows **Detener** / **Iniciar** / **Reiniciar**. If unset, wg-quick controls are **off**. Start with `WGUI_ALLOW_WG_QUICK=true` when you intend to restart the tunnel from the UI. Env values are trimmed before parsing. | `false` |
| `WGUI_WG_RESTART_VIA_SYSTEMD` | On Linux, **Apply** prefers `systemctl restart wg-quick@ifac` when that unit exists (`LoadState=loaded`), so **`journalctl -u wg-quick@wg0`** shows restarts like a manual systemd restart. If `false` or no systemd, uses `wg-quick down`/`up`. | `true` |
| `WGUI_WGCONF_PENDING_WHEN_TUNNEL_STOPPED` | Linux: when Apply does **not** restart WireGuard while the netdev is absent/down (e.g. after **Detener**), the UI writes a side file next to `wg.conf` (suffix `.wgui-pending`) instead of overwriting the live **`WGUI_CONFIG_FILE_PATH`**. That avoids systemd **`.path`** units watching `wg.conf` that restart `wg-quick` on every save. **`wg-quick up`** or **Servidor Iniciar** merges the pending file into `wg.conf` first. Set `false` to always write `wg.conf` directly (legacy). | `true` |
| `WGUI_LOG_TAIL_PATH` | Optional absolute path to a log file shown in the **Logs** page. This variable is read-only: wireguard-ui does not write this file automatically. | _(unset)_ |
| `WGUI_WEBAUTHN_RP_ID` | Optional fixed WebAuthn RP ID (recommended behind reverse proxy/public domain). If unset, it is inferred from request host. | _(auto)_ |
| `WGUI_WEBAUTHN_RP_ORIGINS` | Optional comma-separated allowed origins for Passkeys (example: `https://vpn.example.com,https://admin.example.com`). If unset, origin is inferred per request. | _(auto)_ |
| `WGUI_WEBAUTHN_RP_DISPLAY_NAME` | Optional WebAuthn RP display name shown by authenticators. | `WireGuard UI` |
#### Troubleshooting: `wg-quick up` fails on `ip -6 route` / «Cannot find device wg0»
After toggling peers and **Iniciar**, a failed half-bridge can leave routing in an odd state; the UI now runs **`wg-quick down`** (ignored if already down), waits briefly, then **`wg-quick up`**, and **retries once** if the first `up` still errors. If it persists, exclude **`wg0`** from **NetworkManager** / **systemd-networkd**, and ensure IPv6 is consistent (either working or intentionally off) with the **`Address`** line in **`wg.conf`**.
#### `WGUI_LOG_TAIL_PATH` quick setup (systemd)
Use this when you want the **Logs** page to also show a custom application log file.
@ -279,7 +284,38 @@ If HTTPS still fails behind NAT, verify port 80 reaches Caddy on first certifica
WireGuard-UI only takes care of configuration generation. On Linux you can enable in-process `wg syncconf` after apply (see variables above), or use systemd to watch for changes and restart the
service. Following is an example:
### Using systemd
> **Note:** The **systemd** block below does **not** start the `wireguard-ui` web process. It only runs `systemctl restart wg-quick@wg0` when `wg0.conf` is modified on disk. The UI binary is a separate program (see **Run WireGuard-UI** above and **systemd unit for `wireguard-ui`** below).
### systemd unit for `wireguard-ui` (web app)
The app stores its JSON database under **`./db` relative to the process working directory**, so the unit should set `WorkingDirectory` to a folder you own (e.g. `/var/lib/wireguard-ui`) and place the binary on your `PATH` or use an absolute `ExecStart`.
Example `/etc/systemd/system/wireguard-ui.service`:
```ini
[Unit]
Description=WireGuard UI
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=wireguard-ui
Group=wireguard-ui
WorkingDirectory=/var/lib/wireguard-ui
Environment="BIND_ADDRESS=127.0.0.1:5000"
# Optional: EnvironmentFile=-/etc/wireguard-ui.env
ExecStart=/usr/local/bin/wireguard-ui
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
```
Create the data directory and user as needed (names are examples), then `systemctl daemon-reload`, `systemctl enable --now wireguard-ui`.
### Using systemd (restart `wg-quick` when config file changes)
Create `/etc/systemd/system/wgui.service`

View File

@ -63,15 +63,16 @@ body.wg-body.wg-theme-light .logo-ico img{filter:invert(1)}
/* Client cards: overlay only covers peer column (.cc-head-main), never the action chips */
.wg-client-card.client-card{position:relative;background:var(--card);border:1px solid var(--bdr);border-radius:var(--rlg);margin-bottom:12px;overflow:visible}
.wg-client-card .cc-head{display:flex;align-items:flex-start;gap:10px;padding:14px 16px;border-bottom:1px solid transparent;background:var(--card);border-radius:inherit}
.wg-client-card .cc-head{display:flex;flex-direction:column;align-items:stretch;gap:10px;padding:14px 16px;border-bottom:1px solid transparent;background:var(--card);border-radius:inherit}
.wg-client-card.expanded .cc-head{border-bottom-color:var(--bdr);border-radius:var(--rlg) var(--rlg) 0 0}
.wg-client-card .cc-head-top{display:flex;align-items:flex-start;gap:10px;width:100%;min-width:0}
.wg-client-card .cc-head-main{position:relative;flex:1;min-width:0;display:flex;align-items:flex-start;min-height:52px;border-radius:12px}
.wg-client-card .cc-head-main .wg-paused-overlay{position:absolute;inset:0;z-index:2;border-radius:inherit;background:rgba(0,0,0,.58);display:flex;align-items:center;justify-content:center}
.wg-client-card[data-wg-enabled="false"] .cc-head-main .cc-peer-click{pointer-events:none}
.wg-client-card[data-wg-enabled="false"] .cc-body-inner{opacity:.45;pointer-events:none;filter:grayscale(.25)}
.wg-client-card .cc-head-main .cc-peer-click{position:relative;z-index:1;display:flex;gap:12px;align-items:flex-start;cursor:pointer;width:100%;min-width:0}
/* Header right: status badge + enable toggle */
.wg-client-card .cc-head-right{display:flex;align-items:center;flex-shrink:0;gap:10px;margin-left:auto}
.wg-client-card .cc-head-right{display:flex;align-items:center;flex-shrink:0;gap:10px}
.wg-cc-badge{display:inline-flex;align-items:center;gap:5px;font-size:10px;font-weight:700;color:var(--t2);}
.wg-cc-badge-dot{width:7px;height:7px;border-radius:50%;background:#757575;}
.wg-cc-badge-on{color:#66BB6A;}
@ -86,15 +87,18 @@ body.wg-body.wg-theme-light .logo-ico img{filter:invert(1)}
.wg-cc-switch input:checked + .wg-cc-switch-track{background:rgba(239,83,80,.35);border-color:rgba(239,83,80,.55);}
.wg-cc-switch input:checked + .wg-cc-switch-track:after{left:21px;background:var(--acc,#EF5350);}
.wg-client-card .wg-title{font-size:14px;font-weight:800;color:var(--t1);}
/* Header meta row: email / dates / DNS as spaced chips */
.wg-client-card .cc-extra-lines{display:flex;flex-wrap:wrap;gap:8px 12px;margin-top:10px;max-width:100%;padding-top:2px;}
.wg-client-card .cc-chip{display:inline-flex;align-items:flex-start;gap:8px;padding:6px 11px;background:var(--ele);border:1px solid var(--bdr);border-radius:10px;font-size:11px;line-height:1.45;color:var(--t1);max-width:100%;}
.wg-client-card .cc-chip i.fas{width:14px;text-align:center;flex-shrink:0;margin-top:2px;color:#EF5350;opacity:.9;}
/* Header meta: same rhythm as .cc-field — grid on mobile avoids mis-sized flex chips */
.wg-client-card .cc-extra-lines{display:grid;grid-template-columns:1fr;gap:10px;margin:0;width:100%;min-width:0;box-sizing:border-box;padding-top:0;position:relative;z-index:1}
@media(min-width:560px){.wg-client-card .cc-extra-lines{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(min-width:900px){.wg-client-card .cc-extra-lines{grid-template-columns:repeat(4,minmax(0,1fr))}}
.wg-client-card .cc-chip{display:flex;flex-direction:row;align-items:flex-start;gap:10px;min-width:0;width:100%;box-sizing:border-box;padding:9px 11px;background:var(--ele);border:1px solid var(--bdr);border-radius:var(--rsm);font-size:11px;line-height:1.35;color:var(--t1)}
.wg-client-card .cc-chip i.fas{width:15px;text-align:center;flex-shrink:0;margin-top:1px;color:#EF5350;opacity:.9}
.wg-client-card .cc-chip .text-muted-cc{opacity:.45!important;color:var(--t3)!important;}
.wg-client-card .cc-chip-label{font-size:10px;color:var(--t3);font-weight:600;text-transform:none;margin-right:2px;}
.wg-client-card .cc-chip-dat,.wg-client-card .cc-chip-val{font-variant-numeric:tabular-nums;color:var(--t1);}
.wg-client-card .cc-chip-wide{flex:1 1 100%;min-width:min(100%,220px);}
.wg-client-card .cc-chip-val{word-break:break-word;}
.wg-client-card .cc-chip-body{display:flex;flex-direction:column;align-items:flex-start;gap:3px;min-width:0;flex:1}
.wg-client-card .cc-chip-k{font-size:9px;color:var(--t3);font-weight:700;letter-spacing:.35px;text-transform:uppercase;line-height:1.2}
.wg-client-card .cc-chip-dat{font-variant-numeric:tabular-nums;color:var(--t1);font-size:11px;line-height:1.25;max-width:100%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.wg-client-card .cc-chip-val{font-variant-numeric:tabular-nums;color:var(--t1);font-size:11px;line-height:1.3;word-break:break-word;max-width:100%}
.wg-client-card .cc-chip-wide{grid-column:1/-1}
.wg-client-card .wg-traf-rx{color:#EF5350!important;font-weight:600;}
.wg-client-card .wg-traf-tx{color:#FFCA28!important;font-weight:600;}
.wg-client-card .cc-pub{margin-top:14px;padding:12px 14px;background:var(--ele);border-radius:var(--rsm);border:1px solid var(--bdr);}
@ -244,6 +248,17 @@ body.wg-body.wg-theme-light .wg-mini-badge{
background:var(--ele) !important;
border:1px solid var(--bdr) !important;
}
/* Dashboard WireGuard server status: explicit green/red in light theme (generic .wg-mini-badge above would mute it) */
body.wg-body.wg-theme-light .wg-mini-badge.wg-srv-status--active{
color:#2e7d32 !important;
background:rgba(102,187,106,.14) !important;
border-color:rgba(76,175,80,.4) !important;
}
body.wg-body.wg-theme-light .wg-mini-badge.wg-srv-status--inactive{
color:#c62828 !important;
background:rgba(239,83,80,.1) !important;
border-color:rgba(239,83,80,.35) !important;
}
body.wg-body.wg-theme-light .wg-set-row label,
body.wg-body.wg-theme-light .wg-help dt,
body.wg-body.wg-theme-light .wg-traf-table,

View File

@ -177,52 +177,63 @@ function renderClientList(data, peerTraffic) {
' data-wg-online="' + (c.enabled ? '1' : '0') + '" data-wg-enabled="' + (c.enabled ? 'true' : 'false') + '"' +
' data-client-pubkey="' + wgEscapeAttr(pkRaw) + '">' +
'<div class="cc-head">' +
'<div class="cc-head-main">' +
'<div class="wg-paused-overlay" id="paused_' + id + '" style="' + overlayStyle + '" title="' + wgEscapeAttr(wgT('helper.client_disabled')) + '">' +
'<i class="paused-client fas fa-3x fa-play" onclick="resumeClient(\'' + id + '\')" style="cursor:pointer;color:var(--acc,#EF5350);position:relative;z-index:3" aria-hidden="true"></i>' +
'</div>' +
'<div class="cc-peer-click" onclick="wgTogglePeerCard(this.closest(\'.wg-client-card\'))">' +
'<div class="cc-avatar">' +
'<i class="fas fa-laptop" style="color:#EF5350"></i>' +
'<div class="cc-head-top">' +
'<div class="cc-head-main">' +
'<div class="wg-paused-overlay" id="paused_' + id + '" style="' + overlayStyle + '" title="' + wgEscapeAttr(wgT('helper.client_disabled')) + '">' +
'<i class="paused-client fas fa-3x fa-play" onclick="resumeClient(\'' + id + '\')" style="cursor:pointer;color:var(--acc,#EF5350);position:relative;z-index:3" aria-hidden="true"></i>' +
'</div>' +
'<div class="cc-info">' +
'<span class="info-box-text wg-title"><i class="fas fa-user"></i> ' + wgEscapeHtml(c.name) + '</span>' +
'<span class="info-box-text" style="display:none"><i class="fas fa-envelope"></i>' +
wgEscapeHtml(c.email || '') + '</span>' +
'<div class="cc-meta">' + wgEscapeHtml(ipsJoined) + ' · ' + wgEscapeHtml(wgT('helper.updated')) + ' ' + prettyDateTime(c.updated_at) + '</div>' +
'<span class="info-box-text" style="display:none"><i class="fas fa-key"></i>' + wgEscapeHtml(c.public_key || '') + '</span>' +
'<span class="info-box-text" style="display:none"><i class="fas fa-subnetrange"></i>' + wgEscapeHtml(subnetRangesString) + '</span>' +
telegramHtml + notesHtml +
'<div class="cc-extra-lines" aria-label="' + wgEscapeAttr(wgT('helper.peer_meta_aria')) + '">' +
'<span class="cc-chip" title="' + wgEscapeAttr(wgT('helper.email_title')) + '"><i class="fas fa-envelope" aria-hidden="true"></i>' +
'<span class="cc-chip-val">' + wgEscapeHtml(c.email || '—') + '</span></span>' +
'<span class="cc-chip" title="' + wgEscapeAttr(wgT('helper.created_chip_title')) + '">' +
'<i class="fas fa-clock" aria-hidden="true"></i>' +
'<span class="cc-chip-label">' + wgEscapeHtml(wgT('helper.created_lbl')) + '</span>' +
'<span class="cc-chip-dat">' + prettyDateTime(c.created_at) + '</span></span>' +
'<span class="cc-chip" title="' + wgEscapeAttr(wgT('helper.updated_chip_title')) + '">' +
'<i class="fas fa-history" aria-hidden="true"></i>' +
'<span class="cc-chip-label">' + wgEscapeHtml(wgT('helper.updated_lbl')) + '</span>' +
'<span class="cc-chip-dat">' + prettyDateTime(c.updated_at) + '</span></span>' +
'<span class="cc-chip">' +
'<i class="fas fa-server' + (c.use_server_dns ? '' : ' text-muted-cc') +
'" aria-hidden="true"></i>' +
'<span class="cc-chip-val">' + dnsChipTxt + '</span></span>' +
(c.additional_notes ? '<span class="cc-chip cc-chip-wide"><i class="fas fa-file-alt" aria-hidden="true"></i>' +
'<span class="cc-chip-val">' + wgEscapeHtml(c.additional_notes) + '</span></span>' : '') +
'<div class="cc-peer-click" onclick="wgTogglePeerCard(this.closest(\'.wg-client-card\'))">' +
'<div class="cc-avatar">' +
'<i class="fas fa-laptop" style="color:#EF5350"></i>' +
'</div>' +
'<div class="cc-info">' +
'<span class="info-box-text wg-title"><i class="fas fa-user"></i> ' + wgEscapeHtml(c.name) + '</span>' +
'<span class="info-box-text" style="display:none"><i class="fas fa-envelope"></i>' +
wgEscapeHtml(c.email || '') + '</span>' +
'<div class="cc-meta">' + wgEscapeHtml(ipsJoined) + ' · ' + wgEscapeHtml(wgT('helper.updated')) + ' ' + prettyDateTime(c.updated_at) + '</div>' +
'<span class="info-box-text" style="display:none"><i class="fas fa-key"></i>' + wgEscapeHtml(c.public_key || '') + '</span>' +
'<span class="info-box-text" style="display:none"><i class="fas fa-subnetrange"></i>' + wgEscapeHtml(subnetRangesString) + '</span>' +
telegramHtml + notesHtml +
'</div>' +
'</div>' +
'</div>' +
'<div class="cc-head-right" onclick="event.stopPropagation()">' +
'<span class="wg-cc-badge ' + (c.enabled ? 'wg-cc-badge-on' : 'wg-cc-badge-off') + '">' +
'<span class="wg-cc-badge-dot" aria-hidden="true"></span>' +
'<span class="wg-cc-badge-txt">' + wgEscapeHtml(c.enabled ? wgT('helper.badge_online') : wgT('helper.badge_blocked')) + '</span></span>' +
'<label class="wg-cc-switch" title="' + wgEscapeAttr(wgT('helper.switch_peer')) + '">' +
'<input type="checkbox" class="wg-cc-toggle"' + (c.enabled ? ' checked' : '') +
' data-clientid="' + id + '" onchange="wgPeerToggleEnable(event,this)" onclick="event.stopPropagation()"/>' +
'<span class="wg-cc-switch-track" aria-hidden="true"></span>' +
'</label>' +
'</div>' +
'</div>' +
'<div class="cc-head-right" onclick="event.stopPropagation()">' +
'<span class="wg-cc-badge ' + (c.enabled ? 'wg-cc-badge-on' : 'wg-cc-badge-off') + '">' +
'<span class="wg-cc-badge-dot" aria-hidden="true"></span>' +
'<span class="wg-cc-badge-txt">' + wgEscapeHtml(c.enabled ? wgT('helper.badge_online') : wgT('helper.badge_blocked')) + '</span></span>' +
'<label class="wg-cc-switch" title="' + wgEscapeAttr(wgT('helper.switch_peer')) + '">' +
'<input type="checkbox" class="wg-cc-toggle"' + (c.enabled ? ' checked' : '') +
' data-clientid="' + id + '" onchange="wgPeerToggleEnable(event,this)" onclick="event.stopPropagation()"/>' +
'<span class="wg-cc-switch-track" aria-hidden="true"></span>' +
'</label>' +
'<div class="cc-extra-lines" onclick="event.stopPropagation()" aria-label="' + wgEscapeAttr(wgT('helper.peer_meta_aria')) + '">' +
'<span class="cc-chip" title="' + wgEscapeAttr(wgT('helper.email_title')) + '">' +
'<i class="fas fa-envelope" aria-hidden="true"></i>' +
'<span class="cc-chip-body">' +
'<span class="cc-chip-k">' + wgEscapeHtml(wgT('helper.email_short_lbl')) + '</span>' +
'<span class="cc-chip-val">' + wgEscapeHtml(c.email || '—') + '</span></span></span>' +
'<span class="cc-chip" title="' + wgEscapeAttr(wgT('helper.created_chip_title')) + '">' +
'<i class="fas fa-clock" aria-hidden="true"></i>' +
'<span class="cc-chip-body">' +
'<span class="cc-chip-k">' + wgEscapeHtml(wgT('helper.created_lbl')) + '</span>' +
'<span class="cc-chip-dat">' + wgEscapeHtml(prettyDateTime(c.created_at)) + '</span></span></span>' +
'<span class="cc-chip" title="' + wgEscapeAttr(wgT('helper.updated_chip_title')) + '">' +
'<i class="fas fa-history" aria-hidden="true"></i>' +
'<span class="cc-chip-body">' +
'<span class="cc-chip-k">' + wgEscapeHtml(wgT('helper.updated_lbl')) + '</span>' +
'<span class="cc-chip-dat">' + wgEscapeHtml(prettyDateTime(c.updated_at)) + '</span></span></span>' +
'<span class="cc-chip">' +
'<i class="fas fa-server' + (c.use_server_dns ? '' : ' text-muted-cc') +
'" aria-hidden="true"></i>' +
'<span class="cc-chip-body">' +
'<span class="cc-chip-k">' + wgEscapeHtml(wgT('helper.dns_section_lbl')) + '</span>' +
'<span class="cc-chip-val">' + wgEscapeHtml(dnsChipTxt) + '</span></span></span>' +
(c.additional_notes ? '<span class="cc-chip cc-chip-wide"><i class="fas fa-file-alt" aria-hidden="true"></i>' +
'<span class="cc-chip-body">' +
'<span class="cc-chip-k">' + wgEscapeHtml(wgT('helper.notes_inline_lbl')) + '</span>' +
'<span class="cc-chip-val">' + wgEscapeHtml(c.additional_notes) + '</span></span></span>' : '') +
'</div>' +
'</div>' +
'<div class="cc-body">' +

View File

@ -13,7 +13,9 @@ import (
"io/fs"
"net/http"
"os"
"path/filepath"
"regexp"
"runtime"
"sort"
"strconv"
"strings"
@ -1455,15 +1457,16 @@ func WireGuardServer(db store.IStore) echo.HandlerFunc {
dnsCsv := strings.Join(globalSettings.DNSServers, ", ")
return renderShell(c, db, "server.html", map[string]interface{}{
"baseData": model.BaseData{Active: "wg-server", CurrentUser: currentUser(c), Admin: isAdmin(c)},
"page_subtitle": fmt.Sprintf("Configuración de %s", ifaceName),
"serverInterface": server.Interface,
"serverKeyPair": server.KeyPair,
"globalSettings": globalSettings,
"wgIfaceName": ifaceName,
"dnsCsv": dnsCsv,
"serverBanner": banner,
"allowWgQuick": util.LookupEnvOrBool(util.AllowWgQuickCtlEnvVar, false),
"baseData": model.BaseData{Active: "wg-server", CurrentUser: currentUser(c), Admin: isAdmin(c)},
"page_subtitle": fmt.Sprintf("Configuración de %s", ifaceName),
"serverInterface": server.Interface,
"serverKeyPair": server.KeyPair,
"globalSettings": globalSettings,
"wgIfaceName": ifaceName,
"dnsCsv": dnsCsv,
"serverBanner": banner,
"allowWgQuick": util.LookupEnvOrBool(util.AllowWgQuickCtlEnvVar, false),
"needsWgConfApply": util.HashesChanged(db),
})
}
}
@ -1784,6 +1787,11 @@ func applyKernelAfterWritingWgConf(configFilePath string, wgQuickRestart bool) e
}
return nil
}
// With no wg-quick restart, optional syncconf must not fail when iface is absent (tunnel stopped on purpose).
if util.ShouldApplySyncconfAfterWrite() && !util.WgTunnelIsRunning(configFilePath) {
log.Infof("Skipping wg syncconf (tunnel down) after apply: %s", configFilePath)
return nil
}
if synErr := util.WgSyncConfFromSavedFile(configFilePath); synErr != nil {
return fmt.Errorf("config saved but kernel reload failed (%s): %w", configFilePath, synErr)
}
@ -1815,7 +1823,19 @@ func applyWireGuardConfigToDisk(db store.IStore, tmplDir fs.FS, wgQuickRestart b
return fmt.Errorf("cannot get global settings: %w", err)
}
err = util.WriteWireGuardServerConfig(tmplDir, server, clients, users, settings)
canonical := strings.TrimSpace(settings.ConfigFilePath)
outPath := canonical
if wgQuickRestart {
util.RemoveWgConfPending(canonical)
} else if util.WgConfPendingWhenTunnelStopped() && runtime.GOOS == "linux" &&
canonical != "" && filepath.IsAbs(canonical) && !util.WgTunnelIsRunning(canonical) {
outPath = util.WgConfPendingPath(canonical)
log.Infof("Applying wg config while tunnel stopped: writing pending file %s (canonical %s)", outPath, canonical)
} else if canonical != "" {
util.RemoveWgConfPending(canonical)
}
err = util.WriteWireGuardServerConfig(tmplDir, server, clients, users, settings, outPath)
if err != nil {
log.Error("Cannot apply server config: ", err)
return err
@ -1827,15 +1847,21 @@ func applyWireGuardConfigToDisk(db store.IStore, tmplDir fs.FS, wgQuickRestart b
return err
}
return applyKernelAfterWritingWgConf(settings.ConfigFilePath, wgQuickRestart)
return applyKernelAfterWritingWgConf(canonical, wgQuickRestart)
}
// ApplyServerConfig writes wg.conf and applies it to the kernel. Optional JSON body:
// - Empty body or absent key: try wg-quick restart (down+up); when not allowed or on failure, fall back to wg syncconf.
// - Empty body or omit restart_wireguard: restart only if util.WgTunnelIsRunning(ConfigFilePath) is true — avoids restarting a tunnel that was deliberately stopped via wg-quick down.
// - {"restart_wireguard":false}: syncconf only (e.g. session/appearance-only changes plus config dump without bringing the iface down).
// - {"restart_wireguard":true}: always run wg-quick restart (or systemd equivalent), even when the tunnel is currently down — use to bring WG up alongside applying the file.
func ApplyServerConfig(db store.IStore, tmplDir fs.FS) echo.HandlerFunc {
return func(c echo.Context) error {
restartWG := true
gsSnap, gsErr := db.GetGlobalSettings()
if gsErr != nil {
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot read global settings"})
}
restartWG := util.WgTunnelIsRunning(gsSnap.ConfigFilePath)
body, errRead := io.ReadAll(c.Request().Body)
if errRead != nil {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Cannot read request body"})
@ -1947,7 +1973,11 @@ func WireGuardServerSave(db store.IStore, tmplDir fs.FS) echo.HandlerFunc {
}
}
return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Servidor actualizado"})
return c.JSON(http.StatusOK, map[string]interface{}{
"status": true,
"message": "Servidor actualizado",
"needs_wg_conf_apply": util.HashesChanged(db),
})
}
}
@ -1981,6 +2011,22 @@ func WireGuardQuickStart(db store.IStore) echo.HandlerFunc {
}
}
// WireGuardTunnelStatus exposes util.WgTunnelIsRunning(gs.ConfigFilePath) for dashboards and tooling.
func WireGuardTunnelStatus(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
gs, err := db.GetGlobalSettings()
if err != nil {
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot read global settings"})
}
confPath := strings.TrimSpace(gs.ConfigFilePath)
iface := util.WireGuardIfaceBasename(confPath)
return c.JSON(http.StatusOK, map[string]interface{}{
"tunnel_running": util.WgTunnelIsRunning(confPath),
"iface_name": iface,
})
}
}
// GetUINavHints returns navbar + shell prefs without a full page reload (badge, Logs, theme, HTML lang).
func GetUINavHints(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {

View File

@ -115,6 +115,9 @@
"helper.created_lbl": "Created",
"helper.updated_lbl": "Updated",
"helper.email_title": "Email",
"helper.email_short_lbl": "Email",
"helper.dns_section_lbl": "DNS",
"helper.notes_inline_lbl": "Notes",
"helper.created_chip_title": "Created",
"helper.updated_chip_title": "Last modified",
"helper.badge_online": "Online",
@ -278,6 +281,7 @@
"server.js_confirm_dirty_then_apply": "You have unsaved changes. Save them and then apply configuration to wg.conf?",
"server.js_confirm_saved_apply": "Changes saved. Apply configuration to wg.conf now?",
"server.js_confirm_apply_saved": "Apply saved configuration to wg.conf now?",
"server.js_tunnel_down_apply_note": "The tunnel is stopped: we only write wg.conf to disk; use Start (Iniciar) to bring WireGuard up.",
"server.js_confirm_quick_down": "Stop the WireGuard interface (wg-quick down) using the global Config File Path?",
"server.js_confirm_quick_up": "Bring up WireGuard (wg-quick up) with the file from global Config File Path?",
"server.js_confirm_quick_restart": "Restart the WireGuard interface now? (wg-quick down + wg-quick up)",

View File

@ -115,6 +115,9 @@
"helper.created_lbl": "Creado",
"helper.updated_lbl": "Actualizado",
"helper.email_title": "Correo",
"helper.email_short_lbl": "Correo",
"helper.dns_section_lbl": "DNS",
"helper.notes_inline_lbl": "Notas",
"helper.created_chip_title": "Fecha de creación",
"helper.updated_chip_title": "Última modificación",
"helper.badge_online": "Online",
@ -278,6 +281,7 @@
"server.js_confirm_dirty_then_apply": "Hay cambios sin guardar. ¿Quieres guardarlos y luego aplicar configuración a wg.conf?",
"server.js_confirm_saved_apply": "Cambios guardados. ¿Aplicar ahora configuración a wg.conf?",
"server.js_confirm_apply_saved": "¿Aplicar ahora configuración guardada a wg.conf?",
"server.js_tunnel_down_apply_note": "El túnel está parado: solo escribimos wg.conf en disco; no iniciará hasta que pulses «Iniciar».",
"server.js_confirm_quick_down": "¿Detener la interfaz WireGuard (wg-quick down) usando la ruta de Config File Path global?",
"server.js_confirm_quick_up": "¿Levantar la interfaz WireGuard (wg-quick up) con el archivo configurado en Config File Path global?",
"server.js_confirm_quick_restart": "¿Reiniciar interfaz WireGuard ahora? (wg-quick down + wg-quick up)",

View File

@ -269,6 +269,8 @@ func main() {
app.POST(util.BasePath+"/api/wg-server/save-page", handler.WireGuardServerSave(db, tmplDir), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin)
app.POST(util.BasePath+"/api/wireguard/wg-quick-down", handler.WireGuardQuickStop(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin)
app.POST(util.BasePath+"/api/wireguard/wg-quick-up", handler.WireGuardQuickStart(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin)
// Same session gate as /api/apply-wg-config (ValidSession, not admin-only) so managers can align restart with tunnel state.
app.GET(util.BasePath+"/api/wireguard/tunnel-status", handler.WireGuardTunnelStatus(db), handler.ValidSession, handler.RefreshSession)
app.POST(util.BasePath+"/wg-server/keypair", handler.WireGuardServerKeyPair(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin)
app.GET(util.BasePath+"/global-settings", handler.GlobalSettings(db), handler.ValidSession, handler.RefreshSession, handler.NeedsAdmin)
app.POST(util.BasePath+"/global-settings", handler.GlobalSettingSubmit(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin)
@ -348,7 +350,7 @@ func initServerConfig(db store.IStore, tmplDir fs.FS) {
}
// write config file
err = util.WriteWireGuardServerConfig(tmplDir, server, clients, users, settings)
err = util.WriteWireGuardServerConfig(tmplDir, server, clients, users, settings, "")
if err != nil {
log.Fatalf("Cannot create server config: %v", err)
}

View File

@ -835,24 +835,32 @@
var hasPendingGlobalPayload = false;
try { pendingKindSnap = String(localStorage.getItem(WG_PENDING_KIND_KEY) || ''); } catch (eS) {}
try { hasPendingGlobalPayload = !!localStorage.getItem('wg_pending_global_settings'); } catch (ePen) {}
/** Request wg-quick restart unless the only pending global-settings change is session/appearance (staged JSON exists and kind is ui_only).
If there is no staged global payload but a stale ui_only kind lingers in localStorage, do not force restart false. */
var restartWireguard = !(hasPendingGlobalPayload && pendingKindSnap === 'ui_only');
/** ui_only (session/appearance cards): never restart WG. wg_card / both / client-only: restart only if tunnel is actually up (GET tunnel-status). */
function resolveApplyPayloadFromTunnel(tunnelResp) {
if (hasPendingGlobalPayload && pendingKindSnap === 'ui_only') {
return { restart_wireguard: false };
}
var tunnelUp = !!(tunnelResp && tunnelResp.tunnel_running);
return { restart_wireguard: tunnelUp };
}
var applyWgConfig = function (reloadAfterApplyForNav, doRestartWG) {
var applyPayload;
var applyWgConfig = function (reloadAfterApplyForNav) {
var skipKernelRestart = (applyPayload.restart_wireguard === false);
$.ajax({
cache: false,
method: 'POST',
url: '{{.basePath}}/api/apply-wg-config',
dataType: 'json',
contentType: "application/json",
data: JSON.stringify({ restart_wireguard: !!doRestartWG }),
data: JSON.stringify(applyPayload),
success: function(data) {
$("#modal_apply_config").modal('hide');
toastr.success(wgT('js.apply_config_success'));
var finalizeApplyUI = function () {
updateApplyConfigVisibility();
if (!reloadAfterApplyForNav && !doRestartWG && !wgSyncconfAfterApplyEnabled) {
if (!reloadAfterApplyForNav && skipKernelRestart && !wgSyncconfAfterApplyEnabled) {
showManualKernelReloadHelp(wgT('js.apply_kernel_hint'));
}
};
@ -873,22 +881,36 @@
});
};
var staged = wgPostPendingGlobalSettingsIfNeeded({});
staged.done(function (postedGlobalFromServer) {
applyWgConfig(!!postedGlobalFromServer, restartWireguard);
}).fail(function (xhr) {
var msg = wgT('js.global_post_fail');
try {
if (xhr.responseJSON && xhr.responseJSON.message) {
msg = xhr.responseJSON.message;
} else if (xhr.responseText) {
var j = JSON.parse(xhr.responseText);
if (j && j.message) msg = j.message;
}
} catch (e6) {}
toastr.error(msg);
confirmBtn.prop("disabled", false);
});
function runStagedThenApply() {
var staged = wgPostPendingGlobalSettingsIfNeeded({});
staged.done(function (postedGlobalFromServer) {
applyWgConfig(!!postedGlobalFromServer);
}).fail(function (xhr) {
var msg = wgT('js.global_post_fail');
try {
if (xhr.responseJSON && xhr.responseJSON.message) {
msg = xhr.responseJSON.message;
} else if (xhr.responseText) {
var j = JSON.parse(xhr.responseText);
if (j && j.message) msg = j.message;
}
} catch (e6) {}
toastr.error(msg);
confirmBtn.prop("disabled", false);
});
}
$.getJSON('{{.basePath}}/api/wireguard/tunnel-status')
.done(function (ts) {
applyPayload = resolveApplyPayloadFromTunnel(ts);
runStagedThenApply();
})
.fail(function () {
applyPayload = (hasPendingGlobalPayload && pendingKindSnap === 'ui_only')
? { restart_wireguard: false }
: {};
runStagedThenApply();
});
});
});

View File

@ -15,6 +15,8 @@
.wg-pane{background:#1e1e1e;border:1px solid rgba(255,255,255,.06);border-radius:16px;padding:14px 16px}
.wg-pane h3{font-size:13px;margin:0 0 12px;display:flex;align-items:center;gap:8px;justify-content:space-between}
.wg-mini-badge{font-size:10px;color:#9e9e9e;background:#252525;padding:3px 8px;border-radius:8px;font-weight:600}
.wg-mini-badge.wg-srv-status--active{color:#66BB6A;background:rgba(102,187,106,.12)}
.wg-mini-badge.wg-srv-status--inactive{color:#EF5350;background:rgba(239,83,80,.14)}
.wg-peer-row{display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid rgba(255,255,255,.06);}
.wg-peer-row:last-child{border-bottom:0}
.wg-peer-meta{display:flex;align-items:flex-start;gap:10px}
@ -111,7 +113,7 @@
</div>
<div>
<div class="wg-pane" style="margin-bottom:14px;">
<h3><span>{{ tr .UILang "dash.srv_title" }}</span><span class="wg-mini-badge" style="{{if .serverActive}}color:#66BB6A;background:rgba(102,187,106,.12);{{end}}">{{if .serverActive}}{{ tr .UILang "dash.srv_active" }}{{else}}{{ tr .UILang "dash.srv_inactive" }}{{end}}</span></h3>
<h3><span>{{ tr .UILang "dash.srv_title" }}</span><span class="wg-mini-badge {{if .serverActive}}wg-srv-status--active{{else}}wg-srv-status--inactive{{end}}">{{if .serverActive}}{{ tr .UILang "dash.srv_active" }}{{else}}{{ tr .UILang "dash.srv_inactive" }}{{end}}</span></h3>
{{ if .serverSummary.Interface }}
<dl class="wg-srv-mini">
<dt>{{ tr .UILang "dash.srv_iface_port" }}</dt>

View File

@ -208,10 +208,10 @@
<div class="success-overlay" id="successOverlay">
<div class="success-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</div>
</div>
<div class="success-text">{{tr .UILang "login.success_title"}}</div>
<div class="success-sub">{{tr .UILang "login.success_sub"}}</div>
</div>
</div>
<div class="card-header">
<a href="https://github.com/ngoduykhanh/wireguard-ui" class="logo-wrap" style="text-decoration:none;color:inherit" title="WireGuard UI">
@ -219,17 +219,17 @@
</a>
<div class="app-name">WireGuard UI</div>
<div class="app-sub">{{tr .UILang "login.subtitle"}}</div>
</div>
</div>
<div class="status-row status-{{if .loginStatusState}}{{.loginStatusState}}{{else}}active{{end}}" id="wg-login-status" role="status" aria-live="polite">
<span class="s-dot" id="wg-login-status-dot" aria-hidden="true"></span>
<span id="wg-login-status-text">{{.loginStatusLine}}</span>
</div>
</div>
<div id="loginAlert" role="alert">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<span id="message"></span>
</div>
</div>
<form class="form" id="loginForm" action="" method="post">
<div class="field">
@ -237,8 +237,8 @@
<div class="input-wrap">
<svg class="input-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
<input class="field-input" id="username" name="username" type="text" placeholder="{{tr .UILang "login.user"}}" autocomplete="username"/>
</div>
</div>
</div>
</div>
<div class="field">
<label class="field-label" for="password">{{tr .UILang "login.pass"}}</label>
@ -250,19 +250,19 @@
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>
</svg>
</button>
</div>
</div>
</div>
</div>
<div class="form-opts">
<label class="checkbox-wrap">
<input type="checkbox" id="remember" checked/>
<div class="custom-check">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</div>
</div>
<span class="check-label">{{tr .UILang "login.remember"}}</span>
</label>
<span class="forgot" title="{{tr .UILang "login.forgot_unavailable_tip"}}">{{tr .UILang "login.forgot"}}</span>
</div>
</div>
<button class="btn-login" id="btn_login" type="submit">
<div class="btn-inner">
@ -297,8 +297,8 @@
</div>
</div>
<script src="{{.basePath}}/static/plugins/jquery/jquery.min.js"></script>
<script src="{{.basePath}}/static/plugins/bootstrap/js/bootstrap.bundle.min.js"></script>
<script src="{{.basePath}}/static/plugins/jquery/jquery.min.js"></script>
<script src="{{.basePath}}/static/plugins/bootstrap/js/bootstrap.bundle.min.js"></script>
<script>window.WG_T={{.WGMsgJSON}};window.wgT=function(k){try{var m=window.WG_T;return(m&&typeof m[k]==='string')?m[k]:k;}catch(e){return k;}};</script>
<script>
@ -341,12 +341,12 @@ function hideLoginAlert() {
document.getElementById('message').innerHTML = '';
}
function redirectNext() {
function redirectNext() {
var urlParams = new URLSearchParams(window.location.search);
var nextURL = urlParams.get('next');
if (nextURL && /(?:^\/[a-zA-Z_])|(?:^\/$)/.test(nextURL.trim())) {
window.location.href = nextURL;
} else {
if (nextURL && /(?:^\/[a-zA-Z_])|(?:^\/$)/.test(nextURL.trim())) {
window.location.href = nextURL;
} else {
var bp = {{printf "%q" .basePath}};
window.location.href = (bp && bp.length) ? bp : '/';
}
@ -377,9 +377,9 @@ function publicKeyCredentialToJSON(cred) {
return cred;
}
$(document).ready(function () {
$('form').on('submit', function(e) {
e.preventDefault();
$(document).ready(function () {
$('form').on('submit', function(e) {
e.preventDefault();
$('#btn_login').trigger('click');
});
@ -389,14 +389,14 @@ $(document).ready(function () {
var rememberMe = $('#remember').is(':checked');
hideLoginAlert();
setLoginLoading(true);
$.ajax({
cache: false,
method: 'POST',
url: '{{.basePath}}/login',
dataType: 'json',
$.ajax({
cache: false,
method: 'POST',
url: '{{.basePath}}/login',
dataType: 'json',
contentType: 'application/json',
data: JSON.stringify({ username: username, password: password, rememberMe: rememberMe }),
success: function(data) {
success: function(data) {
setLoginLoading(false);
showLoginAlert('<p style="margin:0">' + (data.message || 'OK') + '</p>', true);
document.getElementById('successOverlay').classList.add('show');
@ -407,9 +407,9 @@ $(document).ready(function () {
var responseJson = {};
try { responseJson = JSON.parse(jqXHR.responseText || '{}'); } catch (e) {}
showLoginAlert('<p style="margin:0">' + (responseJson.message || 'Error') + '</p>', false);
}
}
});
});
});
{{if .passkeysEnabled}}
$('#btn_passkey').click(async function () {
@ -489,7 +489,7 @@ $(document).ready(function () {
pollLoginWg();
setInterval(pollLoginWg, 8000);
})();
});
});
</script>
</body>
</html>

View File

@ -67,7 +67,7 @@
<div class="wg-prof-field">
<label for="pf_email">{{ tr .UILang "profile.lbl_email" }}</label>
<input type="email" id="pf_email" class="wg-prof-input" placeholder="{{ tr .UILang "profile.ph_email" }}"/>
</div>
</div>
<div class="wg-prof-field">
<label for="pf_password">{{ tr .UILang "profile.lbl_new_password" }}</label>
<div class="wg-pass-wrap">
@ -82,8 +82,8 @@
</div>
<div class="wg-prof-actions">
<button type="submit" class="wg-btn bp">{{ tr .UILang "settings.save_footer" }}</button>
</div>
</form>
</div>
</form>
</div>
<div class="wg-prof-card">

View File

@ -68,17 +68,17 @@ input[type=checkbox].wg-toggle:checked::after{left:22px;background:#EF5350;}
{{- else -}}
{{ tr $.UILang "server.banner_no_iface" }}
{{- end -}}
</div>
</div>
<div class="wg-srv-ban-meta">
{{ tr $.UILang "server.banner_port" }} {{if .UdpListenPortCfg}}{{.UdpListenPortCfg}}{{else}}—{{end}}/UDP
· {{.WgPeersTotal}} {{ tr $.UILang "server.banner_peers" }}
· {{ printf (tr $.UILang "server.banner_hs_fmt") .WgPeersRecent }}
{{- if .HostUptime}}{{ tr $.UILang "server.banner_uptime" }} {{.HostUptime}}{{end}}
</div>
</div>
{{ if ne .WgBackendErr "" }}
<div class="wg-srv-ban-meta" style="color:#ffb4b4;margin-top:6px;">{{.WgBackendErr}}</div>
{{ end }}
</div>
</div>
<div class="wg-srv-ban-actions">
<button type="button" class="wg-btn bg" id="wg_srv_btn_apply_config"
{{if $.baseData.Admin}}title="{{ tr $.UILang "server.tooltip_apply_dump" }}"
@ -91,8 +91,8 @@ input[type=checkbox].wg-toggle:checked::after{left:22px;background:#EF5350;}
<button type="button" class="wg-btn bp" id="wg_srv_wg_quick_up" {{if $.baseData.Admin}}{{else}}disabled{{end}}><i class="fas fa-play-circle"></i> {{ tr $.UILang "server.quick_up" }}</button>
{{end}}
{{end}}
</div>
</div>
</div>
</div>
{{ end }}
<input type="hidden" id="wg_srv_initial_json" value="" />
@ -108,15 +108,15 @@ input[type=checkbox].wg-toggle:checked::after{left:22px;background:#EF5350;}
<div>
<span class="wg-srv-label">{{ tr .UILang "server.lbl_ip_addr" }}</span>
<input type="text" data-role="tagsinput" class="form-control wg-srv-input srv-tags" style="padding:8px;background:#252525;border-color:rgba(255,255,255,.08);color:inherit;" id="srv_addresses" value="" />
</div>
</div>
<div>
<span class="wg-srv-label">{{ tr .UILang "server.lbl_listen_port" }}</span>
<input type="text" class="wg-srv-input" id="srv_listen_port" name="listen_port" inputmode="numeric" value="{{if .serverInterface}}{{.serverInterface.ListenPort}}{{end}}" />
</div>
</div>
<div>
<span class="wg-srv-label">{{ tr .UILang "server.lbl_mtu" }}</span>
<input type="text" class="wg-srv-input" id="srv_mtu" inputmode="numeric" value="{{if .globalSettings.MTU}}{{.globalSettings.MTU}}{{end}}" />
</div>
</div>
<div>
<span class="wg-srv-label">{{ tr .UILang "server.lbl_dns" }}</span>
<input type="text" class="wg-srv-input" id="srv_dns" autocomplete="off" value="{{ .dnsCsv }}" placeholder="1.1.1.1, 8.8.8.8" />
@ -155,10 +155,10 @@ input[type=checkbox].wg-toggle:checked::after{left:22px;background:#EF5350;}
<div>
<span class="wg-srv-label">{{ tr .UILang "server.lbl_post_down" }}</span>
<textarea class="wg-srv-textarea" id="srv_post_down" name="post_down">{{if .serverInterface}}{{.serverInterface.PostDown}}{{end}}</textarea>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="wg-srv-pane">
<h4><i class="fas fa-shield-alt"></i> {{ tr .UILang "server.section_firewall" }}</h4>
@ -183,7 +183,7 @@ input[type=checkbox].wg-toggle:checked::after{left:22px;background:#EF5350;}
<p>{{ printf (tr $.UILang "server.opt_persist_body_fmt") .wgIfaceName }}</p>
</div>
<label style="cursor:pointer;display:inline-flex;"><input type="checkbox" role="switch" class="wg-toggle" id="opt_persist_conf" {{if .globalSettings.PersistWgConfOnSave}}checked{{end}}{{if $.baseData.Admin}}{{else}}disabled{{end}} /></label>
</div>
</div>
<div class="wg-srv-opt">
<div>
<h5>{{ tr $.UILang "server.opt_auto_title" }}</h5>
@ -226,7 +226,7 @@ input[type=checkbox].wg-toggle:checked::after{left:22px;background:#EF5350;}
var wgSrvSnap = null;
var wgSrvKernelListening = {{if and .serverBanner .serverBanner.IsListening}}true{{else}}false{{end}};
var wgSrvIsAdmin = {{if .baseData.Admin}}true{{else}}false{{end}};
var wgSrvPendingApply = false;
var wgSrvPendingApply = {{if .needsWgConfApply}}true{{else}}false{{end}};
function wgSrvGatherPayload() {
const addresses = $("#srv_addresses").val() ? $("#srv_addresses").val().split(",").map(function (s) { return s.trim(); }).filter(Boolean) : [];
@ -260,20 +260,14 @@ input[type=checkbox].wg-toggle:checked::after{left:22px;background:#EF5350;}
function wgSrvRefreshApplyButton() {
var btn = $("#wg_srv_btn_apply_config");
if (!btn.length) return;
var shouldEnable = wgSrvIsAdmin && (wgSrvIsDirty() || wgSrvPendingApply);
btn.prop("disabled", !shouldEnable);
var show = wgSrvIsAdmin && (wgSrvIsDirty() || wgSrvPendingApply);
btn.toggle(show);
if (!show) return;
if (wgSrvIsDirty()) {
btn.html('<i class="fas fa-save"></i> ' + wgT('server.js_btn_save_apply'));
btn.attr("title", wgT('server.js_tooltip_dirty_apply'));
} else {
btn.html('<i class="fas fa-file-export"></i> ' + wgT('top.apply_config'));
}
if (!shouldEnable && wgSrvIsAdmin) {
btn.attr("title", wgT('server.js_tooltip_no_dirty'));
} else if (wgSrvIsDirty()) {
btn.attr("title", wgT('server.js_tooltip_dirty_apply'));
} else if (wgSrvPendingApply) {
btn.attr("title", wgT('server.js_tooltip_apply_file'));
} else if (wgSrvIsAdmin) {
btn.attr("title", wgT('server.js_tooltip_apply_file'));
}
}
@ -306,10 +300,10 @@ input[type=checkbox].wg-toggle:checked::after{left:22px;background:#EF5350;}
dataType: "json",
contentType: "application/json",
data: JSON.stringify(payload),
success: function () {
success: function (data) {
toastr.success(wgT('server.js_ok_updated'));
wgSrvSaveSnap();
wgSrvPendingApply = true;
wgSrvPendingApply = !!(data && data.needs_wg_conf_apply);
wgSrvRefreshApplyButton();
if (typeof updateApplyConfigVisibility === "function") { updateApplyConfigVisibility(); }
if (typeof onSuccess === "function") onSuccess();
@ -321,29 +315,43 @@ input[type=checkbox].wg-toggle:checked::after{left:22px;background:#EF5350;}
});
}
function wgSrvTunnelDownApplyHint() {
return wgSrvKernelListening ? '' : ('\n\n' + wgT('server.js_tunnel_down_apply_note'));
}
function wgSrvApplyConfigNow() {
$.ajax({
cache: false,
method: "POST",
url: "{{.basePath}}/api/apply-wg-config",
dataType: "json",
contentType: "application/json",
data: JSON.stringify({}),
success: function () {
toastr.success(wgT('server.js_ok_applied_conf'));
wgSrvPendingApply = false;
wgSrvRefreshApplyButton();
if (typeof updateApplyConfigVisibility === "function") { updateApplyConfigVisibility(); }
},
error: function (jqXHR) {
try {
var responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson.message || jqXHR.responseText);
} catch (e) {
toastr.error(jqXHR.responseText || wgT('server.js_err_generic'));
}
},
});
function doPost(restartWg) {
var body = (restartWg === undefined) ? {} : { restart_wireguard: !!restartWg };
$.ajax({
cache: false,
method: "POST",
url: "{{.basePath}}/api/apply-wg-config",
dataType: "json",
contentType: "application/json",
data: JSON.stringify(body),
success: function () {
toastr.success(wgT('server.js_ok_applied_conf'));
wgSrvPendingApply = false;
wgSrvRefreshApplyButton();
if (typeof updateApplyConfigVisibility === "function") { updateApplyConfigVisibility(); }
},
error: function (jqXHR) {
try {
var responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson.message || jqXHR.responseText);
} catch (e) {
toastr.error(jqXHR.responseText || wgT('server.js_err_generic'));
}
},
});
}
$.getJSON("{{.basePath}}/api/wireguard/tunnel-status")
.done(function (ts) {
doPost(!!(ts && ts.tunnel_running));
})
.fail(function () {
doPost(undefined);
});
}
function wgSrvValidateMtu() {
@ -368,16 +376,16 @@ input[type=checkbox].wg-toggle:checked::after{left:22px;background:#EF5350;}
$("#wg_srv_btn_apply_config").on("click", function () {
if ($(this).prop("disabled")) return;
if (wgSrvIsDirty()) {
if (!confirm(wgT('server.js_confirm_dirty_then_apply'))) {
if (!confirm(wgT('server.js_confirm_dirty_then_apply') + wgSrvTunnelDownApplyHint())) {
return;
}
wgSrvSaveCurrent(function () {
if (!confirm(wgT('server.js_confirm_saved_apply'))) return;
if (!confirm(wgT('server.js_confirm_saved_apply') + wgSrvTunnelDownApplyHint())) return;
wgSrvApplyConfigNow();
});
return;
}
if (!confirm(wgT('server.js_confirm_apply_saved'))) return;
if (!confirm(wgT('server.js_confirm_apply_saved') + wgSrvTunnelDownApplyHint())) return;
wgSrvApplyConfigNow();
});

View File

@ -67,7 +67,7 @@
<h4 class="modal-title font-weight-bold">{{ tr .UILang "users.modal_new_title" }}</h4>
<button type="button" class="close" data-dismiss="modal">&times;</button>
</div>
<div class="modal-body">
<div class="modal-body">
<div class="form-row">
<div class="form-group col-md-6">
<label>{{ tr .UILang "users.lbl_display_name" }}</label>
@ -77,12 +77,12 @@
<label>{{ tr .UILang "users.lbl_username" }}</label>
<input type="text" class="form-control" id="wu_new_username" placeholder="{{ tr .UILang "users.ph_username_ex" }}"/>
</div>
</div>
<div class="form-group">
</div>
<div class="form-group">
<label>{{ tr .UILang "users.lbl_email" }}</label>
<input type="email" class="form-control" id="wu_new_email" placeholder="{{ tr .UILang "profile.ph_email" }}"/>
</div>
<div class="form-group">
</div>
<div class="form-group">
<label>{{ tr .UILang "users.lbl_initial_pw" }}</label>
<input type="password" class="form-control" id="wu_new_password" autocomplete="new-password" placeholder="{{ tr .UILang "users.ph_min_pw" }}"/>
</div>
@ -435,12 +435,12 @@
toastr.error(wgT('users.err_username_req'));
return;
}
$.ajax({
cache: false,
method: 'POST',
$.ajax({
cache: false,
method: 'POST',
url: BASE + '/update-user',
contentType: 'application/json',
dataType: 'json',
dataType: 'json',
data: JSON.stringify({
previous_username: prev,
username: un,
@ -479,12 +479,12 @@
$('#wu_del_confirm').click(function () {
if (!pendingDel) return;
var un = pendingDel;
$.ajax({
cache: false,
$.ajax({
cache: false,
method: 'POST',
url: BASE + '/remove-user',
contentType: 'application/json',
dataType: 'json',
dataType: 'json',
data: JSON.stringify({ username: un }),
success: function () {
toastr.success(wgT('users.ok_user_deleted'));
@ -533,12 +533,12 @@
var u = usersCache.find(function (x) { return x.username === un; });
if (!u || u.disabled) return;
if (!confirm(wgT('users.confirm_revoke_fmt').replace(/\{username\}/g, String(un)))) return;
$.ajax({
cache: false,
method: 'POST',
$.ajax({
cache: false,
method: 'POST',
url: BASE + '/api/user/revoke-sessions',
contentType: 'application/json',
dataType: 'json',
dataType: 'json',
data: JSON.stringify({ username: un }),
success: function (resp) {
toastr.success((resp && resp.message) || wgT('users.ok_sessions_revoked'));
@ -561,12 +561,12 @@
if (nextDis) {
if (!confirm(wgT('users.confirm_suspend_fmt').replace(/\{username\}/g, String(un)))) return;
}
$.ajax({
cache: false,
method: 'POST',
$.ajax({
cache: false,
method: 'POST',
url: BASE + '/api/user/set-disabled',
contentType: 'application/json',
dataType: 'json',
dataType: 'json',
data: JSON.stringify({ username: un, disabled: nextDis }),
success: function (resp) {
toastr.success((resp && resp.message) || wgT('users.ok_state_updated'));

View File

@ -2,6 +2,8 @@ package util
import (
"net"
"os"
"strconv"
"strings"
"github.com/labstack/gommon/log"
@ -73,6 +75,9 @@ const (
// SyncConfAfterApplyEnvVar: if set, parse as bool — run wg-quick strip | wg syncconf after writing wg.conf (“Apply config”).
// If unset, defaults to matching WGUI_ALLOW_WG_QUICK so peers update in-kernel without full restart when host tools exist.
SyncConfAfterApplyEnvVar = "WGUI_WG_SYNCCONF_AFTER_APPLY"
// WgConfPendingWhenTunnelStoppedEnvVar: default true — when Apply does not restart WireGuard while the netdev is absent/down,
// writes to `<config>.wgui-pending` instead of overwriting `wg.conf` so systemd path units watching wg.conf cannot pull wg-quick up.
WgConfPendingWhenTunnelStoppedEnvVar = "WGUI_WGCONF_PENDING_WHEN_TUNNEL_STOPPED"
)
func ParseBasePath(basePath string) string {
@ -126,3 +131,17 @@ func ParseSubnetRanges(subnetRangesStr string) map[string]([]*net.IPNet) {
}
return subnetRanges
}
// WgConfPendingWhenTunnelStopped mirrors WGUI_WGCONF_PENDING_WHEN_TUNNEL_STOPPED (default true).
func WgConfPendingWhenTunnelStopped() bool {
v, ok := os.LookupEnv(WgConfPendingWhenTunnelStoppedEnvVar)
if !ok {
return true
}
b, err := strconv.ParseBool(strings.TrimSpace(v))
if err != nil {
log.Warnf("[%s]: invalid bool %q, using default true", WgConfPendingWhenTunnelStoppedEnvVar, v)
return true
}
return b
}

View File

@ -581,8 +581,8 @@ func GetSubnetRangesString() string {
return strings.TrimSpace(strB.String())
}
// WriteWireGuardServerConfig to write Wireguard server config. e.g. wg0.conf
func WriteWireGuardServerConfig(tmplDir fs.FS, serverConfig model.Server, clientDataList []model.ClientData, usersList []model.User, globalSettings model.GlobalSetting) error {
// WriteWireGuardServerConfig renders wg.conf template. When outputConfPath is empty, globalSettings.ConfigFilePath is used.
func WriteWireGuardServerConfig(tmplDir fs.FS, serverConfig model.Server, clientDataList []model.ClientData, usersList []model.User, globalSettings model.GlobalSetting, outputConfPath string) error {
var tmplWireguardConf string
// if set, read wg.conf template from WgConfTemplate
@ -616,8 +616,13 @@ func WriteWireGuardServerConfig(tmplDir fs.FS, serverConfig model.Server, client
return err
}
target := strings.TrimSpace(outputConfPath)
if target == "" {
target = strings.TrimSpace(globalSettings.ConfigFilePath)
}
// write config file to disk
f, err := os.Create(globalSettings.ConfigFilePath)
f, err := os.Create(target)
if err != nil {
return err
}

View File

@ -3,6 +3,7 @@ package util
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
@ -39,13 +40,33 @@ func RunWgQuickDown(confPath string) error {
return nil
}
// wgQuickDownBestEffort clears a half-dead or lingering wg netdev before "up" (ignored errors).
// Avoids races where a previous failed wg-quick up left the stack inconsistent and the next
// ip -6 route (or similar) fails with "Cannot find device wg0" even though Bring-up mostly worked.
func wgQuickDownBestEffort(absConf string) {
out, err := exec.Command("wg-quick", "down", absConf).CombinedOutput()
if err != nil && strings.TrimSpace(string(out)) != "" {
log.Debugf("wg-quick down (best-effort): %v: %s", err, strings.TrimSpace(string(out)))
}
time.Sleep(200 * time.Millisecond)
}
// RunWgQuickUp runs `wg-quick up <config>` only when WGUI_ALLOW_WG_QUICK is enabled (security gate).
func RunWgQuickUp(confPath string) error {
p, err := ensureWgQuickAllowed(confPath)
if err != nil {
return err
}
if err := PromotePendingWgConfIfAny(p); err != nil {
return err
}
wgQuickDownBestEffort(p)
out, err := exec.Command("wg-quick", "up", p).CombinedOutput()
if err != nil {
log.Warnf("wg-quick up first attempt failed (%v), retrying after another down+pause", err)
wgQuickDownBestEffort(p)
out, err = exec.Command("wg-quick", "up", p).CombinedOutput()
}
if err != nil {
return fmt.Errorf("wg-quick up: %w: %s", err, strings.TrimSpace(string(out)))
}
@ -69,6 +90,98 @@ func systemdShowLoadState(unit string) (string, error) {
return strings.TrimSpace(string(out)), nil
}
func wgIfaceForConfPath(confPath string) string {
iface := WireGuardIfaceBasename(strings.TrimSpace(confPath))
if strings.TrimSpace(iface) == "" {
return "wg0"
}
return iface
}
// WgConfPendingSuffix names the side-car file Apply uses while the tunnel is stopped (avoid path units on wg.conf).
const WgConfPendingSuffix = ".wgui-pending"
// WgConfPendingPath returns canonical + suffix.
func WgConfPendingPath(canonical string) string {
return strings.TrimSpace(canonical) + WgConfPendingSuffix
}
// RemoveWgConfPending deletes the pending side-car if present (best-effort).
func RemoveWgConfPending(canonical string) {
_ = os.Remove(WgConfPendingPath(canonical))
}
// PromotePendingWgConfIfAny merges <canonical>.wgui-pending over canonical before wg-quick up / systemd restart.
func PromotePendingWgConfIfAny(canonical string) error {
canonical = strings.TrimSpace(canonical)
if canonical == "" || !filepath.IsAbs(canonical) {
return fmt.Errorf("invalid wg config path for promote")
}
pending := WgConfPendingPath(canonical)
if _, err := os.Stat(pending); err != nil {
return nil
}
b, err := os.ReadFile(pending)
if err != nil {
return fmt.Errorf("read pending wg.conf: %w", err)
}
dir := filepath.Dir(canonical)
tmpPath := filepath.Join(dir, fmt.Sprintf(".wgui-wg-merge-%d.tmp", os.Getpid()))
if err := os.WriteFile(tmpPath, b, 0600); err != nil {
return fmt.Errorf("write staged wg.conf: %w", err)
}
if err := os.Rename(tmpPath, canonical); err != nil {
_ = os.Remove(tmpPath)
return fmt.Errorf("promote pending wg.conf: %w", err)
}
_ = os.Remove(pending)
return nil
}
func wgShowIfaceNonEmpty(iface string) bool {
wgBin, err := exec.LookPath("wg")
if err != nil {
return false
}
out, err := exec.Command(wgBin, "show", iface).Output()
if err != nil {
return false
}
return strings.TrimSpace(string(out)) != ""
}
func linuxWgIfaceOperUp(iface string) bool {
iface = strings.TrimSpace(iface)
if iface == "" {
return false
}
meta := filepath.Join("/sys/class/net", iface)
fi, err := os.Stat(meta)
if err != nil || !fi.IsDir() {
return false
}
b, err := os.ReadFile(filepath.Join(meta, "operstate"))
if err != nil {
return false
}
s := strings.TrimSpace(strings.ToLower(string(b)))
// wg netdev is often unknown while running; iface missing after wg-quick down.
return s == "up" || s == "unknown"
}
// WgTunnelIsRunning prefers sysfs netdev state on Linux — avoids systemd and stale wg dumps.
func WgTunnelIsRunning(confPath string) bool {
p := strings.TrimSpace(confPath)
if p == "" {
return false
}
iface := wgIfaceForConfPath(p)
if runtime.GOOS == "linux" {
return linuxWgIfaceOperUp(iface)
}
return wgShowIfaceNonEmpty(iface)
}
// RestartWgQuick runs `wg-quick down` + `wg-quick up` unless a matching systemd unit is active (see WGUI_WG_RESTART_VIA_SYSTEMD),
// in which case `systemctl restart wg-quick@iface` is used so journalctl -u wg-quick@iface captures the event.
func RestartWgQuick(confPath string) error {
@ -76,6 +189,9 @@ func RestartWgQuick(confPath string) error {
if err != nil {
return err
}
if err := PromotePendingWgConfIfAny(p); err != nil {
return err
}
if runtime.GOOS == "linux" && LookupEnvOrBool(RestartWGViaSystemdEnvVar, true) {
if _, lpErr := exec.LookPath("systemctl"); lpErr == nil {