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:
parent
1b07fe442d
commit
ee0399b26c
38
README.md
38
README.md
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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">' +
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
4
main.go
4
main.go
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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">×</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'));
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
11
util/util.go
11
util/util.go
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
116
util/wg_quick.go
116
util/wg_quick.go
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue