Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion cmd/netplug/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()))
Expand Down
158 changes: 143 additions & 15 deletions internal/app/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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(""))
}

Expand Down Expand Up @@ -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(""))
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions internal/app/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 41 additions & 8 deletions internal/view/templates/login.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
<title>NetPlug UI - {{.Title}}</title>
<link rel="icon" type="image/png" href="/plug-icon.png" />
<link rel="stylesheet" href="/assets/app.css" />
<script src="/assets/lucide.min.js"></script>
</head>
<body class="min-h-screen flex items-center justify-center px-4 text-white antialiased bg-[radial-gradient(ellipse_at_center,_#141f2e_0%,_#0c111c_55%,_#0a0d12_100%)]">
<div class="w-full max-w-[400px]">
Expand Down Expand Up @@ -50,14 +51,27 @@
</div>
<div>
<label for="login-password" class="mb-1.5 block text-sm text-[#718096]">Password</label>
<input
id="login-password"
name="password"
type="password"
placeholder="Password"
autocomplete="current-password"
class="w-full rounded-lg border border-slate-700/35 bg-slate-950/60 px-3.5 py-2.5 text-slate-100 placeholder:text-slate-500 outline-none transition focus:border-[#10b981] focus:ring-2 focus:ring-[#10b981]/30"
/>
<div class="relative">
<input
id="login-password"
name="password"
type="password"
placeholder="Password"
autocomplete="current-password"
spellcheck="false"
class="w-full rounded-lg border border-slate-700/35 bg-slate-950/60 px-3.5 py-2.5 pr-11 text-slate-100 placeholder:text-slate-500 outline-none transition focus:border-[#10b981] focus:ring-2 focus:ring-[#10b981]/35"
/>
<button
type="button"
id="login-password-toggle"
class="absolute right-1 top-1/2 -translate-y-1/2 rounded-md p-1.5 text-slate-400 transition-colors hover:bg-slate-800 hover:text-slate-200"
aria-label="Show password"
aria-pressed="false"
tabindex="-1"
>
<i id="login-password-eye" data-lucide="eye" class="h-4 w-4" style="stroke-width:1.5"></i>
</button>
</div>
</div>
<button
type="submit"
Expand All @@ -69,6 +83,25 @@
</div>
</div>
{{template "partials/np_form_submit_busy.tmpl" .}}
<script>
(function () {
var toggle = document.getElementById('login-password-toggle');
var input = document.getElementById('login-password');
var icon = document.getElementById('login-password-eye');
if (!toggle || !input) return;
toggle.addEventListener('click', function () {
var show = input.type === 'password';
input.type = show ? 'text' : 'password';
toggle.setAttribute('aria-label', show ? 'Hide password' : 'Show password');
toggle.setAttribute('aria-pressed', show ? 'true' : 'false');
if (icon) {
icon.setAttribute('data-lucide', show ? 'eye-off' : 'eye');
try { lucide.createIcons(); } catch (e) {}
}
});
try { lucide.createIcons(); } catch (e) {}
})();
</script>
</body>
</html>
{{end}}
6 changes: 6 additions & 0 deletions internal/view/templates/partials/np_form_submit_busy.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,12 @@
});
});

window.addEventListener("pageshow", function () {
document.querySelectorAll("button.np-btn-busy, input.np-btn-busy").forEach(function (b) {
setBusy(b, false);
});
});

