From ee0399b26c128feef265c9db7e89017014fb6280 Mon Sep 17 00:00:00 2001 From: Skyline Date: Fri, 1 May 2026 16:56:06 -0600 Subject: [PATCH] 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 --- README.md | 38 ++++++++++- custom/css/wgshell.css | 35 +++++++--- custom/js/helper.js | 93 +++++++++++++++------------ handler/routes.go | 74 ++++++++++++++++++---- locale/en.json | 4 ++ locale/es.json | 4 ++ main.go | 4 +- templates/base.html | 66 ++++++++++++------- templates/dashboard.html | 4 +- templates/login.html | 58 ++++++++--------- templates/profile.html | 6 +- templates/server.html | 110 +++++++++++++++++--------------- templates/users_settings.html | 40 ++++++------ util/config.go | 19 ++++++ util/util.go | 11 +++- util/wg_quick.go | 116 ++++++++++++++++++++++++++++++++++ 16 files changed, 486 insertions(+), 196 deletions(-) diff --git a/README.md b/README.md index 6e4dd38..6cdf98e 100644 --- a/README.md +++ b/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 \| wg syncconf `** 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` diff --git a/custom/css/wgshell.css b/custom/css/wgshell.css index 56ce0bd..ee7446d 100644 --- a/custom/css/wgshell.css +++ b/custom/css/wgshell.css @@ -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, diff --git a/custom/js/helper.js b/custom/js/helper.js index a5a1faa..d159983 100644 --- a/custom/js/helper.js +++ b/custom/js/helper.js @@ -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) + '">' + '
' + - '
' + - '
' + - '' + - '
' + - '
' + - '
' + - '' + + '
' + + '
' + + '
' + + '' + '
' + - '
' + - ' ' + wgEscapeHtml(c.name) + '' + - '' + - '
' + wgEscapeHtml(ipsJoined) + ' · ' + wgEscapeHtml(wgT('helper.updated')) + ' ' + prettyDateTime(c.updated_at) + '
' + - '' + - '' + - telegramHtml + notesHtml + - '
' + - '' + - '' + wgEscapeHtml(c.email || '—') + '' + - '' + - '' + - '' + wgEscapeHtml(wgT('helper.created_lbl')) + '' + - '' + prettyDateTime(c.created_at) + '' + - '' + - '' + - '' + wgEscapeHtml(wgT('helper.updated_lbl')) + '' + - '' + prettyDateTime(c.updated_at) + '' + - '' + - '' + - '' + dnsChipTxt + '' + - (c.additional_notes ? '' + - '' + wgEscapeHtml(c.additional_notes) + '' : '') + + '
' + + '
' + + '' + + '
' + + '
' + + ' ' + wgEscapeHtml(c.name) + '' + + '' + + '
' + wgEscapeHtml(ipsJoined) + ' · ' + wgEscapeHtml(wgT('helper.updated')) + ' ' + prettyDateTime(c.updated_at) + '
' + + '' + + '' + + telegramHtml + notesHtml + '
' + '
' + '
' + + '
' + + '' + + '' + + '' + wgEscapeHtml(c.enabled ? wgT('helper.badge_online') : wgT('helper.badge_blocked')) + '' + + '' + + '
' + '
' + - '
' + - '' + - '' + - '' + wgEscapeHtml(c.enabled ? wgT('helper.badge_online') : wgT('helper.badge_blocked')) + '' + - '' + + '
' + + '' + + '' + + '' + + '' + wgEscapeHtml(wgT('helper.email_short_lbl')) + '' + + '' + wgEscapeHtml(c.email || '—') + '' + + '' + + '' + + '' + + '' + wgEscapeHtml(wgT('helper.created_lbl')) + '' + + '' + wgEscapeHtml(prettyDateTime(c.created_at)) + '' + + '' + + '' + + '' + + '' + wgEscapeHtml(wgT('helper.updated_lbl')) + '' + + '' + wgEscapeHtml(prettyDateTime(c.updated_at)) + '' + + '' + + '' + + '' + + '' + wgEscapeHtml(wgT('helper.dns_section_lbl')) + '' + + '' + wgEscapeHtml(dnsChipTxt) + '' + + (c.additional_notes ? '' + + '' + + '' + wgEscapeHtml(wgT('helper.notes_inline_lbl')) + '' + + '' + wgEscapeHtml(c.additional_notes) + '' : '') + '
' + '
' + '
' + diff --git a/handler/routes.go b/handler/routes.go index bdf295f..91905aa 100644 --- a/handler/routes.go +++ b/handler/routes.go @@ -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 { diff --git a/locale/en.json b/locale/en.json index 3d971b6..fade28f 100644 --- a/locale/en.json +++ b/locale/en.json @@ -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)", diff --git a/locale/es.json b/locale/es.json index 4ab5a56..74aba14 100644 --- a/locale/es.json +++ b/locale/es.json @@ -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)", diff --git a/main.go b/main.go index c8eedb3..4ecda2f 100644 --- a/main.go +++ b/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) } diff --git a/templates/base.html b/templates/base.html index 1a43177..f352410 100644 --- a/templates/base.html +++ b/templates/base.html @@ -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(); + }); }); }); diff --git a/templates/dashboard.html b/templates/dashboard.html index 53693ff..b40e88e 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -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 @@
-

