webui: relocate keystore status -> configure

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
This commit is contained in:
Joachim Wiberg
2026-06-14 22:07:39 +02:00
parent 6e84a75550
commit a80a731324
7 changed files with 886 additions and 200 deletions
@@ -0,0 +1,509 @@
// SPDX-License-Identifier: MIT
package handlers
import (
"crypto/ecdsa"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
"html/template"
"log"
"net/http"
"net/url"
"strings"
"github.com/kernelkit/webui/internal/restconf"
"github.com/kernelkit/webui/internal/schema"
)
const keystorePath = candidatePath + "/ietf-keystore:keystore"
// ─── Template data ────────────────────────────────────────────────────────────
type cfgKeystorePageData struct {
PageData
Loading bool
SymmetricKeys []cfgSymKeyEntry
AsymmetricKeys []cfgAsymKeyEntry
SymKeyFormats []schema.IdentityOption
Error string
}
type cfgSymKeyEntry struct {
Name string
Format string
Value string
}
type cfgCertEntry struct {
Name string
PEM string // DER re-encoded as PEM for display
}
type cfgAsymKeyEntry struct {
Name string
Algorithm string
PublicKeyPEM string
PrivateKeyPEM string
Certificates []cfgCertEntry
}
// ─── Handler ──────────────────────────────────────────────────────────────────
type ConfigureKeystoreHandler struct {
Template *template.Template
RC restconf.Fetcher
Schema *schema.Cache
}
// Overview renders the Configure > Keystore page reading from the candidate.
// GET /configure/keystore
func (h *ConfigureKeystoreHandler) Overview(w http.ResponseWriter, r *http.Request) {
data := cfgKeystorePageData{
PageData: newPageData(r, "configure-keystore", "Configure: Keystore"),
}
var ks keystoreWrapper
if err := h.RC.Get(r.Context(), keystorePath, &ks); err != nil {
var rcErr *restconf.Error
if errors.As(err, &rcErr) && rcErr.StatusCode == http.StatusNotFound {
if fallErr := h.RC.Get(r.Context(), "/data/ietf-keystore:keystore", &ks); fallErr != nil {
var rcFall *restconf.Error
if !errors.As(fallErr, &rcFall) || rcFall.StatusCode != http.StatusNotFound {
log.Printf("configure keystore (running fallback): %v", fallErr)
data.Error = "Could not read keystore"
}
}
} else {
log.Printf("configure keystore: %v", err)
data.Error = "Could not read keystore"
}
}
for _, k := range ks.Keystore.SymmetricKeys.SymmetricKey {
data.SymmetricKeys = append(data.SymmetricKeys, cfgSymKeyEntry{
Name: k.Name,
Format: shortFormat(k.KeyFormat),
Value: decodeSymmetricValue(k),
})
}
for _, k := range ks.Keystore.AsymmetricKeys.AsymmetricKey {
entry := cfgAsymKeyEntry{
Name: k.Name,
Algorithm: asymAlgorithm(k),
PublicKeyPEM: derBase64ToPEM(k.PublicKey, pemBlockType(k.PublicKeyFormat)),
PrivateKeyPEM: derBase64ToPEM(k.CleartextPrivateKey, pemBlockType(k.PrivateKeyFormat)),
}
for _, c := range k.Certificates.Certificate {
entry.Certificates = append(entry.Certificates, cfgCertEntry{
Name: c.Name,
PEM: derBase64ToPEM(c.CertData, "CERTIFICATE"),
})
}
data.AsymmetricKeys = append(data.AsymmetricKeys, entry)
}
mgr := h.Schema.Manager()
data.Loading = mgr == nil
if mgr != nil {
data.SymKeyFormats = schema.OptionsFor(mgr, "/ietf-keystore:keystore/symmetric-keys/symmetric-key/key-format")
}
tmplName := "configure-keystore.html"
if r.Header.Get("HX-Request") == "true" {
tmplName = "content"
}
if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil {
log.Printf("template error: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
// AddSymKey adds a symmetric key to the candidate.
// POST /configure/keystore/symmetric
func (h *ConfigureKeystoreHandler) AddSymKey(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
name := strings.TrimSpace(r.FormValue("name"))
value := r.FormValue("value")
format := r.FormValue("format")
if format == "" {
format = "infix-crypto-types:passphrase-key-format"
}
if name == "" {
renderSaveError(w, fmt.Errorf("name is required"))
return
}
// Passphrase values are base64-encoded plaintext in the YANG model.
keyB64 := base64.StdEncoding.EncodeToString([]byte(value))
body := map[string]any{
"ietf-keystore:symmetric-key": []map[string]any{{
"name": name,
"key-format": format,
"cleartext-symmetric-key": keyB64,
}},
}
path := keystorePath + "/symmetric-keys/symmetric-key=" + url.PathEscape(name)
if err := h.RC.Put(r.Context(), path, body); err != nil {
log.Printf("configure keystore add sym %q: %v", name, err)
renderSaveError(w, err)
return
}
w.Header().Set("HX-Location", `{"path":"/configure/keystore","target":"#content"}`)
w.WriteHeader(http.StatusNoContent)
}
// DeleteSymKey removes a symmetric key from the candidate.
// DELETE /configure/keystore/symmetric/{name}
func (h *ConfigureKeystoreHandler) DeleteSymKey(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
path := keystorePath + "/symmetric-keys/symmetric-key=" + url.PathEscape(name)
if err := h.RC.Delete(r.Context(), path); err != nil {
log.Printf("configure keystore delete sym %q: %v", name, err)
renderSaveError(w, err)
return
}
w.Header().Set("HX-Location", `{"path":"/configure/keystore","target":"#content"}`)
w.WriteHeader(http.StatusNoContent)
}
// AddAsymKey adds an asymmetric key from a PEM-encoded private key.
// POST /configure/keystore/asymmetric
func (h *ConfigureKeystoreHandler) AddAsymKey(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
name := strings.TrimSpace(r.FormValue("name"))
privPEM := r.FormValue("private_key")
pubPEM := strings.TrimSpace(r.FormValue("public_key"))
if name == "" {
renderSaveError(w, fmt.Errorf("name is required"))
return
}
if privPEM == "" {
renderSaveError(w, fmt.Errorf("private key is required"))
return
}
block, _ := pem.Decode([]byte(privPEM))
if block == nil {
renderSaveError(w, fmt.Errorf("invalid private key PEM: no PEM block found"))
return
}
privB64 := base64.StdEncoding.EncodeToString(block.Bytes)
keyBody := map[string]any{
"name": name,
"private-key-format": pemTypeToKeyFormat(block.Type),
"cleartext-private-key": privB64,
}
// Use explicitly provided public key, or derive from private key (avoids re-parsing PEM).
if pubPEM == "" {
pubPEM = derivePublicKeyFromDER(block.Bytes, block.Type)
}
if pubPEM != "" {
if err := applyPublicKey(keyBody, pubPEM); err != nil {
renderSaveError(w, err)
return
}
}
body := map[string]any{"ietf-keystore:asymmetric-key": []map[string]any{keyBody}}
path := keystorePath + "/asymmetric-keys/asymmetric-key=" + url.PathEscape(name)
if err := h.RC.Put(r.Context(), path, body); err != nil {
log.Printf("configure keystore add asym %q: %v", name, err)
renderSaveError(w, err)
return
}
w.Header().Set("HX-Location", `{"path":"/configure/keystore","target":"#content"}`)
w.WriteHeader(http.StatusNoContent)
}
// derivePublicKeyFromDER extracts the public key from DER-encoded private key bytes
// and returns it as a PKIX PEM block. Returns empty string if derivation fails.
func derivePublicKeyFromDER(der []byte, pemType string) string {
var pubKey any
switch strings.ToUpper(pemType) {
case "EC PRIVATE KEY":
k, err := x509.ParseECPrivateKey(der)
if err != nil {
return ""
}
pubKey = &k.PublicKey
case "RSA PRIVATE KEY":
k, err := x509.ParsePKCS1PrivateKey(der)
if err != nil {
return ""
}
pubKey = &k.PublicKey
default: // PKCS#8 / "PRIVATE KEY"
k, err := x509.ParsePKCS8PrivateKey(der)
if err != nil {
return ""
}
switch v := k.(type) {
case *ecdsa.PrivateKey:
pubKey = &v.PublicKey
case *rsa.PrivateKey:
pubKey = &v.PublicKey
default:
return ""
}
}
pubDER, err := x509.MarshalPKIXPublicKey(pubKey)
if err != nil {
return ""
}
return string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER}))
}
// applyPublicKey parses pubPEM and sets public-key and public-key-format in keyBody.
func applyPublicKey(keyBody map[string]any, pubPEM string) error {
b64, pemType, err := parsePEMBody(pubPEM)
if err != nil {
return fmt.Errorf("invalid public key PEM: %w", err)
}
keyBody["public-key-format"] = pemTypeToKeyFormat(pemType)
keyBody["public-key"] = b64
return nil
}
// DeleteAsymKey removes an asymmetric key from the candidate.
// DELETE /configure/keystore/asymmetric/{name}
func (h *ConfigureKeystoreHandler) DeleteAsymKey(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
path := keystorePath + "/asymmetric-keys/asymmetric-key=" + url.PathEscape(name)
if err := h.RC.Delete(r.Context(), path); err != nil {
log.Printf("configure keystore delete asym %q: %v", name, err)
renderSaveError(w, err)
return
}
w.Header().Set("HX-Location", `{"path":"/configure/keystore","target":"#content"}`)
w.WriteHeader(http.StatusNoContent)
}
// AddCert adds a certificate to an asymmetric key entry.
// POST /configure/keystore/asymmetric/{name}/certs
func (h *ConfigureKeystoreHandler) AddCert(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
keyName := r.PathValue("name")
certName := strings.TrimSpace(r.FormValue("cert_name"))
pemData := r.FormValue("cert_data")
if certName == "" || pemData == "" {
renderSaveError(w, fmt.Errorf("certificate name and data are required"))
return
}
certB64, _, err := parsePEMBody(pemData)
if err != nil {
renderSaveError(w, fmt.Errorf("invalid PEM: %w", err))
return
}
body := map[string]any{
"ietf-keystore:certificate": []map[string]any{{
"name": certName,
"cert-data": certB64,
}},
}
path := keystorePath + "/asymmetric-keys/asymmetric-key=" + url.PathEscape(keyName) +
"/certificates/certificate=" + url.PathEscape(certName)
if err := h.RC.Put(r.Context(), path, body); err != nil {
log.Printf("configure keystore add cert %q/%q: %v", keyName, certName, err)
renderSaveError(w, err)
return
}
w.Header().Set("HX-Location", `{"path":"/configure/keystore","target":"#content"}`)
w.WriteHeader(http.StatusNoContent)
}
// UpdateCert replaces the PEM data of an existing certificate.
// POST /configure/keystore/asymmetric/{name}/certs/{certname}
func (h *ConfigureKeystoreHandler) UpdateCert(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
keyName := r.PathValue("name")
certName := r.PathValue("certname")
pemData := r.FormValue("cert_data")
if pemData == "" {
renderSaveError(w, fmt.Errorf("certificate PEM data is required"))
return
}
certB64, _, err := parsePEMBody(pemData)
if err != nil {
renderSaveError(w, fmt.Errorf("invalid PEM: %w", err))
return
}
body := map[string]any{
"ietf-keystore:certificate": []map[string]any{{
"name": certName,
"cert-data": certB64,
}},
}
path := keystorePath + "/asymmetric-keys/asymmetric-key=" + url.PathEscape(keyName) +
"/certificates/certificate=" + url.PathEscape(certName)
if err := h.RC.Patch(r.Context(), path, body); err != nil {
log.Printf("configure keystore update cert %q/%q: %v", keyName, certName, err)
renderSaveError(w, err)
return
}
renderSaved(w, "Certificate updated")
}
// DeleteCert removes a certificate from an asymmetric key entry.
// DELETE /configure/keystore/asymmetric/{name}/certs/{certname}
func (h *ConfigureKeystoreHandler) DeleteCert(w http.ResponseWriter, r *http.Request) {
keyName := r.PathValue("name")
certName := r.PathValue("certname")
path := keystorePath + "/asymmetric-keys/asymmetric-key=" + url.PathEscape(keyName) +
"/certificates/certificate=" + url.PathEscape(certName)
if err := h.RC.Delete(r.Context(), path); err != nil {
log.Printf("configure keystore delete cert %q/%q: %v", keyName, certName, err)
renderSaveError(w, err)
return
}
w.Header().Set("HX-Location", `{"path":"/configure/keystore","target":"#content"}`)
w.WriteHeader(http.StatusNoContent)
}
// derBase64ToPEM converts a base64-encoded DER blob (as returned by RESTCONF
// for YANG binary fields) back into a PEM-encoded string for display.
func derBase64ToPEM(b64, blockType string) string {
b64 = strings.ReplaceAll(b64, "\n", "")
der, err := base64.StdEncoding.DecodeString(b64)
if err != nil || len(der) == 0 {
return ""
}
return string(pem.EncodeToMemory(&pem.Block{Type: blockType, Bytes: der}))
}
// UpdateSymKey changes the value of an existing symmetric key in the candidate.
// POST /configure/keystore/symmetric/{name}
func (h *ConfigureKeystoreHandler) UpdateSymKey(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
name := r.PathValue("name")
value := r.FormValue("value")
format := r.FormValue("format")
if format == "" {
format = "infix-crypto-types:passphrase-key-format"
}
keyB64 := base64.StdEncoding.EncodeToString([]byte(value))
body := map[string]any{
"ietf-keystore:symmetric-key": []map[string]any{{
"name": name,
"key-format": format,
"cleartext-symmetric-key": keyB64,
}},
}
path := keystorePath + "/symmetric-keys/symmetric-key=" + url.PathEscape(name)
if err := h.RC.Patch(r.Context(), path, body); err != nil {
log.Printf("configure keystore update sym %q: %v", name, err)
renderSaveError(w, err)
return
}
renderSaved(w, "Key updated")
}
// UpdateAsymKey updates the private and/or public key of an asymmetric key entry.
// POST /configure/keystore/asymmetric/{name}
func (h *ConfigureKeystoreHandler) UpdateAsymKey(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
name := r.PathValue("name")
privPEM := strings.TrimSpace(r.FormValue("private_key"))
pubPEM := strings.TrimSpace(r.FormValue("public_key"))
if privPEM == "" && pubPEM == "" {
renderSaveError(w, fmt.Errorf("at least one key field is required"))
return
}
keyBody := map[string]any{"name": name}
if privPEM != "" {
block, _ := pem.Decode([]byte(privPEM))
if block == nil {
renderSaveError(w, fmt.Errorf("invalid private key PEM: no PEM block found"))
return
}
keyBody["private-key-format"] = pemTypeToKeyFormat(block.Type)
keyBody["cleartext-private-key"] = base64.StdEncoding.EncodeToString(block.Bytes)
if pubPEM == "" {
pubPEM = derivePublicKeyFromDER(block.Bytes, block.Type)
}
}
if pubPEM != "" {
if err := applyPublicKey(keyBody, pubPEM); err != nil {
renderSaveError(w, err)
return
}
}
body := map[string]any{"ietf-keystore:asymmetric-key": []map[string]any{keyBody}}
path := keystorePath + "/asymmetric-keys/asymmetric-key=" + url.PathEscape(name)
if err := h.RC.Patch(r.Context(), path, body); err != nil {
log.Printf("configure keystore update asym %q: %v", name, err)
renderSaveError(w, err)
return
}
renderSaved(w, "Key updated")
}
// pemBlockType maps a YANG key-format identity string to the corresponding PEM block type.
func pemBlockType(format string) string {
switch shortFormat(format) {
case "ec":
return "EC PRIVATE KEY"
case "rsa":
return "RSA PRIVATE KEY"
case "subject-public-key-info":
return "PUBLIC KEY"
default: // one-asymmetric (PKCS#8) and anything else
return "PRIVATE KEY"
}
}
// parsePEMBody decodes a PEM block and returns the DER content re-encoded as
// standard base64 (no line breaks), plus the PEM block type string.
func parsePEMBody(s string) (b64 string, pemType string, err error) {
block, _ := pem.Decode([]byte(s))
if block == nil {
return "", "", fmt.Errorf("no PEM block found")
}
return base64.StdEncoding.EncodeToString(block.Bytes), block.Type, nil
}
// pemTypeToKeyFormat maps a PEM block type to the appropriate ietf-crypto-types identity.
func pemTypeToKeyFormat(pemType string) string {
switch strings.ToUpper(pemType) {
case "EC PRIVATE KEY":
return "ietf-crypto-types:ec-private-key-format"
case "RSA PRIVATE KEY":
return "ietf-crypto-types:rsa-private-key-format"
case "PUBLIC KEY":
return "ietf-crypto-types:subject-public-key-info-format"
default: // "PRIVATE KEY" (PKCS#8 / one-asymmetric-key) and anything else
return "ietf-crypto-types:one-asymmetric-key-format"
}
}
-92
View File
@@ -4,12 +4,7 @@ package handlers
import (
"encoding/base64"
"html/template"
"log"
"net/http"
"strings"
"github.com/kernelkit/webui/internal/restconf"
)
// RESTCONF JSON structures for ietf-keystore:keystore.
@@ -55,85 +50,6 @@ type certificateJSON struct {
CertData string `json:"cert-data"`
}
// Template data structures.
type keystoreData struct {
PageData
SymmetricKeys []symKeyEntry
AsymmetricKeys []asymKeyEntry
Empty bool
Error string
}
type symKeyEntry struct {
Name string
Format string
Value string
}
type asymKeyEntry struct {
Name string
Algorithm string
PublicKey string
PublicKeyFull string
PrivateKey string
PrivateKeyFull string
Certificates []string
}
// KeystoreHandler serves the keystore overview page.
type KeystoreHandler struct {
Template *template.Template
RC *restconf.Client
}
// Overview renders the keystore overview (GET /keystore).
func (h *KeystoreHandler) Overview(w http.ResponseWriter, r *http.Request) {
data := keystoreData{
PageData: newPageData(r, "keystore", "Keystore"),
}
var ks keystoreWrapper
if err := h.RC.Get(r.Context(), "/data/ietf-keystore:keystore", &ks); err != nil {
log.Printf("restconf keystore: %v", err)
data.Error = "Could not fetch keystore"
} else {
for _, k := range ks.Keystore.SymmetricKeys.SymmetricKey {
data.SymmetricKeys = append(data.SymmetricKeys, symKeyEntry{
Name: k.Name,
Format: shortFormat(k.KeyFormat),
Value: decodeSymmetricValue(k),
})
}
for _, k := range ks.Keystore.AsymmetricKeys.AsymmetricKey {
entry := asymKeyEntry{
Name: k.Name,
Algorithm: asymAlgorithm(k),
PublicKeyFull: k.PublicKey,
PublicKey: truncate(k.PublicKey, 40),
PrivateKeyFull: k.CleartextPrivateKey,
PrivateKey: truncate(k.CleartextPrivateKey, 40),
}
for _, c := range k.Certificates.Certificate {
entry.Certificates = append(entry.Certificates, c.Name)
}
data.AsymmetricKeys = append(data.AsymmetricKeys, entry)
}
data.Empty = len(data.SymmetricKeys) == 0 && len(data.AsymmetricKeys) == 0
}
tmplName := "keystore.html"
if r.Header.Get("HX-Request") == "true" {
tmplName = "content"
}
if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil {
log.Printf("template error: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
// shortFormat strips the YANG module prefix and "-key-format" suffix.
// e.g. "ietf-crypto-types:octet-string-key-format" → "octet-string"
func shortFormat(full string) string {
@@ -171,11 +87,3 @@ func decodeSymmetricValue(k symmetricKeyJSON) string {
}
return val
}
// truncate shortens s to max characters, adding "..." if truncated.
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max-3] + "..."
}
+16 -3
View File
@@ -36,7 +36,7 @@ func New(
if err != nil {
return nil, err
}
ksTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/keystore.html")
ksTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "fragments/configure-toolbar.html", "pages/configure-keystore.html")
if err != nil {
return nil, err
}
@@ -141,9 +141,10 @@ func New(
RC: rc,
}
ks := &handlers.KeystoreHandler{
cfgKs := &handlers.ConfigureKeystoreHandler{
Template: ksTmpl,
RC: rc,
Schema: schemaCache,
}
iface := &handlers.InterfacesHandler{
@@ -197,7 +198,9 @@ func New(
mux.HandleFunc("GET /interfaces/{name}", iface.Detail)
mux.HandleFunc("GET /interfaces/{name}/counters", iface.Counters)
mux.HandleFunc("GET /firewall", fw.Overview)
mux.HandleFunc("GET /keystore", ks.Overview)
mux.HandleFunc("GET /keystore", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/configure/keystore", http.StatusMovedPermanently)
})
mux.HandleFunc("GET /firmware", sys.Firmware)
mux.HandleFunc("GET /firmware/progress", sys.FirmwareProgress)
mux.HandleFunc("POST /firmware/install", sys.FirmwareInstall)
@@ -236,6 +239,16 @@ func New(
mux.HandleFunc("DELETE /configure/users/{name}/keys/{keyname}", cfgUsers.DeleteKey)
mux.HandleFunc("POST /configure/users/groups/{name}/members", cfgUsers.AddGroupMembers)
mux.HandleFunc("DELETE /configure/users/groups/{name}/members/{user}", cfgUsers.RemoveGroupMember)
mux.HandleFunc("GET /configure/keystore", cfgKs.Overview)
mux.HandleFunc("POST /configure/keystore/symmetric", cfgKs.AddSymKey)
mux.HandleFunc("POST /configure/keystore/symmetric/{name}", cfgKs.UpdateSymKey)
mux.HandleFunc("DELETE /configure/keystore/symmetric/{name}", cfgKs.DeleteSymKey)
mux.HandleFunc("POST /configure/keystore/asymmetric", cfgKs.AddAsymKey)
mux.HandleFunc("POST /configure/keystore/asymmetric/{name}", cfgKs.UpdateAsymKey)
mux.HandleFunc("DELETE /configure/keystore/asymmetric/{name}", cfgKs.DeleteAsymKey)
mux.HandleFunc("POST /configure/keystore/asymmetric/{name}/certs", cfgKs.AddCert)
mux.HandleFunc("POST /configure/keystore/asymmetric/{name}/certs/{certname}", cfgKs.UpdateCert)
mux.HandleFunc("DELETE /configure/keystore/asymmetric/{name}/certs/{certname}", cfgKs.DeleteCert)
// Schema API routes (authenticated).
mux.HandleFunc("GET /api/schema", schemaH.Schema)
+1
View File
@@ -1617,6 +1617,7 @@ details.fw-hint[open] summary::before { transform: rotate(90deg); }
background: var(--surface-alt);
padding: 0.75rem 1rem 0.75rem 1.5rem !important;
border-top: none !important;
text-align: left !important;
}
.key-detail-body { display: flex; flex-direction: column; gap: 0.6rem; }
.key-field { display: flex; flex-direction: column; gap: 0.2rem; }
+11 -11
View File
@@ -29,16 +29,6 @@
</a>
</li>
{{end}}
<li>
<a href="/keystore"
hx-get="/keystore"
hx-target="#content"
hx-push-url="true"
class="nav-link {{if eq .ActivePage "keystore"}}active{{end}}">
<span class="nav-icon" style="--icon: url('/assets/img/icons/keystore.svg')"></span>
Keystore
</a>
</li>
</ul>
<div class="nav-section-label">Network</div>
@@ -168,7 +158,7 @@
</details>
<details class="nav-group-top" id="nav-configure"
{{if or (eq .ActivePage "configure-system") (eq .ActivePage "configure-users") (eq .ActivePage "configure-tree")}}open{{end}}>
{{if or (eq .ActivePage "configure-system") (eq .ActivePage "configure-users") (eq .ActivePage "configure-keystore") (eq .ActivePage "configure-tree")}}open{{end}}>
<summary class="nav-group-summary-top">Configure</summary>
<ul class="nav-group-items">
<li>
@@ -191,6 +181,16 @@
Users
</a>
</li>
<li>
<a href="/configure/keystore"
hx-get="/configure/keystore"
hx-target="#content"
hx-push-url="true"
class="nav-link {{if eq .ActivePage "configure-keystore"}}active{{end}}">
<span class="nav-icon" style="--icon: url('/assets/img/icons/keystore.svg')"></span>
Keystore
</a>
</li>
<li>
<a href="/configure/tree"
hx-get="/configure/tree"
@@ -0,0 +1,349 @@
{{define "configure-keystore.html"}}
{{template "base" .}}
{{end}}
{{define "content"}}
<div class="configure-page">
{{if .Error}}
<div class="alert alert-error">{{.Error}}</div>
{{end}}
<section class="info-grid">
{{/* ── Symmetric Keys ─────────────────────────────────────────────── */}}
<section class="info-card info-grid-span-2">
<div class="card-header">Symmetric Keys</div>
<div class="data-table-wrap">
<table class="data-table cfg-table">
<thead>
<tr>
<th>Name</th>
<th>Format</th>
<th>Value</th>
<th></th>
</tr>
</thead>
<tbody>
{{range $i, $k := .SymmetricKeys}}
<tr>
<td>
<button class="key-row-toggle" type="button"
aria-expanded="false" data-target="sym-{{$i}}">
<span class="key-row-arrow" aria-hidden="true"></span>{{.Name}}
</button>
</td>
<td>{{.Format}}</td>
<td>
{{if .Value}}
<span class="cfg-secret-wrap">
<span class="cfg-input-mask" id="sym-val-{{$i}}">{{.Value}}</span>
<button type="button" class="btn-icon cfg-secret-toggle"
data-toggle-mask="sym-val-{{$i}}"
aria-label="Show / hide value">👁</button>
</span>
{{else}}<span class="text-muted"></span>{{end}}
</td>
<td style="text-align:right">
<button type="button" class="btn-icon btn-icon-danger"
hx-delete="/configure/keystore/symmetric/{{.Name}}"
hx-confirm="Delete symmetric key {{.Name}}?"
hx-swap="none"
title="Delete key">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</td>
</tr>
<tr class="key-detail-row" id="key-detail-sym-{{$i}}">
<td colspan="4" class="key-detail-cell">
<div class="key-detail-body">
<form hx-post="/configure/keystore/symmetric/{{.Name}}" hx-swap="none">
<table class="info-table">
<tr>
<th>Format</th>
<td>
<select class="cfg-input" name="format">
{{$fmt := .Format}}
{{range $.SymKeyFormats}}
<option value="{{.Value}}"
{{if eq $fmt .Label}}selected{{end}}>{{.Label}}</option>
{{else}}
<option value="infix-crypto-types:passphrase-key-format"
{{if eq $fmt "passphrase"}}selected{{end}}>passphrase</option>
<option value="ietf-crypto-types:octet-string-key-format"
{{if eq $fmt "octet-string"}}selected{{end}}>octet-string</option>
<option value="ietf-crypto-types:one-symmetric-key-format"
{{if eq $fmt "one-symmetric"}}selected{{end}}>one-symmetric</option>
{{end}}
</select>
</td>
</tr>
<tr>
<th>Value</th>
<td>
<div class="cfg-secret-wrap">
<input class="cfg-input cfg-input-mask" type="text"
name="value" value="{{.Value}}"
id="sym-edit-{{$i}}"
autocomplete="off" spellcheck="false"
data-1p-ignore="true" data-lpignore="true"
data-bwignore="true" data-form-type="other">
<button type="button" class="btn-icon cfg-secret-toggle"
data-toggle-mask="sym-edit-{{$i}}"
aria-label="Show / hide value">👁</button>
</div>
</td>
</tr>
</table>
<div class="cfg-card-footer" style="padding-left:0">
<button class="btn btn-primary btn-sm" type="submit">Save</button>
<span class="cfg-save-status"></span>
</div>
</form>
</div>
</td>
</tr>
{{end}}
{{/* Hidden add-sym-key row */}}
<tr id="add-sym-row" hidden>
<td colspan="4" class="key-detail-cell">
<form hx-post="/configure/keystore/symmetric" hx-swap="none"
class="user-add-inline">
<input class="cfg-input" type="text" name="name" required
placeholder="Name">
<select class="cfg-input" name="format">
{{range .SymKeyFormats}}
<option value="{{.Value}}">{{.Label}}</option>
{{else}}
<option value="infix-crypto-types:passphrase-key-format">passphrase</option>
<option value="ietf-crypto-types:octet-string-key-format">octet-string</option>
<option value="ietf-crypto-types:one-symmetric-key-format">one-symmetric</option>
{{end}}
</select>
<span class="cfg-secret-wrap">
<input class="cfg-input cfg-input-mask" type="text" name="value"
id="add-sym-value" placeholder="Value"
autocomplete="off" spellcheck="false"
data-1p-ignore="true" data-lpignore="true"
data-bwignore="true" data-form-type="other">
<button type="button" class="btn-icon cfg-secret-toggle"
data-toggle-mask="add-sym-value"
aria-label="Show / hide value">👁</button>
</span>
<button class="btn btn-primary btn-sm" type="submit">Add</button>
<button type="button" class="btn btn-sm" data-hide="add-sym-row">Cancel</button>
<span class="cfg-save-status"></span>
</form>
</td>
</tr>
</tbody>
</table>
</div>
<div class="cfg-card-footer">
<button type="button" class="btn-add-row" data-show="add-sym-row">+ Add symmetric key</button>
</div>
</section>
{{/* ── Asymmetric Keys ─────────────────────────────────────────────── */}}
<section class="info-card info-grid-span-2">
<div class="card-header">Asymmetric Keys</div>
<div class="data-table-wrap">
<table class="data-table cfg-table">
<thead>
<tr>
<th>Name</th>
<th>Algorithm</th>
<th style="text-align:center">Certificates</th>
<th></th>
</tr>
</thead>
<tbody>
{{range $i, $k := .AsymmetricKeys}}
<tr>
<td>
<button class="key-row-toggle" type="button"
aria-expanded="false" data-target="asym-{{$i}}">
<span class="key-row-arrow" aria-hidden="true"></span>{{.Name}}
</button>
</td>
<td>{{.Algorithm}}</td>
<td style="text-align:center">{{len .Certificates}}</td>
<td style="text-align:right">
<button type="button" class="btn-icon btn-icon-danger"
hx-delete="/configure/keystore/asymmetric/{{.Name}}"
hx-confirm="Delete asymmetric key {{.Name}} and all its certificates?"
hx-swap="none"
title="Delete key">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</td>
</tr>
<tr class="key-detail-row" id="key-detail-asym-{{$i}}">
<td colspan="4" class="key-detail-cell">
<div class="key-detail-body">
{{/* Editable key material */}}
<form hx-post="/configure/keystore/asymmetric/{{.Name}}" hx-swap="none">
<table class="info-table">
<tr>
<th>Private Key</th>
<td><textarea class="cfg-input cfg-input-mono" name="private_key" rows="6">{{.PrivateKeyPEM}}</textarea></td>
</tr>
{{if .PublicKeyPEM}}
<tr>
<th>Public Key</th>
<td><textarea class="cfg-input cfg-input-mono" name="public_key" rows="4">{{.PublicKeyPEM}}</textarea></td>
</tr>
{{end}}
</table>
<div class="cfg-card-footer" style="padding-left:0">
<button class="btn btn-primary btn-sm" type="submit">Save</button>
<span class="cfg-save-status"></span>
</div>
</form>
{{/* Certificate list */}}
<div>
<div class="section-subtitle">Certificates ({{len .Certificates}})</div>
{{if .Certificates}}
<div class="data-table-wrap">
<table class="data-table cfg-table">
<thead>
<tr><th>Name</th><th></th></tr>
</thead>
<tbody>
{{range .Certificates}}
<tr class="key-summary-row">
<td>
{{if .PEM}}
<button class="key-row-toggle" type="button"
aria-expanded="false" data-target="cert-{{$i}}-{{.Name}}">
<span class="key-row-arrow" aria-hidden="true"></span>{{.Name}}
</button>
{{else}}{{.Name}}{{end}}
</td>
<td style="text-align:right">
<button type="button" class="btn-icon btn-icon-danger"
hx-delete="/configure/keystore/asymmetric/{{$k.Name}}/certs/{{.Name}}"
hx-confirm="Remove certificate {{.Name}}?"
hx-swap="none"
title="Remove certificate">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</td>
</tr>
{{if .PEM}}
<tr class="key-detail-row" id="key-detail-cert-{{$i}}-{{.Name}}">
<td colspan="2" class="key-detail-cell">
<div class="key-detail-body">
<form hx-post="/configure/keystore/asymmetric/{{$k.Name}}/certs/{{.Name}}"
hx-swap="none">
<table class="info-table">
<tr>
<th>PEM</th>
<td><textarea class="cfg-input cfg-input-mono" name="cert_data"
rows="6">{{.PEM}}</textarea></td>
</tr>
</table>
<div class="cfg-card-footer" style="padding-left:0">
<button class="btn btn-primary btn-sm" type="submit">Save</button>
<span class="cfg-save-status"></span>
</div>
</form>
</div>
</td>
</tr>
{{end}}
{{end}}
</tbody>
</table>
</div>
{{end}}
{{/* Add certificate */}}
<form hx-post="/configure/keystore/asymmetric/{{$k.Name}}/certs"
hx-swap="none" style="margin-top:0.5rem">
<table class="info-table">
<tr>
<th>Cert name</th>
<td><input class="cfg-input" type="text" name="cert_name"
placeholder="e.g. tls-cert"></td>
</tr>
<tr>
<th>PEM data</th>
<td><textarea class="cfg-input cfg-input-mono" name="cert_data" rows="4"
placeholder="-----BEGIN CERTIFICATE-----&#10;...&#10;-----END CERTIFICATE-----"></textarea></td>
</tr>
</table>
<div class="cfg-card-footer" style="padding-left:0">
<button class="btn btn-primary btn-sm" type="submit">Add Certificate</button>
<span class="cfg-save-status"></span>
</div>
</form>
</div>
</div>
</td>
</tr>
{{end}}
{{/* Hidden add-asym-key row */}}
<tr id="add-asym-row" hidden>
<td colspan="4" class="key-detail-cell">
<form hx-post="/configure/keystore/asymmetric" hx-swap="none">
<table class="info-table">
<tr>
<th>Name</th>
<td><input class="cfg-input" type="text" name="name" required
placeholder="e.g. tls-server-key"></td>
</tr>
<tr>
<th>Private Key (PEM)</th>
<td><textarea class="cfg-input cfg-input-mono" name="private_key"
rows="6" required
placeholder="-----BEGIN PRIVATE KEY-----&#10;...&#10;-----END PRIVATE KEY-----"></textarea></td>
</tr>
<tr>
<th>Public Key (PEM)</th>
<td><textarea class="cfg-input cfg-input-mono" name="public_key"
rows="4"
placeholder="Optional — derived automatically from RSA/EC private keys"></textarea></td>
</tr>
</table>
<div class="cfg-card-footer" style="padding-left:0">
<button class="btn btn-primary btn-sm" type="submit">Add</button>
<button type="button" class="btn btn-sm" data-hide="add-asym-row">Cancel</button>
<span class="cfg-save-status"></span>
</div>
</form>
</td>
</tr>
</tbody>
</table>
</div>
<div class="cfg-card-footer">
<button type="button" class="btn-add-row" data-show="add-asym-row">+ Add asymmetric key</button>
</div>
</section>
</section>
{{template "configure-toolbar" .}}
</div>
{{end}}
-94
View File
@@ -1,94 +0,0 @@
{{define "keystore.html"}}
{{template "base" .}}
{{end}}
{{define "content"}}
{{if .Error}}
<div class="alert alert-error">{{.Error}}</div>
{{end}}
{{if .Empty}}
<section class="info-card">
<div class="card-header">Keystore</div>
<p class="empty-message">Keystore is empty.</p>
</section>
{{end}}
{{if .SymmetricKeys}}
<section class="info-card">
<div class="card-header">Symmetric Keys</div>
<div class="data-table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Name</th>
<th>Format</th>
</tr>
</thead>
<tbody>
{{range $i, $k := .SymmetricKeys}}
<tr class="key-summary-row">
<td><button class="key-row-toggle" type="button" aria-expanded="false" data-target="sym-{{$i}}"><span class="key-row-arrow" aria-hidden="true"></span>{{.Name}}</button></td>
<td>{{.Format}}</td>
</tr>
<tr class="key-detail-row" id="key-detail-sym-{{$i}}">
<td colspan="2" class="key-detail-cell">
<div class="key-detail-body">
<div class="key-field">
<span class="key-field-label">Value</span>
<code class="key-field-value">{{.Value}}</code>
</div>
</div>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</section>
{{end}}
{{if .AsymmetricKeys}}
<section class="info-card">
<div class="card-header">Asymmetric Keys</div>
<div class="data-table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Name</th>
<th>Algorithm</th>
<th>Certificates</th>
</tr>
</thead>
<tbody>
{{range $i, $k := .AsymmetricKeys}}
<tr class="key-summary-row">
<td><button class="key-row-toggle" type="button" aria-expanded="false" data-target="asym-{{$i}}"><span class="key-row-arrow" aria-hidden="true"></span>{{.Name}}</button></td>
<td>{{.Algorithm}}</td>
<td>{{range $j, $c := .Certificates}}{{if $j}}, {{end}}{{$c}}{{end}}</td>
</tr>
<tr class="key-detail-row" id="key-detail-asym-{{$i}}">
<td colspan="4" class="key-detail-cell">
<div class="key-detail-body">
{{if .PublicKeyFull}}
<div class="key-field">
<span class="key-field-label">Public Key</span>
<code class="key-field-value">{{.PublicKeyFull}}</code>
</div>
{{end}}
{{if .PrivateKeyFull}}
<div class="key-field">
<span class="key-field-label">Private Key</span>
<code class="key-field-value">{{.PrivateKeyFull}}</code>
</div>
{{end}}
</div>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</section>
{{end}}
{{end}}