window.npSetSubmitBusy = setBusy;
})();
</script>
Expand Down
30 changes: 22 additions & 8 deletions internal/view/templates/partials/setup_keys.tmpl
Original file line number Diff line number Diff line change
@@ -1,18 +1,32 @@
{{define "partials/setup_keys.tmpl"}}
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div class="relative sm:col-span-2">
<div class="sm:col-span-2">
<label class="block text-xs text-slate-400 mb-1" for="setup_private_key">Server private key</label>
<input id="setup_private_key" name="private_key" type="password" autocomplete="off"
value="{{.PrivateKey}}"
class="w-full bg-slate-950 border border-slate-800 rounded px-3 py-2 pr-24 font-mono text-xs" />
<button type="button" id="setup-privkey-toggle"
class="absolute right-2 top-[1.85rem] rounded border border-slate-700 bg-slate-900 px-2.5 py-1 text-[11px] font-medium text-slate-200 transition-colors hover:bg-slate-800"
aria-label="Show private key" aria-pressed="false">Show</button>
<div class="relative">
<input
id="setup_private_key"
name="private_key"
type="password"
autocomplete="off"
spellcheck="false"
value="{{.PrivateKey}}"
class="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2.5 pr-11 font-mono text-xs text-slate-100 focus:outline-none focus:ring-2 focus:ring-emerald-500/30 focus:border-emerald-500"
/>
<button
type="button"
id="setup-privkey-toggle"
class="absolute right-1 top-1/2 -translate-y-1/2 rounded-md p-1.5 text-slate-400 transition-colors hover:bg-slate-800 hover:text-slate-200"
aria-label="Show private key"
aria-pressed="false"
>
<i id="setup-privkey-eye" data-lucide="eye" class="h-4 w-4" style="stroke-width:1.5"></i>
</button>
</div>
</div>
<div>
<label class="block text-xs text-slate-400 mb-1">Server public key</label>
<input name="public_key" value="{{.PublicKey}}" readonly
class="w-full bg-slate-950 border border-slate-800 rounded px-3 py-2 font-mono text-xs" />
class="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2.5 font-mono text-xs text-slate-100" />
</div>
</div>
{{end}}
51 changes: 51 additions & 0 deletions internal/view/templates/partials/wg_backup_modal.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
{{define "partials/wg_backup_modal.tmpl"}}
<div id="wg-backup-modal" class="fixed inset-0 z-50 hidden items-center justify-center" role="dialog" aria-modal="true" aria-labelledby="wg-backup-modal-title">
<div class="absolute inset-0 bg-black/50 backdrop-blur-sm" data-wg-modal-close></div>
<div class="relative w-full max-w-md rounded-lg border border-gray-200 bg-white shadow-xl dark:border-[rgba(148,163,184,0.085)] dark:bg-[var(--np-panel)]">
<div class="flex items-start justify-between border-b border-gray-200 px-5 py-4 dark:border-[rgba(148,163,184,0.085)]">
<div>
<h4 id="wg-backup-modal-title" class="text-sm font-semibold text-gray-900 dark:text-gray-100">Create backup</h4>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">Full server export (.npbk)</p>
</div>
<button type="button" class="rounded-md p-1.5 text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-[var(--np-muted)]" data-wg-modal-close aria-label="Close">
<i data-lucide="x" class="h-4 w-4"></i>
</button>
</div>
<form id="wg-backup-form" class="px-5 py-4" data-no-submit-busy novalidate>
<p class="mb-4 text-xs text-gray-600 dark:text-gray-400">
Includes configuration, keys, peers, groups, and queue settings. Leave password empty for an unencrypted file.
</p>
<div>
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300" for="backup_password">Password <span class="font-normal text-gray-400">(optional)</span></label>
<div class="relative">
<input
id="backup_password"
name="backup_password"
type="password"
autocomplete="new-password"
spellcheck="false"
class="w-full rounded border border-gray-300 bg-white py-2 pl-3 pr-10 text-sm dark:border-[rgba(148,163,184,0.085)] dark:bg-[var(--np-field)] dark:text-gray-100"
/>
<button
type="button"
id="backup-password-toggle"
class="absolute right-1 top-1/2 -translate-y-1/2 rounded-md p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-800 dark:text-gray-400 dark:hover:bg-[var(--np-muted)] dark:hover:text-gray-200"
aria-label="Show password"
aria-pressed="false"
>
<i id="backup-password-eye" data-lucide="eye" class="h-4 w-4" style="stroke-width:1.5"></i>
</button>
</div>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">Minimum 4 characters if set. Password is not stored on the server.</p>
<div class="mt-5 flex justify-end gap-2 border-t border-gray-200 pt-4 dark:border-[rgba(148,163,184,0.085)]">
<button type="button" class="rounded border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-[rgba(148,163,184,0.085)] dark:text-gray-300 dark:hover:bg-[var(--np-muted)]" data-wg-modal-close>Cancel</button>
<button type="submit" class="inline-flex items-center gap-2 rounded bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-500">
<i data-lucide="download" class="h-4 w-4"></i>
Download
</button>
</div>
</form>
</div>
</div>
{{end}}
Loading
Loading