{{ tr .UILang "dash.srv_title" }}{{if .serverActive}}{{ tr .UILang "dash.srv_active" }}{{else}}{{ tr .UILang "dash.srv_inactive" }}{{end}}

+

{{ tr .UILang "dash.srv_title" }}{{if .serverActive}}{{ tr .UILang "dash.srv_active" }}{{else}}{{ tr .UILang "dash.srv_inactive" }}{{end}}

{{ if .serverSummary.Interface }}
{{ tr .UILang "dash.srv_iface_port" }}
diff --git a/templates/login.html b/templates/login.html index 39c0bff..7023ac4 100644 --- a/templates/login.html +++ b/templates/login.html @@ -208,10 +208,10 @@
-
+
{{tr .UILang "login.success_title"}}
{{tr .UILang "login.success_sub"}}
-
+
@@ -219,17 +219,17 @@
WireGuard UI
{{tr .UILang "login.subtitle"}}
-
+
{{.loginStatusLine}} -
+
+
@@ -237,8 +237,8 @@
-
-
+
+
@@ -250,19 +250,19 @@ -
-
+ +
{{tr .UILang "login.remember"}} {{tr .UILang "login.forgot"}} - + - - + +
diff --git a/templates/server.html b/templates/server.html index f785d00..9b914d8 100644 --- a/templates/server.html +++ b/templates/server.html @@ -68,17 +68,17 @@ input[type=checkbox].wg-toggle:checked::after{left:22px;background:#EF5350;} {{- else -}} {{ tr $.UILang "server.banner_no_iface" }} {{- end -}} -
+
{{ 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}} -
+ {{ if ne .WgBackendErr "" }}
{{.WgBackendErr}}
{{ end }} - +
{{end}} {{end}} -
- + + {{ end }} @@ -108,15 +108,15 @@ input[type=checkbox].wg-toggle:checked::after{left:22px;background:#EF5350;}
{{ tr .UILang "server.lbl_ip_addr" }} -
+
{{ tr .UILang "server.lbl_listen_port" }} -
+
{{ tr .UILang "server.lbl_mtu" }} -
+
{{ tr .UILang "server.lbl_dns" }} @@ -155,10 +155,10 @@ input[type=checkbox].wg-toggle:checked::after{left:22px;background:#EF5350;}
{{ tr .UILang "server.lbl_post_down" }} -
-
- - + + + +

{{ tr .UILang "server.section_firewall" }}

@@ -183,7 +183,7 @@ input[type=checkbox].wg-toggle:checked::after{left:22px;background:#EF5350;}

{{ printf (tr $.UILang "server.opt_persist_body_fmt") .wgIfaceName }}

- +
{{ tr $.UILang "server.opt_auto_title" }}
@@ -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(' ' + wgT('server.js_btn_save_apply')); + btn.attr("title", wgT('server.js_tooltip_dirty_apply')); } else { btn.html(' ' + 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(); }); diff --git a/templates/users_settings.html b/templates/users_settings.html index 9b8029d..35c197c 100644 --- a/templates/users_settings.html +++ b/templates/users_settings.html @@ -67,7 +67,7 @@
-