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
47 changes: 21 additions & 26 deletions internal/app/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -1250,10 +1250,8 @@ func (h *Handlers) UserUpdatePost(w http.ResponseWriter, r *http.Request) {

var (
existingAllowed sql.NullString
existingPriv sql.NullString
existingPSK sql.NullString
)
err := h.svc.DB.QueryRow(`SELECT allowed_ips, private_key, preshared_key FROM vpn_users WHERE id = ? LIMIT 1`, id).Scan(&existingAllowed, &existingPriv, &existingPSK)
err := h.svc.DB.QueryRow(`SELECT allowed_ips FROM vpn_users WHERE id = ? LIMIT 1`, id).Scan(&existingAllowed)
if err != nil {
http.NotFound(w, r)
return
Expand Down Expand Up @@ -1295,36 +1293,33 @@ func (h *Handlers) UserUpdatePost(w http.ResponseWriter, r *http.Request) {
isEnabled = 1
}

// Allow setting keys only if they were not previously set (parity with original UI behavior).
// Allow updating keys and PSK at any time (client configs must be updated to match).
privateKey := strings.TrimSpace(r.FormValue("private_key"))
publicKey := strings.TrimSpace(r.FormValue("public_key"))
psk := strings.TrimSpace(r.FormValue("preshared_key"))

updatePriv := any(nil)
updatePub := any(nil)
updatePSK := any(nil)
var updatePriv any = nil
var updatePub any = nil
var updatePSK any = nil

if !existingPriv.Valid || strings.TrimSpace(existingPriv.String) == "" {
if privateKey != "" {
derivedPub, err := wireguard.DerivePublicKey(privateKey)
if err != nil {
w.Header().Set("HX-Trigger", `{"toast":{"type":"danger","message":"Invalid private key."}}`)
http.Error(w, "invalid private key", http.StatusBadRequest)
return
}
if publicKey != "" && publicKey != derivedPub {
w.Header().Set("HX-Trigger", `{"toast":{"type":"danger","message":"Public key does not match this private key."}}`)
http.Error(w, "public key mismatch", http.StatusBadRequest)
return
}
updatePriv = privateKey
updatePub = derivedPub
if privateKey != "" {
derivedPub, err := wireguard.DerivePublicKey(privateKey)
if err != nil {
w.Header().Set("HX-Trigger", `{"toast":{"type":"danger","message":"Invalid private key."}}`)
http.Error(w, "invalid private key", http.StatusBadRequest)
return
}
}
if !existingPSK.Valid || strings.TrimSpace(existingPSK.String) == "" {
if psk != "" {
updatePSK = psk
if publicKey != "" && publicKey != derivedPub {
w.Header().Set("HX-Trigger", `{"toast":{"type":"danger","message":"Public key does not match this private key."}}`)
http.Error(w, "public key mismatch", http.StatusBadRequest)
return
}
updatePriv = privateKey
updatePub = derivedPub
}

if psk != "" {
updatePSK = psk
}

_, err = h.svc.DB.Exec(`
Expand Down
4 changes: 4 additions & 0 deletions internal/view/format.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,7 @@ func humanBytes(n any) string {
return fmt.Sprintf("%.2f TiB", v/float64(tib))
}

func addInt64(a, b int64) int64 {
return a + b
}

67 changes: 67 additions & 0 deletions internal/view/templates/base.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,73 @@
if (npMainSwapTarget(evt.detail)) setPageLoading(false);
});

async function modalGenerateKeys() {
const res = await fetch('/api/users/generate-keys', { method: 'POST', credentials: 'same-origin' });
if (!res.ok) return;
const j = await res.json();
var pk = document.getElementById('modal_private_key');
var pub = document.getElementById('modal_public_key');
if (pk) pk.value = j.privateKey || '';
if (pub) pub.value = j.publicKey || '';
try { lucide.createIcons(); } catch (e) {}
}

async function modalDerivePublicKey() {
var pkEl = document.getElementById('modal_private_key');
var pubEl = document.getElementById('modal_public_key');
if (!pkEl || !pubEl) return;
var priv = pkEl.value || '';
if (!priv) {
pubEl.value = '';
return;
}
const res = await fetch('/api/users/derive-public-key', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ privateKey: priv })
});
if (!res.ok) {
pubEl.value = '';
return;
}
const j = await res.json();
pubEl.value = j.publicKey || '';
}

async function modalGeneratePSK() {
const res = await fetch('/api/users/generate-psk', { method: 'POST', credentials: 'same-origin' });
if (!res.ok) return;
const j = await res.json();
var psk = document.getElementById('modal_psk');
if (psk) psk.value = j.presharedKey || '';
try { lucide.createIcons(); } catch (e) {}
}

function modalCopy(id) {
var el = document.getElementById(id);
if (!el) return;
var val = el.value || '';
if (!val && el.disabled) {
val = el.getAttribute('value') || '';
}
navigator.clipboard.writeText(val).catch(function () {});
}

function modalTogglePk() {
modalToggleSecret('modal_private_key');
}

function modalTogglePsk() {
modalToggleSecret('modal_psk');
}

function modalToggleSecret(id) {
var el = document.getElementById(id);
if (!el) return;
el.type = (el.type === 'password') ? 'text' : 'password';
}

var __bwChart = null;
async function initBandwidthChart() {
var canvas = document.getElementById('bandwidth-chart');
Expand Down
49 changes: 0 additions & 49 deletions internal/view/templates/partials/add_user_modal.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -181,55 +181,6 @@
}
}, 0);
})();

async function modalGenerateKeys() {
const res = await fetch('/api/users/generate-keys', { method: 'POST', credentials: 'same-origin' });
if (!res.ok) return;
const j = await res.json();
document.getElementById('modal_private_key').value = j.privateKey || '';
document.getElementById('modal_public_key').value = j.publicKey || '';
try { lucide.createIcons(); } catch(e) {}
}

async function modalDerivePublicKey() {
const priv = document.getElementById('modal_private_key').value || '';
if (!priv) {
document.getElementById('modal_public_key').value = '';
return;
}
const res = await fetch('/api/users/derive-public-key', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ privateKey: priv })
});
if (!res.ok) {
document.getElementById('modal_public_key').value = '';
return;
}
const j = await res.json();
document.getElementById('modal_public_key').value = j.publicKey || '';
}

async function modalGeneratePSK() {
const res = await fetch('/api/users/generate-psk', { method: 'POST', credentials: 'same-origin' });
if (!res.ok) return;
const j = await res.json();
document.getElementById('modal_psk').value = j.presharedKey || '';
try { lucide.createIcons(); } catch(e) {}
}

function modalCopy(id) {
const el = document.getElementById(id);
if (!el) return;
navigator.clipboard.writeText(el.value || '').catch(function(){});
}

function modalTogglePk() {
const el = document.getElementById('modal_private_key');
if (!el) return;
el.type = (el.type === 'password') ? 'text' : 'password';
}
</script>
{{end}}

36 changes: 20 additions & 16 deletions internal/view/templates/partials/edit_user_modal.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,7 @@
<button
type="button"
onclick="modalGenerateKeys()"
class="inline-flex items-center gap-1 rounded bg-emerald-600/20 px-2.5 py-1 text-[11px] font-medium text-emerald-300 hover:bg-emerald-600/30 disabled:opacity-50 disabled:cursor-not-allowed"
{{if or .PrivateKey .ImportedNoPrivKey}}disabled{{end}}
class="inline-flex items-center gap-1 rounded bg-emerald-600/20 px-2.5 py-1 text-[11px] font-medium text-emerald-300 hover:bg-emerald-600/30"
>
<i data-lucide="key-round" class="h-3.5 w-3.5" style="stroke-width:1.5"></i>
Generate Keys
Expand All @@ -71,20 +70,20 @@
name="private_key"
type="password"
value="{{.PrivateKey}}"
class="w-full h-9 rounded border border-[rgba(148,163,184,0.09)] bg-[#0a0d12] px-3 pr-20 font-mono text-[11px] text-gray-100 focus:border-emerald-600 focus:outline-none focus:ring-1 focus:ring-emerald-600 disabled:bg-[var(--np-field)] disabled:text-gray-400"
{{if .PrivateKey}}disabled{{end}}
autocomplete="off"
class="w-full h-9 rounded border border-[rgba(148,163,184,0.09)] bg-[#0a0d12] px-3 pr-20 font-mono text-[11px] text-gray-100 focus:border-emerald-600 focus:outline-none focus:ring-1 focus:ring-emerald-600"
/>
<div class="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
<button type="button" onclick="modalTogglePk()" class="rounded p-1 text-gray-400 hover:bg-[#1e2d42] hover:text-gray-200">
<button type="button" onclick="modalTogglePk()" class="rounded p-1 text-gray-400 hover:bg-[#1e2d42] hover:text-gray-200" title="Show/hide private key">
<i data-lucide="eye" class="h-4 w-4" style="stroke-width:1.5"></i>
</button>
<button type="button" onclick="modalCopy('modal_private_key')" class="rounded p-1 text-gray-400 hover:bg-[#1e2d42] hover:text-gray-200">
<button type="button" onclick="modalCopy('modal_private_key')" class="rounded p-1 text-gray-400 hover:bg-[#1e2d42] hover:text-gray-200" title="Copy private key">
<i data-lucide="copy" class="h-4 w-4" style="stroke-width:1.5"></i>
</button>
</div>
</div>
<div class="mt-1 text-[11px] text-gray-500">
{{if .PrivateKey}}Private key cannot be changed after it has been set.{{else if .ImportedNoPrivKey}}Imported peer: paste the client private key that matches the stored public key. Generate Keys is disabled so the peer is not accidentally rotated.{{else}}WireGuard private key (auto-generated or manually entered){{end}}
{{if .ImportedNoPrivKey}}Imported peer: paste the client private key or generate a new key pair (this updates the stored public key).{{else if .PrivateKey}}Use the eye icon to reveal the stored key. Generating new keys rotates this peer — clients must update their config.{{else}}WireGuard private key (auto-generated or manually entered){{end}}
</div>
</div>

Expand All @@ -106,8 +105,7 @@
<button
type="button"
onclick="modalGeneratePSK()"
class="inline-flex items-center gap-1 rounded bg-purple-600/20 px-2.5 py-1 text-[11px] font-medium text-purple-300 hover:bg-purple-600/30 disabled:opacity-50 disabled:cursor-not-allowed"
{{if .PresharedKey}}disabled{{end}}
class="inline-flex items-center gap-1 rounded bg-purple-600/20 px-2.5 py-1 text-[11px] font-medium text-purple-300 hover:bg-purple-600/30"
>
<i data-lucide="sparkles" class="h-3.5 w-3.5" style="stroke-width:1.5"></i>
Generate PSK
Expand All @@ -117,17 +115,23 @@
<input
id="modal_psk"
name="preshared_key"
type="password"
value="{{.PresharedKey}}"
placeholder="Optional: enhance security with PSK"
class="w-full h-9 rounded border border-[rgba(148,163,184,0.09)] bg-[#0a0d12] px-3 pr-10 font-mono text-[11px] text-gray-100 placeholder:text-gray-600 focus:border-purple-500 focus:outline-none focus:ring-1 focus:ring-purple-500 disabled:bg-[var(--np-field)] disabled:text-gray-400"
{{if .PresharedKey}}disabled{{end}}
autocomplete="off"
class="w-full h-9 rounded border border-[rgba(148,163,184,0.09)] bg-[#0a0d12] px-3 pr-20 font-mono text-[11px] text-gray-100 placeholder:text-gray-600 focus:border-purple-500 focus:outline-none focus:ring-1 focus:ring-purple-500"
/>
<button type="button" onclick="modalCopy('modal_psk')" class="absolute right-2 top-1/2 -translate-y-1/2 rounded p-1 text-gray-400 hover:bg-[#1e2d42] hover:text-gray-200">
<i data-lucide="copy" class="h-4 w-4" style="stroke-width:1.5"></i>
</button>
<div class="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
<button type="button" onclick="modalTogglePsk()" class="rounded p-1 text-gray-400 hover:bg-[#1e2d42] hover:text-gray-200" title="Show/hide preshared key">
<i data-lucide="eye" class="h-4 w-4" style="stroke-width:1.5"></i>
</button>
<button type="button" onclick="modalCopy('modal_psk')" class="rounded p-1 text-gray-400 hover:bg-[#1e2d42] hover:text-gray-200" title="Copy preshared key">
<i data-lucide="copy" class="h-4 w-4" style="stroke-width:1.5"></i>
</button>
</div>
</div>
<div class="mt-1 text-[11px] text-gray-500">
{{if .PresharedKey}}Preshared key cannot be changed after it has been set.{{else}}Optional: Adds an extra layer of symmetric encryption for post-quantum security{{end}}
Optional: adds an extra layer of symmetric encryption. Generate or edit anytime — clients must match the saved PSK.
</div>
</div>

Expand Down Expand Up @@ -217,7 +221,7 @@
try { lucide.createIcons(); } catch (e) {}

const pk = document.getElementById('modal_private_key');
if (pk && !pk.disabled) {
if (pk) {
let t = null;
pk.addEventListener('input', function () {
if (t) clearTimeout(t);
Expand Down
Loading
Loading