From 536a2dbc5c68ce2dbc71344d5f0f7b4feff7ea51 Mon Sep 17 00:00:00 2001 From: Sajad Hosseinzade Date: Wed, 27 May 2026 02:24:08 +0330 Subject: [PATCH] feat: enhance WireGuard management with backup and restore functionality - Introduced new endpoints for WireGuard backup and restore operations. - Added UI elements for initiating backup and restore processes, including modals for user interaction. - Improved error handling and user notifications for WireGuard reload and configuration changes. - Updated timeout settings for middleware to accommodate longer operations. - Enhanced password visibility toggle for sensitive fields in the UI. --- cmd/netplug/main.go | 3 +- internal/app/handlers.go | 158 ++++- internal/app/routes.go | 3 + internal/view/templates/login.tmpl | 49 +- .../partials/np_form_submit_busy.tmpl | 6 + .../view/templates/partials/setup_keys.tmpl | 30 +- .../templates/partials/wg_backup_modal.tmpl | 51 ++ .../templates/partials/wg_restore_modal.tmpl | 58 ++ internal/view/templates/setup.tmpl | 16 +- internal/view/templates/wireguard.tmpl | 354 ++++++++++- internal/wireguard/apply.go | 154 ++++- internal/wireguard/apply_test.go | 17 + internal/wireguard/backup.go | 588 ++++++++++++++++++ internal/wireguard/backup_crypto.go | 170 +++++ internal/wireguard/backup_crypto_test.go | 121 ++++ internal/wireguard/config.go | 6 +- internal/wireguard/live.go | 2 +- internal/wireguard/state.go | 22 +- 18 files changed, 1733 insertions(+), 75 deletions(-) create mode 100644 internal/view/templates/partials/wg_backup_modal.tmpl create mode 100644 internal/view/templates/partials/wg_restore_modal.tmpl create mode 100644 internal/wireguard/apply_test.go create mode 100644 internal/wireguard/backup.go create mode 100644 internal/wireguard/backup_crypto.go create mode 100644 internal/wireguard/backup_crypto_test.go diff --git a/cmd/netplug/main.go b/cmd/netplug/main.go index 1da9746..9ea7830 100644 --- a/cmd/netplug/main.go +++ b/cmd/netplug/main.go @@ -82,7 +82,8 @@ func main() { r.Use(middleware.RequestID) r.Use(middleware.RealIP) r.Use(middleware.Recoverer) - r.Use(middleware.Timeout(30 * time.Second)) + // WireGuard backup encryption (scrypt) can take longer than typical API calls. + r.Use(middleware.Timeout(90 * time.Second)) r.Use(sessionManager.LoadAndSave) r.Handle("/static/*", http.StripPrefix("/static/", webstatic.Handler())) diff --git a/internal/app/handlers.go b/internal/app/handlers.go index b9da980..6bb1f21 100644 --- a/internal/app/handlers.go +++ b/internal/app/handlers.go @@ -1142,8 +1142,10 @@ func (h *Handlers) UserCreatePost(w http.ResponseWriter, r *http.Request) { return } - _ = wireguard.WriteWireGuardConfig(h.svc.DB, h.svc.Config.DataDir) - _ = wireguard.ApplyConfig(h.svc.Config.DataDir, h.svc.Config.WGInterface) + if err := h.applyWireGuardFromDB(); err != nil { + http.Redirect(w, r, "/ui/users?msgType=warning&msg="+urlQueryEscape("User created but WireGuard reload failed: "+err.Error()), http.StatusFound) + return + } h.reconcilePCQ() http.Redirect(w, r, "/ui/users", http.StatusFound) @@ -1341,13 +1343,18 @@ func (h *Handlers) UserUpdatePost(w http.ResponseWriter, r *http.Request) { return } - _ = wireguard.WriteWireGuardConfig(h.svc.DB, h.svc.Config.DataDir) - _ = wireguard.ApplyConfig(h.svc.Config.DataDir, h.svc.Config.WGInterface) - h.reconcilePCQ() + toastType := "success" + toastMsg := "Changes saved." + if err := h.applyWireGuardFromDB(); err != nil { + toastType = "warning" + toastMsg = "Saved to database but WireGuard reload failed: " + err.Error() + } else { + h.reconcilePCQ() + } // Close modal and show a toast; then refresh list. w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Header().Set("HX-Trigger", `{"toast":{"type":"success","message":"Changes saved."},"usersReload":true}`) + w.Header().Set("HX-Trigger", fmt.Sprintf(`{"toast":{"type":"%s","message":%q},"usersReload":true}`, toastType, toastMsg)) _, _ = w.Write([]byte("")) } @@ -1392,16 +1399,19 @@ func (h *Handlers) UserDeletePost(w http.ResponseWriter, r *http.Request) { return } - _ = wireguard.WriteWireGuardConfig(h.svc.DB, h.svc.Config.DataDir) - _ = wireguard.ApplyConfig(h.svc.Config.DataDir, h.svc.Config.WGInterface) - h.reconcilePCQ() - + toastType := "success" msg := "User deleted." - if strings.TrimSpace(username) != "" { - msg = `User "` + username + `" deleted.` + if err := h.applyWireGuardFromDB(); err != nil { + toastType = "warning" + msg = "User deleted but WireGuard reload failed: " + err.Error() + } else { + h.reconcilePCQ() + if strings.TrimSpace(username) != "" { + msg = `User "` + username + `" deleted.` + } } w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Header().Set("HX-Trigger", `{"toast":{"type":"success","message":`+strconv.Quote(msg)+`},"usersReload":true}`) + w.Header().Set("HX-Trigger", `{"toast":{"type":"`+toastType+`","message":`+strconv.Quote(msg)+`},"usersReload":true}`) _, _ = w.Write([]byte("")) } @@ -1563,8 +1573,10 @@ func (h *Handlers) UserTogglePost(w http.ResponseWriter, r *http.Request) { http.Error(w, "update failed", http.StatusInternalServerError) return } - _ = wireguard.WriteWireGuardConfig(h.svc.DB, h.svc.Config.DataDir) - _ = wireguard.ApplyConfig(h.svc.Config.DataDir, h.svc.Config.WGInterface) + if err := h.applyWireGuardFromDB(); err != nil { + http.Redirect(w, r, "/ui/users?msgType=warning&msg="+urlQueryEscape("User updated but WireGuard reload failed: "+err.Error()), http.StatusFound) + return + } h.reconcilePCQ() http.Redirect(w, r, "/ui/users", http.StatusFound) @@ -1708,6 +1720,122 @@ func (h *Handlers) WireGuardReloadPost(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/ui/wireguard?msgType="+urlQueryEscape(res.Type)+"&msg="+urlQueryEscape(res.Text), http.StatusFound) } +func (h *Handlers) WireGuardRestartPost(w http.ResponseWriter, r *http.Request) { + res, err := wireguard.RestartWireGuard(h.svc.DB, h.svc.Config.DataDir, h.svc.Config.WGInterface) + if err != nil { + http.Redirect(w, r, "/ui/wireguard?msgType=error&msg="+urlQueryEscape(err.Error()), http.StatusFound) + return + } + h.reconcilePCQ() + http.Redirect(w, r, "/ui/wireguard?msgType="+urlQueryEscape(res.Type)+"&msg="+urlQueryEscape(res.Text), http.StatusFound) +} + +func (h *Handlers) wireguardBackupWantsJSON(r *http.Request) bool { + return r.Header.Get("X-Netplug-Backup") == "1" +} + +func (h *Handlers) wireguardBackupError(w http.ResponseWriter, r *http.Request, msg string, code int) { + if h.wireguardBackupWantsJSON(r) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(map[string]string{"error": msg}) + return + } + http.Redirect(w, r, "/ui/wireguard?msgType=error&msg="+urlQueryEscape(msg), http.StatusFound) +} + +func (h *Handlers) WireGuardBackupPost(w http.ResponseWriter, r *http.Request) { + password, err := h.parseWireGuardBackupPassword(r) + if err != nil { + h.wireguardBackupError(w, r, err.Error(), http.StatusBadRequest) + return + } + if password != "" { + if err := wireguard.ValidateBackupPassword(password); err != nil { + h.wireguardBackupError(w, r, err.Error(), http.StatusBadRequest) + return + } + } + data, err := wireguard.ExportBackupArchive(h.svc.DB, password) + if err != nil { + h.wireguardBackupError(w, r, err.Error(), http.StatusInternalServerError) + return + } + encrypted := password != "" + filename := fmt.Sprintf("netplug-backup-%s.npbk", time.Now().UTC().Format("20060102-150405")) + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`) + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Length", strconv.Itoa(len(data))) + if encrypted { + w.Header().Set("X-Netplug-Backup-Encrypted", "1") + } + _, _ = w.Write(data) +} + +func (h *Handlers) parseWireGuardBackupPassword(r *http.Request) (string, error) { + if h.wireguardBackupWantsJSON(r) || strings.Contains(r.Header.Get("Content-Type"), "application/json") { + var payload struct { + Password string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + return "", errors.New("invalid backup request") + } + return strings.TrimSpace(payload.Password), nil + } + + ct := r.Header.Get("Content-Type") + if strings.HasPrefix(ct, "multipart/form-data") { + if err := r.ParseMultipartForm(10 << 20); err != nil { + return "", errors.New("invalid backup request") + } + } else if err := r.ParseForm(); err != nil { + return "", errors.New("invalid backup request") + } + return strings.TrimSpace(r.FormValue("backup_password")), nil +} + +func (h *Handlers) WireGuardRestorePost(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, 32<<20) + if err := r.ParseMultipartForm(32 << 20); err != nil { + http.Redirect(w, r, "/ui/wireguard?msgType=error&msg="+urlQueryEscape("Invalid backup upload."), http.StatusFound) + return + } + f, _, err := r.FormFile("backup_file") + if err != nil { + http.Redirect(w, r, "/ui/wireguard?msgType=error&msg="+urlQueryEscape("Backup file is required."), http.StatusFound) + return + } + defer f.Close() + + raw, err := io.ReadAll(io.LimitReader(f, 32<<20)) + if err != nil { + http.Redirect(w, r, "/ui/wireguard?msgType=error&msg="+urlQueryEscape("Could not read backup file."), http.StatusFound) + return + } + password := strings.TrimSpace(r.FormValue("backup_password")) + file, err := wireguard.ParseBackupArchive(raw, password) + if err != nil { + http.Redirect(w, r, "/ui/wireguard?msgType=error&msg="+urlQueryEscape(err.Error()), http.StatusFound) + return + } + + opts := wireguard.RestoreOptions{ + ReplacePeers: r.FormValue("replace_peers") == "on", + ReplaceGroups: r.FormValue("replace_groups") == "on", + } + if err := wireguard.RestoreBackup(h.svc.DB, h.svc.Config.DataDir, h.svc.Config.WGInterface, file, opts); err != nil { + http.Redirect(w, r, "/ui/wireguard?msgType=error&msg="+urlQueryEscape(err.Error()), http.StatusFound) + return + } + h.reconcilePCQ() + http.Redirect(w, r, "/ui/wireguard?msgType=success&msg="+urlQueryEscape("Backup restored and WireGuard restarted."), http.StatusFound) +} + +func (h *Handlers) applyWireGuardFromDB() error { + return wireguard.SyncFromDB(h.svc.DB, h.svc.Config.DataDir, h.svc.Config.WGInterface) +} + func (h *Handlers) WireGuardAPIGet(w http.ResponseWriter, r *http.Request) { cfg, server, live, hostUp, tunUp, err := wireguard.LoadWireGuardState(h.svc.DB, h.svc.Config.WGInterface, h.svc.StartedAt) if err != nil { diff --git a/internal/app/routes.go b/internal/app/routes.go index 4a149d4..876bc26 100644 --- a/internal/app/routes.go +++ b/internal/app/routes.go @@ -80,6 +80,9 @@ func RegisterRoutes(r chi.Router, svc *Services) { r.Get("/ui/wireguard", h.WireGuardPage) r.Post("/ui/wireguard/save", h.WireGuardSavePost) r.Post("/ui/wireguard/reload", h.WireGuardReloadPost) + r.Post("/ui/wireguard/restart", h.WireGuardRestartPost) + r.Post("/ui/wireguard/backup", h.WireGuardBackupPost) + r.Post("/ui/wireguard/restore", h.WireGuardRestorePost) r.Get("/ui/settings", h.SettingsPage) // HTMX partials diff --git a/internal/view/templates/login.tmpl b/internal/view/templates/login.tmpl index 27354af..00f0440 100644 --- a/internal/view/templates/login.tmpl +++ b/internal/view/templates/login.tmpl @@ -7,6 +7,7 @@ NetPlug UI - {{.Title}} +
@@ -50,14 +51,27 @@
- +
+ + +
+
+ + +
+ class="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2.5 font-mono text-xs text-slate-100" />
{{end}} diff --git a/internal/view/templates/partials/wg_backup_modal.tmpl b/internal/view/templates/partials/wg_backup_modal.tmpl new file mode 100644 index 0000000..8fc7142 --- /dev/null +++ b/internal/view/templates/partials/wg_backup_modal.tmpl @@ -0,0 +1,51 @@ +{{define "partials/wg_backup_modal.tmpl"}} + +{{end}} diff --git a/internal/view/templates/partials/wg_restore_modal.tmpl b/internal/view/templates/partials/wg_restore_modal.tmpl new file mode 100644 index 0000000..4ebe490 --- /dev/null +++ b/internal/view/templates/partials/wg_restore_modal.tmpl @@ -0,0 +1,58 @@ +{{define "partials/wg_restore_modal.tmpl"}} + +{{end}} diff --git a/internal/view/templates/setup.tmpl b/internal/view/templates/setup.tmpl index 26627cd..ec4a349 100644 --- a/internal/view/templates/setup.tmpl +++ b/internal/view/templates/setup.tmpl @@ -16,6 +16,7 @@ +
@@ -329,11 +330,24 @@ if (!inp) return; var showPlain = inp.type === 'password'; inp.type = showPlain ? 'text' : 'password'; - tb.textContent = showPlain ? 'Hide' : 'Show'; tb.setAttribute('aria-label', showPlain ? 'Hide private key' : 'Show private key'); tb.setAttribute('aria-pressed', showPlain ? 'true' : 'false'); + var icon = document.getElementById('setup-privkey-eye'); + if (icon) { + icon.setAttribute('data-lucide', showPlain ? 'eye-off' : 'eye'); + try { lucide.createIcons(); } catch (err) {} + } }); + document.body.addEventListener('htmx:afterSwap', function (ev) { + var t = ev.detail && ev.detail.target; + if (t && t.id === 'setup-keys') { + try { lucide.createIcons(); } catch (err) {} + } + }); + + try { lucide.createIcons(); } catch (err) {} + function bindHookToggle(btnId, panelId) { var btn = document.getElementById(btnId); var panel = document.getElementById(panelId); diff --git a/internal/view/templates/wireguard.tmpl b/internal/view/templates/wireguard.tmpl index 5c507d1..1f54ff0 100644 --- a/internal/view/templates/wireguard.tmpl +++ b/internal/view/templates/wireguard.tmpl @@ -184,31 +184,64 @@ {{template "wg_textarea.tmpl" dict "Label" "PostDown" "ID" "post_down" "Value" .WGConfig.PostDown "Placeholder" "Commands to run after interface is down (optional)" }}
+ +
+
+
+ +

Backup & Restore

+
+
+ + +
+
+

Export or import full server state on this or another NetPlug instance.

+ +
+ +
+ {{template "partials/wg_backup_modal.tmpl" .}} + {{template "partials/wg_restore_modal.tmpl" .}} +
{{end}}
-
-
- - -
- -
+
+ +
+
+ +
+
+

+ Reload applies peer changes (allowed IPs, keys). Restart reapplies all server settings and briefly disconnects clients. +

{{if .SaveMessage}} -
+
{{.SaveMessage}}
{{end}} @@ -251,6 +284,295 @@ setHidden('post_down', 'post_down_h'); return true; } + + (function () { + var ENCRYPTED_FORMAT = 'netplug-encrypted-backup-v1'; + + function isEncryptedBackupText(text) { + try { + var j = JSON.parse(String(text).trim()); + return !!(j && j.format === ENCRYPTED_FORMAT && j.ciphertext); + } catch (e) { + return false; + } + } + + function readBackupPasswordInput(id) { + var el = document.getElementById(id); + return el ? String(el.value || '').trim() : ''; + } + var backupModal = document.getElementById('wg-backup-modal'); + var restoreModal = document.getElementById('wg-restore-modal'); + var backupForm = document.getElementById('wg-backup-form'); + var restoreForm = document.getElementById('wg-restore-form'); + var restoreFileInput = document.getElementById('wg-restore-file-input'); + var pendingRestoreFile = null; + var pendingRestoreEncrypted = false; + + function icons() { + try { lucide.createIcons(); } catch (e) {} + } + + function openModal(el) { + if (!el) return; + el.classList.remove('hidden'); + el.classList.add('flex'); + icons(); + } + + function closeModals() { + [backupModal, restoreModal].forEach(function (el) { + if (!el) return; + el.classList.add('hidden'); + el.classList.remove('flex'); + }); + pendingRestoreFile = null; + pendingRestoreEncrypted = false; + if (restoreFileInput) restoreFileInput.value = ''; + } + + function resetRestorePasswordField() { + var inp = document.getElementById('restore_backup_password'); + var btn = document.getElementById('restore-password-toggle'); + var icon = document.getElementById('restore-password-eye'); + if (inp) inp.type = 'password'; + if (btn) { + btn.setAttribute('aria-label', 'Show password'); + btn.setAttribute('aria-pressed', 'false'); + } + if (icon) icon.setAttribute('data-lucide', 'eye'); + } + + var restorePwToggle = document.getElementById('restore-password-toggle'); + if (restorePwToggle) { + restorePwToggle.addEventListener('click', function () { + var inp = document.getElementById('restore_backup_password'); + var icon = document.getElementById('restore-password-eye'); + if (!inp) return; + var show = inp.type === 'password'; + inp.type = show ? 'text' : 'password'; + restorePwToggle.setAttribute('aria-label', show ? 'Hide password' : 'Show password'); + restorePwToggle.setAttribute('aria-pressed', show ? 'true' : 'false'); + if (icon) { + icon.setAttribute('data-lucide', show ? 'eye-off' : 'eye'); + icons(); + } + }); + } + + document.querySelectorAll('[data-wg-modal-close]').forEach(function (el) { + el.addEventListener('click', closeModals); + }); + + document.addEventListener('keydown', function (e) { + if (e.key === 'Escape') closeModals(); + }); + + var btnBackup = document.getElementById('wg-btn-backup'); + if (btnBackup) { + btnBackup.addEventListener('click', function () { + if (backupForm) backupForm.reset(); + resetBackupPasswordField(); + openModal(backupModal); + }); + } + + var btnRestore = document.getElementById('wg-btn-restore'); + if (btnRestore && restoreFileInput) { + btnRestore.addEventListener('click', function () { + restoreFileInput.click(); + }); + restoreFileInput.addEventListener('change', function () { + var file = restoreFileInput.files && restoreFileInput.files[0]; + if (!file) return; + var reader = new FileReader(); + reader.onload = function () { + var text = String(reader.result || ''); + if (!text.trim()) { + alert('Backup file is empty.'); + restoreFileInput.value = ''; + return; + } + var encrypted = isEncryptedBackupText(text); + if (!encrypted) { + try { + var plain = JSON.parse(text); + if (!plain || plain.version === undefined) { + alert('Invalid backup file.'); + restoreFileInput.value = ''; + return; + } + } catch (e) { + alert('Invalid backup file.'); + restoreFileInput.value = ''; + return; + } + } + pendingRestoreFile = file; + pendingRestoreEncrypted = encrypted; + var nameEl = document.getElementById('wg-restore-filename'); + if (nameEl) nameEl.textContent = file.name; + var pwWrap = document.getElementById('wg-restore-password-wrap'); + var pwInput = document.getElementById('restore_backup_password'); + if (pwWrap) pwWrap.classList.toggle('hidden', !encrypted); + if (pwInput) { + pwInput.value = ''; + pwInput.required = encrypted; + } + resetRestorePasswordField(); + if (restoreForm) { + restoreForm.querySelectorAll('input[type="checkbox"]').forEach(function (cb) { cb.checked = false; }); + } + openModal(restoreModal); + if (encrypted && pwInput) { + setTimeout(function () { pwInput.focus(); }, 50); + } + }; + reader.readAsText(file); + }); + } + + function validateBackupPassword() { + var p = readBackupPasswordInput('backup_password'); + if (!p) return null; + if (p.length < 4) return 'Password must be at least 4 characters.'; + return null; + } + + function resetBackupPasswordField() { + var inp = document.getElementById('backup_password'); + var btn = document.getElementById('backup-password-toggle'); + var icon = document.getElementById('backup-password-eye'); + if (inp) inp.type = 'password'; + if (btn) { + btn.setAttribute('aria-label', 'Show password'); + btn.setAttribute('aria-pressed', 'false'); + } + if (icon) icon.setAttribute('data-lucide', 'eye'); + } + + var backupPwToggle = document.getElementById('backup-password-toggle'); + if (backupPwToggle) { + backupPwToggle.addEventListener('click', function () { + var inp = document.getElementById('backup_password'); + var icon = document.getElementById('backup-password-eye'); + if (!inp) return; + var show = inp.type === 'password'; + inp.type = show ? 'text' : 'password'; + backupPwToggle.setAttribute('aria-label', show ? 'Hide password' : 'Show password'); + backupPwToggle.setAttribute('aria-pressed', show ? 'true' : 'false'); + if (icon) { + icon.setAttribute('data-lucide', show ? 'eye-off' : 'eye'); + icons(); + } + }); + } + + if (backupForm) { + backupForm.addEventListener('submit', function (e) { + e.preventDefault(); + var pwErr = validateBackupPassword(); + if (pwErr) { + alert(pwErr); + return; + } + var btn = backupForm.querySelector('button[type="submit"]'); + var password = readBackupPasswordInput('backup_password'); + if (window.npSetSubmitBusy) window.npSetSubmitBusy(btn, true); + fetch('/ui/wireguard/backup', { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'X-Netplug-Backup': '1', + }, + body: JSON.stringify({ password: password }), + }) + .then(function (res) { + if (!res.ok) { + return res.json().then(function (data) { + throw new Error((data && data.error) ? data.error : 'Backup failed'); + }).catch(function (err) { + if (err && err.message) throw err; + throw new Error('Backup failed'); + }); + } + var cd = res.headers.get('Content-Disposition') || ''; + if (cd.indexOf('attachment') === -1 && cd.indexOf('filename=') === -1) { + throw new Error('Unexpected response from server'); + } + return res.blob().then(function (blob) { + if (!blob || blob.size === 0) throw new Error('Backup file is empty'); + if (password && res.headers.get('X-Netplug-Backup-Encrypted') !== '1') { + throw new Error('Server returned an unencrypted backup. Rebuild the app and try again.'); + } + var m = /filename="([^"]+)"/.exec(cd) || /filename=([^;\s]+)/.exec(cd); + var name = m ? m[1].replace(/"/g, '') : 'netplug-backup.npbk'; + var url = URL.createObjectURL(blob); + var a = document.createElement('a'); + a.href = url; + a.download = name; + a.style.display = 'none'; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(function () { URL.revokeObjectURL(url); }, 1000); + closeModals(); + }); + }) + .catch(function (err) { + alert((err && err.message) ? err.message : 'Backup failed'); + }) + .finally(function () { + if (window.npSetSubmitBusy) window.npSetSubmitBusy(btn, false); + }); + }); + } + + if (restoreForm) { + restoreForm.addEventListener('submit', function (e) { + e.preventDefault(); + if (!pendingRestoreFile) { + alert('No backup file selected.'); + return; + } + if (pendingRestoreEncrypted) { + var pwt = readBackupPasswordInput('restore_backup_password'); + if (pwt.length < 4) { + alert('Enter the backup password (at least 4 characters).'); + return; + } + } + var btn = restoreForm.querySelector('button[type="submit"]'); + if (window.npSetSubmitBusy) window.npSetSubmitBusy(btn, true); + var fd = new FormData(); + fd.append('backup_file', pendingRestoreFile, pendingRestoreFile.name); + if (pendingRestoreEncrypted) { + fd.append('backup_password', readBackupPasswordInput('restore_backup_password')); + } + restoreForm.querySelectorAll('input[type="checkbox"]').forEach(function (cb) { + if (cb.checked) fd.append(cb.name, 'on'); + }); + fetch('/ui/wireguard/restore', { method: 'POST', body: fd, credentials: 'same-origin' }) + .then(function (res) { + if (res.redirected || (res.headers.get('Content-Type') || '').indexOf('text/html') !== -1) { + window.location.href = res.url || '/ui/wireguard'; + return; + } + if (!res.ok) { + return res.text().then(function (t) { throw new Error(t || 'Restore failed'); }); + } + window.location.href = '/ui/wireguard?msgType=success&msg=' + encodeURIComponent('Backup restored and WireGuard restarted.'); + }) + .catch(function (err) { + alert((err && err.message) ? err.message : 'Restore failed'); + }) + .finally(function () { + if (window.npSetSubmitBusy) window.npSetSubmitBusy(btn, false); + }); + }); + } + })(); {{end}} diff --git a/internal/wireguard/apply.go b/internal/wireguard/apply.go index 6b1c9c6..7e0a0a3 100644 --- a/internal/wireguard/apply.go +++ b/internal/wireguard/apply.go @@ -1,6 +1,9 @@ package wireguard import ( + "bytes" + "context" + "database/sql" "errors" "os" "os/exec" @@ -9,70 +12,175 @@ import ( "time" ) +// ApplyConfig reloads the running WireGuard interface without tearing it down. +// Peer changes (allowed IPs, keys, add/remove) are applied via wg syncconf when the +// interface is already up; otherwise wg-quick up is used. func ApplyConfig(dataDir string, configuredInterface string) error { - confPath := filepath.Join(dataDir, "wg0.conf") + return reloadConfig(dataDir, configuredInterface) +} + +// RestartConfig tears the interface down and brings it back up so interface-level +// settings (listen port, address, hooks, MTU) take effect. +func RestartConfig(dataDir string, configuredInterface string) error { + confPath := confPathFor(dataDir) if _, err := os.Stat(confPath); err != nil { return err } + ensureConfigPermissions(confPath) - // If interface exists, prefer syncconf to avoid disconnects. - ifaces, _ := execOut("wg", "show", "interfaces") - actual := pickInterface(ifaces, configuredInterface) - if actual != "" { - return syncconf(actual, confPath) + iface := activeInterface(confPath, configuredInterface) + if iface != "" { + if _, err := execOutTimeout(120*time.Second, "wg-quick", "down", confPath); err != nil { + if !isInterfaceDown(err) { + return err + } + } } - // Else bring up. _, err := execOutTimeout(180*time.Second, "wg-quick", "up", confPath) return err } +// SyncFromDB writes wg0.conf from the database and reloads the running interface. +func SyncFromDB(sqlDB *sql.DB, dataDir string, configuredInterface string) error { + if err := WriteWireGuardConfig(sqlDB, dataDir); err != nil { + return err + } + return ApplyConfig(dataDir, configuredInterface) +} + +func reloadConfig(dataDir string, configuredInterface string) error { + confPath := confPathFor(dataDir) + if _, err := os.Stat(confPath); err != nil { + return err + } + ensureConfigPermissions(confPath) + + iface := activeInterface(confPath, configuredInterface) + if iface == "" { + _, err := execOutTimeout(180*time.Second, "wg-quick", "up", confPath) + return err + } + return syncconf(iface, confPath) +} + +func confPathFor(dataDir string) string { + return filepath.Join(dataDir, "wg0.conf") +} + +func interfaceFromConf(confPath string) string { + base := filepath.Base(confPath) + ext := filepath.Ext(base) + if ext == "" { + return base + } + return strings.TrimSuffix(base, ext) +} + +func activeInterface(confPath string, configuredInterface string) string { + ifaces, _ := execOut("wg", "show", "interfaces") + return pickInterface(ifaces, configuredInterface, interfaceFromConf(confPath)) +} + func syncconf(iface string, confPath string) error { - // `wg syncconf` expects a config without [Interface]; simplest is to call `wg-quick strip`. - out, err := execOutTimeout(30*time.Second, "wg-quick", "strip", confPath) + ensureConfigPermissions(confPath) + out, err := execStdoutTimeout(30*time.Second, "wg-quick", "strip", confPath) if err != nil { return err } + out = cleanWGQuickStripOutput(out) + if strings.TrimSpace(out) == "" { + return errors.New("wg-quick strip produced empty peer configuration") + } tmp := confPath + ".peers.tmp" if err := os.WriteFile(tmp, []byte(out), 0o600); err != nil { return err } defer func() { _ = os.Remove(tmp) }() _, err = execOutTimeout(30*time.Second, "wg", "syncconf", iface, tmp) - if err != nil { - return err + return err +} + +func ensureConfigPermissions(confPath string) { + _ = os.Chmod(confPath, 0o600) +} + +func cleanWGQuickStripOutput(raw string) string { + var b strings.Builder + for _, line := range strings.Split(raw, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + if strings.HasPrefix(strings.ToLower(trimmed), "warning:") { + continue + } + b.WriteString(line) + b.WriteByte('\n') } - return nil + return b.String() } -func pickInterface(raw string, preferred string) string { +func pickInterface(raw string, preferred string, confIface string) string { parts := strings.Fields(strings.TrimSpace(raw)) if len(parts) == 0 { return "" } - for _, p := range parts { - if p == preferred { - return p + for _, want := range []string{preferred, confIface} { + want = strings.TrimSpace(want) + if want == "" { + continue + } + for _, p := range parts { + if p == want { + return p + } } } - // macOS often maps wg0 -> utunX; pick first. return parts[0] } +func isInterfaceDown(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "is not a wireguard interface") || + strings.Contains(msg, "does not exist") || + strings.Contains(msg, "not found") || + strings.Contains(msg, "no such device") +} + func execOut(name string, args ...string) (string, error) { return execOutTimeout(10*time.Second, name, args...) } func execOutTimeout(timeout time.Duration, name string, args ...string) (string, error) { - cmd := exec.Command(name, args...) + return execStdoutTimeout(timeout, name, args...) +} + +func execStdoutTimeout(timeout time.Duration, name string, args ...string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + cmd := exec.CommandContext(ctx, name, args...) cmd.Env = os.Environ() - b, err := cmd.CombinedOutput() + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() if err != nil { - msg := strings.TrimSpace(string(b)) + msg := strings.TrimSpace(stderr.String()) if msg == "" { - msg = err.Error() + msg = strings.TrimSpace(stdout.String()) + } + if msg == "" { + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + msg = "command timed out" + } else { + msg = err.Error() + } } return "", errors.New(msg) } - return string(b), nil + return stdout.String(), nil } - diff --git a/internal/wireguard/apply_test.go b/internal/wireguard/apply_test.go new file mode 100644 index 0000000..995fd29 --- /dev/null +++ b/internal/wireguard/apply_test.go @@ -0,0 +1,17 @@ +package wireguard + +import ( + "strings" + "testing" +) + +func TestCleanWGQuickStripOutput_removesWarnings(t *testing.T) { + raw := "Warning: `/data/wg0.conf' is world accessible\n\n[Peer]\nPublicKey = abc\nAllowedIPs = 10.0.0.2/32\n" + got := cleanWGQuickStripOutput(raw) + if strings.Contains(got, "Warning:") { + t.Fatalf("expected warning stripped, got %q", got) + } + if !strings.Contains(got, "[Peer]") { + t.Fatalf("expected peer config kept, got %q", got) + } +} diff --git a/internal/wireguard/backup.go b/internal/wireguard/backup.go new file mode 100644 index 0000000..914a207 --- /dev/null +++ b/internal/wireguard/backup.go @@ -0,0 +1,588 @@ +package wireguard + +import ( + "bytes" + "database/sql" + "encoding/json" + "errors" + "fmt" + "path/filepath" + "strings" + "time" + + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + + "netplug-go/internal/db" +) + +const BackupVersion = 1 + +type BackupFile struct { + Version int `json:"version"` + ExportedAt string `json:"exportedAt"` + VPNConfiguration json.RawMessage `json:"vpnConfiguration"` + Server BackupServer `json:"server"` + Peers []BackupPeer `json:"peers"` + Groups []BackupGroup `json:"groups,omitempty"` +} + +type BackupServer struct { + Name string `json:"name"` + Protocol string `json:"protocol"` + Host string `json:"host"` + Port int `json:"port"` + PrivateKey string `json:"privateKey,omitempty"` + PublicKey string `json:"publicKey,omitempty"` +} + +type BackupPeer struct { + Username string `json:"username"` + AllowedIPs string `json:"allowedIps"` + PrivateKey string `json:"privateKey,omitempty"` + PublicKey string `json:"publicKey"` + PresharedKey string `json:"presharedKey,omitempty"` + IsEnabled bool `json:"isEnabled"` + RemainingDays *int `json:"remainingDays,omitempty"` + RemainingTrafficBytes *int64 `json:"remainingTrafficBytes,omitempty"` + Endpoint string `json:"endpoint,omitempty"` + LastHandshake string `json:"lastHandshake,omitempty"` + BytesReceived int64 `json:"bytesReceived,omitempty"` + BytesSent int64 `json:"bytesSent,omitempty"` + TotalBytesReceived int64 `json:"totalBytesReceived,omitempty"` + TotalBytesSent int64 `json:"totalBytesSent,omitempty"` + IsConnected bool `json:"isConnected,omitempty"` + ConnectedAt string `json:"connectedAt,omitempty"` +} + +type BackupGroup struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Members []string `json:"members"` + PCQ *BackupGroupPCQ `json:"pcq,omitempty"` +} + +type BackupGroupPCQ struct { + DownloadLimitKbps *int `json:"downloadLimitKbps,omitempty"` + UploadLimitKbps *int `json:"uploadLimitKbps,omitempty"` + BurstDownloadKbps *int `json:"burstDownloadKbps,omitempty"` + BurstUploadKbps *int `json:"burstUploadKbps,omitempty"` + Classifier string `json:"classifier,omitempty"` + IsDisabled bool `json:"isDisabled"` +} + +type RestoreOptions struct { + ReplacePeers bool + ReplaceGroups bool +} + +func ExportBackup(sqlDB *sql.DB) (BackupFile, error) { + if sqlDB == nil { + return BackupFile{}, errors.New("db is nil") + } + + sys, err := db.GetSystemConfig(sqlDB) + if err != nil { + return BackupFile{}, err + } + if len(sys.VPNConfigJSON) == 0 { + return BackupFile{}, errors.New("wireguard is not configured") + } + + var ( + name, protocol, host string + port sql.NullInt64 + priv, pub sql.NullString + ) + err = sqlDB.QueryRow(` + SELECT name, protocol, host, port, private_key, public_key + FROM vpn_servers WHERE id = 'wireguard' LIMIT 1 + `).Scan(&name, &protocol, &host, &port, &priv, &pub) + if err != nil { + return BackupFile{}, fmt.Errorf("wireguard server record: %w", err) + } + + server := BackupServer{ + Name: name, + Protocol: protocol, + Host: host, + } + if port.Valid { + server.Port = int(port.Int64) + } + if priv.Valid { + server.PrivateKey = priv.String + } + if pub.Valid { + server.PublicKey = pub.String + } + + peers, err := exportPeers(sqlDB) + if err != nil { + return BackupFile{}, err + } + groups, err := exportGroups(sqlDB) + if err != nil { + return BackupFile{}, err + } + + return BackupFile{ + Version: BackupVersion, + ExportedAt: time.Now().UTC().Format(time.RFC3339), + VPNConfiguration: append(json.RawMessage(nil), sys.VPNConfigJSON...), + Server: server, + Peers: peers, + Groups: groups, + }, nil +} + +// ExportBackupArchive builds a full backup. When password is non-empty the file is encrypted. +func ExportBackupArchive(sqlDB *sql.DB, password string) ([]byte, error) { + file, err := ExportBackup(sqlDB) + if err != nil { + return nil, err + } + plain, err := json.MarshalIndent(file, "", " ") + if err != nil { + return nil, err + } + password = strings.TrimSpace(password) + if password == "" { + return plain, nil + } + if err := ValidateBackupPassword(password); err != nil { + return nil, err + } + return EncryptBackupPayload(plain, password) +} + +// ParseBackupArchive reads an encrypted or plain full backup file. +func ParseBackupArchive(data []byte, password string) (BackupFile, error) { + data = bytes.TrimSpace(data) + if len(data) == 0 { + return BackupFile{}, errors.New("backup file is empty") + } + + var plain []byte + if IsEncryptedBackupEnvelope(data) { + var err error + plain, err = DecryptBackupPayload(data, password) + if err != nil { + return BackupFile{}, err + } + } else { + plain = data + } + + var file BackupFile + if err := json.Unmarshal(plain, &file); err != nil { + return BackupFile{}, errors.New("invalid backup file") + } + if file.Version != BackupVersion { + return BackupFile{}, fmt.Errorf("unsupported backup version %d", file.Version) + } + return file, nil +} + +func exportPeers(sqlDB *sql.DB) ([]BackupPeer, error) { + rows, err := sqlDB.Query(` + SELECT username, allowed_ips, private_key, public_key, preshared_key, + is_enabled, remaining_days, remaining_traffic_bytes, + endpoint, last_handshake, bytes_received, bytes_sent, + total_bytes_received, total_bytes_sent, is_connected, connected_at + FROM vpn_users WHERE server_id = 'wireguard' + ORDER BY username ASC + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var peers []BackupPeer + for rows.Next() { + var p BackupPeer + var allowed, priv, pub, psk sql.NullString + var remDays sql.NullInt64 + var remTraffic sql.NullInt64 + var isEnabled int + + var endpoint, lastHS, connectedAt sql.NullString + var isConnected int + if err := rows.Scan( + &p.Username, &allowed, &priv, &pub, &psk, + &isEnabled, &remDays, &remTraffic, + &endpoint, &lastHS, &p.BytesReceived, &p.BytesSent, + &p.TotalBytesReceived, &p.TotalBytesSent, &isConnected, &connectedAt, + ); err != nil { + return nil, err + } + if endpoint.Valid { + p.Endpoint = endpoint.String + } + if lastHS.Valid { + p.LastHandshake = lastHS.String + } + if connectedAt.Valid { + p.ConnectedAt = connectedAt.String + } + p.IsConnected = isConnected != 0 + + if allowed.Valid { + p.AllowedIPs = allowed.String + } + if priv.Valid { + p.PrivateKey = priv.String + } + if pub.Valid { + p.PublicKey = pub.String + } + if psk.Valid { + p.PresharedKey = psk.String + } + p.IsEnabled = isEnabled != 0 + if remDays.Valid { + n := int(remDays.Int64) + p.RemainingDays = &n + } + if remTraffic.Valid { + n := remTraffic.Int64 + p.RemainingTrafficBytes = &n + } + peers = append(peers, p) + } + return peers, rows.Err() +} + +func exportGroups(sqlDB *sql.DB) ([]BackupGroup, error) { + rows, err := sqlDB.Query(` + SELECT g.id, g.name, COALESCE(g.description, ''), u.username + FROM vpn_groups g + LEFT JOIN vpn_group_members m ON m.group_id = g.id + LEFT JOIN vpn_users u ON u.id = m.vpn_user_id + ORDER BY g.name ASC, u.username ASC + `) + if err != nil { + return nil, err + } + defer rows.Close() + + byName := map[string]*BackupGroup{} + var order []string + for rows.Next() { + var gid, name, desc, username string + if err := rows.Scan(&gid, &name, &desc, &username); err != nil { + return nil, err + } + g, ok := byName[name] + if !ok { + g = &BackupGroup{Name: name, Description: desc} + byName[name] = g + order = append(order, name) + } + if strings.TrimSpace(username) != "" { + g.Members = append(g.Members, username) + } + } + if err := rows.Err(); err != nil { + return nil, err + } + + for _, name := range order { + g := byName[name] + pcq, err := loadGroupPCQ(sqlDB, name) + if err != nil { + return nil, err + } + g.PCQ = pcq + } + + out := make([]BackupGroup, 0, len(order)) + for _, name := range order { + out = append(out, *byName[name]) + } + return out, nil +} + +func loadGroupPCQ(sqlDB *sql.DB, groupName string) (*BackupGroupPCQ, error) { + var ( + dl, ul, bdl, bul sql.NullInt64 + classifier string + isDisabled int + ) + err := sqlDB.QueryRow(` + SELECT p.download_limit_kbps, p.upload_limit_kbps, + p.burst_download_kbps, p.burst_upload_kbps, + p.pcq_classifier, p.is_disabled + FROM vpn_group_pcq p + JOIN vpn_groups g ON g.id = p.group_id + WHERE g.name = ? + LIMIT 1 + `, groupName).Scan(&dl, &ul, &bdl, &bul, &classifier, &isDisabled) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + pcq := &BackupGroupPCQ{Classifier: classifier, IsDisabled: isDisabled != 0} + if dl.Valid { + n := int(dl.Int64) + pcq.DownloadLimitKbps = &n + } + if ul.Valid { + n := int(ul.Int64) + pcq.UploadLimitKbps = &n + } + if bdl.Valid { + n := int(bdl.Int64) + pcq.BurstDownloadKbps = &n + } + if bul.Valid { + n := int(bul.Int64) + pcq.BurstUploadKbps = &n + } + return pcq, nil +} + +func RestoreBackup(sqlDB *sql.DB, dataDir string, configuredInterface string, file BackupFile, opts RestoreOptions) error { + if sqlDB == nil { + return errors.New("db is nil") + } + if file.Version != BackupVersion { + return fmt.Errorf("unsupported backup version %d", file.Version) + } + if len(file.VPNConfiguration) == 0 { + return errors.New("backup is missing vpn configuration") + } + if strings.TrimSpace(file.Server.Host) == "" { + return errors.New("backup is missing server host") + } + + var wrap struct { + WireGuard WireGuardConfig `json:"wireGuard"` + } + if err := json.Unmarshal(file.VPNConfiguration, &wrap); err != nil { + return fmt.Errorf("invalid vpn configuration: %w", err) + } + if err := db.UpsertSystemConfig(sqlDB, true, map[string]any{"wireGuard": wrap.WireGuard}); err != nil { + return err + } + + if err := restoreServer(sqlDB, dataDir, wrap.WireGuard, file.Server); err != nil { + return err + } + + tx, err := sqlDB.Begin() + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + if opts.ReplacePeers { + if _, err := tx.Exec(`DELETE FROM vpn_users WHERE server_id = 'wireguard'`); err != nil { + return err + } + } + if opts.ReplaceGroups { + if _, err := tx.Exec(`DELETE FROM vpn_group_members`); err != nil { + return err + } + if _, err := tx.Exec(`DELETE FROM vpn_group_pcq`); err != nil { + return err + } + if _, err := tx.Exec(`DELETE FROM vpn_groups`); err != nil { + return err + } + } + + for _, p := range file.Peers { + if err := restorePeer(tx, p); err != nil { + return err + } + } + + if err := tx.Commit(); err != nil { + return err + } + + if len(file.Groups) > 0 { + if err := restoreGroups(sqlDB, file.Groups); err != nil { + return err + } + } + + if err := WriteWireGuardConfig(sqlDB, dataDir); err != nil { + return err + } + return RestartConfig(dataDir, configuredInterface) +} + +func restoreServer(sqlDB *sql.DB, dataDir string, cfg WireGuardConfig, server BackupServer) error { + host := strings.TrimSpace(cfg.ServerHost) + if host == "" { + host = strings.TrimSpace(server.Host) + } + port := cfg.ServerPort + if port == 0 { + port = server.Port + } + priv := strings.TrimSpace(server.PrivateKey) + if priv != "" { + k, err := wgtypes.ParseKey(priv) + if err != nil { + return errors.New("invalid server private key in backup") + } + pub := k.PublicKey().String() + _, err = sqlDB.Exec(` + UPDATE vpn_servers + SET host=?, port=?, private_key=?, public_key=?, updated_at=datetime('now') + WHERE id='wireguard' + `, host, port, priv, pub) + return err + } + configPath := filepath.Join(dataDir, "wg0.conf") + _, err := sqlDB.Exec(` + INSERT INTO vpn_servers (id, name, protocol, host, port, config_path, is_active) + VALUES ('wireguard', 'WireGuard Server', 'wireguard', ?, ?, ?, 1) + ON CONFLICT(id) DO UPDATE SET + host = excluded.host, + port = excluded.port, + config_path = excluded.config_path, + is_active = 1, + updated_at = datetime('now') + `, host, port, configPath) + return err +} + +func restorePeer(tx *sql.Tx, p BackupPeer) error { + pub := strings.TrimSpace(p.PublicKey) + allowed := strings.TrimSpace(p.AllowedIPs) + if pub == "" || allowed == "" { + return nil + } + username := strings.TrimSpace(p.Username) + if username == "" { + username = pub + } + isEnabled := 0 + if p.IsEnabled { + isEnabled = 1 + } + + _, err := tx.Exec(` + INSERT INTO vpn_users ( + id, username, allowed_ips, private_key, public_key, preshared_key, + remaining_days, remaining_traffic_bytes, endpoint, last_handshake, + bytes_received, bytes_sent, total_bytes_received, total_bytes_sent, + is_connected, connected_at, server_id, is_enabled + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'wireguard', ?) + ON CONFLICT(username) DO UPDATE SET + allowed_ips = excluded.allowed_ips, + private_key = COALESCE(excluded.private_key, vpn_users.private_key), + public_key = excluded.public_key, + preshared_key = excluded.preshared_key, + remaining_days = excluded.remaining_days, + remaining_traffic_bytes = excluded.remaining_traffic_bytes, + endpoint = excluded.endpoint, + last_handshake = excluded.last_handshake, + bytes_received = excluded.bytes_received, + bytes_sent = excluded.bytes_sent, + total_bytes_received = excluded.total_bytes_received, + total_bytes_sent = excluded.total_bytes_sent, + is_connected = excluded.is_connected, + connected_at = excluded.connected_at, + server_id = 'wireguard', + is_enabled = excluded.is_enabled, + updated_at = datetime('now') + `, NewID(), username, allowed, nullStr(p.PrivateKey), pub, nullStr(p.PresharedKey), + p.RemainingDays, p.RemainingTrafficBytes, nullStr(p.Endpoint), nullStr(p.LastHandshake), + p.BytesReceived, p.BytesSent, p.TotalBytesReceived, p.TotalBytesSent, + boolInt(p.IsConnected), nullStr(p.ConnectedAt), isEnabled) + return err +} + +func restoreGroups(sqlDB *sql.DB, groups []BackupGroup) error { + for _, g := range groups { + name := strings.TrimSpace(g.Name) + if name == "" { + continue + } + groupID := NewID() + _, err := sqlDB.Exec(` + INSERT INTO vpn_groups (id, name, description) + VALUES (?, ?, ?) + ON CONFLICT(name) DO UPDATE SET + description = excluded.description, + updated_at = datetime('now') + `, groupID, name, strings.TrimSpace(g.Description)) + if err != nil { + return err + } + var resolvedID string + if err := sqlDB.QueryRow(`SELECT id FROM vpn_groups WHERE name = ? LIMIT 1`, name).Scan(&resolvedID); err != nil { + return err + } + + for _, member := range g.Members { + member = strings.TrimSpace(member) + if member == "" { + continue + } + var userID string + if err := sqlDB.QueryRow(`SELECT id FROM vpn_users WHERE username = ? LIMIT 1`, member).Scan(&userID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + continue + } + return err + } + _, err = sqlDB.Exec(` + INSERT INTO vpn_group_members (group_id, vpn_user_id) + VALUES (?, ?) + ON CONFLICT(group_id, vpn_user_id) DO NOTHING + `, resolvedID, userID) + if err != nil { + return err + } + } + + if g.PCQ != nil { + p := g.PCQ + _, err = sqlDB.Exec(` + INSERT INTO vpn_group_pcq ( + group_id, download_limit_kbps, upload_limit_kbps, + burst_download_kbps, burst_upload_kbps, pcq_classifier, is_disabled + ) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(group_id) DO UPDATE SET + download_limit_kbps = excluded.download_limit_kbps, + upload_limit_kbps = excluded.upload_limit_kbps, + burst_download_kbps = excluded.burst_download_kbps, + burst_upload_kbps = excluded.burst_upload_kbps, + pcq_classifier = excluded.pcq_classifier, + is_disabled = excluded.is_disabled, + updated_at = datetime('now') + `, resolvedID, p.DownloadLimitKbps, p.UploadLimitKbps, + p.BurstDownloadKbps, p.BurstUploadKbps, defaultClassifier(p.Classifier), boolInt(p.IsDisabled)) + if err != nil { + return err + } + } + } + return nil +} + +func nullStr(s string) any { + s = strings.TrimSpace(s) + if s == "" { + return nil + } + return s +} + +func defaultClassifier(c string) string { + c = strings.TrimSpace(c) + if c == "" { + return "dual" + } + return c +} diff --git a/internal/wireguard/backup_crypto.go b/internal/wireguard/backup_crypto.go new file mode 100644 index 0000000..8136f89 --- /dev/null +++ b/internal/wireguard/backup_crypto.go @@ -0,0 +1,170 @@ +package wireguard + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "strings" + + "golang.org/x/crypto/scrypt" +) + +const ( + encryptedBackupFormat = "netplug-encrypted-backup-v1" + backupKDF = "scrypt" + backupCipher = "aes-256-gcm" + + backupScryptN = 16384 + backupScryptR = 8 + backupScryptP = 1 + + backupSaltLen = 32 + backupNonceLen = 12 + backupKeyLen = 32 + + backupPasswordMinLen = 4 + backupPasswordMaxLen = 256 +) + +// EncryptedBackupEnvelope is the on-disk format. Only ciphertext and KDF metadata +// are stored; secrets never appear in plaintext in the file. +type EncryptedBackupEnvelope struct { + Format string `json:"format"` + KDF string `json:"kdf"` + Cipher string `json:"cipher"` + ScryptN int `json:"scryptN"` + ScryptR int `json:"scryptR"` + ScryptP int `json:"scryptP"` + Salt string `json:"salt"` + Nonce string `json:"nonce"` + Ciphertext string `json:"ciphertext"` +} + +func ValidateBackupPassword(password string) error { + password = strings.TrimSpace(password) + if password == "" { + return nil + } + if len(password) < backupPasswordMinLen { + return fmt.Errorf("backup password must be at least %d characters", backupPasswordMinLen) + } + if len(password) > backupPasswordMaxLen { + return fmt.Errorf("backup password must be at most %d characters", backupPasswordMaxLen) + } + return nil +} + +func IsEncryptedBackupEnvelope(data []byte) bool { + var env struct { + Format string `json:"format"` + Ciphertext string `json:"ciphertext"` + } + if err := json.Unmarshal(data, &env); err != nil { + return false + } + return env.Format == encryptedBackupFormat && strings.TrimSpace(env.Ciphertext) != "" +} + +func EncryptBackupPayload(plaintext []byte, password string) ([]byte, error) { + password = strings.TrimSpace(password) + if password == "" { + return nil, errors.New("backup password is required for encryption") + } + if err := ValidateBackupPassword(password); err != nil { + return nil, err + } + salt := make([]byte, backupSaltLen) + if _, err := io.ReadFull(rand.Reader, salt); err != nil { + return nil, err + } + key, err := scrypt.Key([]byte(password), salt, backupScryptN, backupScryptR, backupScryptP, backupKeyLen) + if err != nil { + return nil, err + } + + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + nonce := make([]byte, backupNonceLen) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, err + } + ciphertext := gcm.Seal(nil, nonce, plaintext, nil) + + env := EncryptedBackupEnvelope{ + Format: encryptedBackupFormat, + KDF: backupKDF, + Cipher: backupCipher, + ScryptN: backupScryptN, + ScryptR: backupScryptR, + ScryptP: backupScryptP, + Salt: base64.StdEncoding.EncodeToString(salt), + Nonce: base64.StdEncoding.EncodeToString(nonce), + Ciphertext: base64.StdEncoding.EncodeToString(ciphertext), + } + return json.MarshalIndent(env, "", " ") +} + +func DecryptBackupPayload(data []byte, password string) ([]byte, error) { + password = strings.TrimSpace(password) + if password == "" { + return nil, errors.New("backup password is required for encrypted backups") + } + if err := ValidateBackupPassword(password); err != nil { + return nil, err + } + var env EncryptedBackupEnvelope + if err := json.Unmarshal(data, &env); err != nil { + return nil, errors.New("invalid encrypted backup file") + } + if env.Format != encryptedBackupFormat { + return nil, errors.New("not an encrypted backup file") + } + if env.KDF != backupKDF || env.Cipher != backupCipher { + return nil, errors.New("unsupported backup encryption parameters") + } + if env.ScryptN < 8192 || env.ScryptR < 1 || env.ScryptP < 1 { + return nil, errors.New("backup was created with incompatible encryption settings") + } + + salt, err := base64.StdEncoding.DecodeString(env.Salt) + if err != nil || len(salt) == 0 { + return nil, errors.New("invalid backup salt") + } + nonce, err := base64.StdEncoding.DecodeString(env.Nonce) + if err != nil || len(nonce) != backupNonceLen { + return nil, errors.New("invalid backup nonce") + } + ciphertext, err := base64.StdEncoding.DecodeString(env.Ciphertext) + if err != nil || len(ciphertext) == 0 { + return nil, errors.New("invalid backup ciphertext") + } + + key, err := scrypt.Key([]byte(password), salt, env.ScryptN, env.ScryptR, env.ScryptP, backupKeyLen) + if err != nil { + return nil, err + } + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return nil, errors.New("incorrect backup password or corrupted backup file") + } + return plaintext, nil +} diff --git a/internal/wireguard/backup_crypto_test.go b/internal/wireguard/backup_crypto_test.go new file mode 100644 index 0000000..e80b446 --- /dev/null +++ b/internal/wireguard/backup_crypto_test.go @@ -0,0 +1,121 @@ +package wireguard + +import ( + "crypto/aes" + "crypto/cipher" + "encoding/base64" + "encoding/json" + "strings" + "testing" + + "golang.org/x/crypto/scrypt" +) + +func TestIsEncryptedBackupEnvelope(t *testing.T) { + enc, err := EncryptBackupPayload([]byte(`{"version":1}`), "secret") + if err != nil { + t.Fatal(err) + } + if !IsEncryptedBackupEnvelope(enc) { + t.Fatal("expected encrypted envelope") + } + if IsEncryptedBackupEnvelope([]byte(`{"version":1,"peers":[]}`)) { + t.Fatal("plain backup must not match encrypted envelope") + } +} + +func TestEncryptDecryptBackupRoundTrip(t *testing.T) { + plain := []byte(`{"version":1,"peers":[]}`) + password := "test" + enc, err := EncryptBackupPayload(plain, password) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(enc), "correct-horse") { + t.Fatal("plaintext password or payload must not appear in encrypted file") + } + got, err := DecryptBackupPayload(enc, password) + if err != nil { + t.Fatal(err) + } + if string(got) != string(plain) { + t.Fatalf("round trip mismatch: %q", got) + } +} + +func TestDecryptBackupWrongPassword(t *testing.T) { + enc, err := EncryptBackupPayload([]byte("secret"), "long-password-1") + if err != nil { + t.Fatal(err) + } + _, err = DecryptBackupPayload(enc, "long-password-2") + if err == nil || !strings.Contains(err.Error(), "incorrect backup password") { + t.Fatalf("expected wrong password error, got %v", err) + } +} + +func TestValidateBackupPassword(t *testing.T) { + if err := ValidateBackupPassword(""); err != nil { + t.Fatal("empty password should be allowed") + } + if err := ValidateBackupPassword("abc"); err == nil { + t.Fatal("expected error for password under 4 characters") + } + if err := ValidateBackupPassword("abcd"); err != nil { + t.Fatalf("4 character password should be allowed: %v", err) + } +} + +func TestDecryptBackupLegacyScryptN(t *testing.T) { + plain := []byte(`{"version":1}`) + salt := make([]byte, backupSaltLen) + nonce := make([]byte, backupNonceLen) + for i := range salt { + salt[i] = byte(i) + } + for i := range nonce { + nonce[i] = byte(i + 1) + } + legacyN := 32768 + key, err := scrypt.Key([]byte("legacy-pass"), salt, legacyN, backupScryptR, backupScryptP, backupKeyLen) + if err != nil { + t.Fatal(err) + } + block, err := aes.NewCipher(key) + if err != nil { + t.Fatal(err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + t.Fatal(err) + } + ct := gcm.Seal(nil, nonce, plain, nil) + env := EncryptedBackupEnvelope{ + Format: encryptedBackupFormat, KDF: backupKDF, Cipher: backupCipher, + ScryptN: legacyN, ScryptR: backupScryptR, ScryptP: backupScryptP, + Salt: base64.StdEncoding.EncodeToString(salt), + Nonce: base64.StdEncoding.EncodeToString(nonce), + Ciphertext: base64.StdEncoding.EncodeToString(ct), + } + raw, _ := json.Marshal(env) + got, err := DecryptBackupPayload(raw, "legacy-pass") + if err != nil { + t.Fatal(err) + } + if string(got) != string(plain) { + t.Fatalf("legacy decrypt mismatch: %q", got) + } +} + +func TestEncryptedEnvelopeHasNoPlaintextKeys(t *testing.T) { + plain, _ := json.Marshal(BackupFile{ + Server: BackupServer{PrivateKey: "SUPER_SECRET_KEY"}, + }) + enc, err := EncryptBackupPayload(plain, "backup-pass-phrase") + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(enc), "SUPER_SECRET") { + t.Fatal("private key leaked into encrypted envelope") + } +} diff --git a/internal/wireguard/config.go b/internal/wireguard/config.go index ec7ff57..3bb7e77 100644 --- a/internal/wireguard/config.go +++ b/internal/wireguard/config.go @@ -153,7 +153,11 @@ func WriteWireGuardConfig(sqlDB *sql.DB, dataDir string) error { if err := os.WriteFile(tmp, buf.Bytes(), 0o600); err != nil { return err } - return os.Rename(tmp, path) + if err := os.Rename(tmp, path); err != nil { + return err + } + ensureConfigPermissions(path) + return nil } func writeHook(buf *bytes.Buffer, key string, value string) { diff --git a/internal/wireguard/live.go b/internal/wireguard/live.go index 11e12a8..4f766de 100644 --- a/internal/wireguard/live.go +++ b/internal/wireguard/live.go @@ -15,7 +15,7 @@ func GetLiveStatus(configuredInterface string) WireGuardLiveStatus { if err != nil || strings.TrimSpace(raw) == "" { return WireGuardLiveStatus{Up: false} } - actual := pickInterface(raw, configuredInterface) + actual := pickInterface(raw, configuredInterface, configuredInterface) if actual == "" { return WireGuardLiveStatus{Up: false} } diff --git a/internal/wireguard/state.go b/internal/wireguard/state.go index b6b03e1..08b0c3b 100644 --- a/internal/wireguard/state.go +++ b/internal/wireguard/state.go @@ -136,7 +136,27 @@ func ReloadWireGuard(sqlDB *sql.DB, dataDir string, configuredInterface string) if err := ApplyConfig(dataDir, configuredInterface); err != nil { return SaveResult{}, err } - return SaveResult{Type: "success", Text: "WireGuard configuration reloaded.", WroteConfig: true, Applied: true}, nil + return SaveResult{ + Type: "success", + Text: "WireGuard reloaded. Peer and key changes are now active.", + WroteConfig: true, + Applied: true, + }, nil +} + +func RestartWireGuard(sqlDB *sql.DB, dataDir string, configuredInterface string) (SaveResult, error) { + if err := WriteWireGuardConfig(sqlDB, dataDir); err != nil { + return SaveResult{}, err + } + if err := RestartConfig(dataDir, configuredInterface); err != nil { + return SaveResult{}, err + } + return SaveResult{ + Type: "success", + Text: "WireGuard restarted. All settings are now active.", + WroteConfig: true, + Applied: true, + }, nil } func loadServer(sqlDB *sql.DB) (WireGuardServer, error) {