mirror of
https://github.com/kernelkit/infix.git
synced 2026-07-22 01:13:00 +02:00
webui: relocate keystore status -> configure
Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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] + "..."
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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----- ... -----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----- ... -----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}}
|
||||
@@ -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}}
|
||||
Reference in New Issue
Block a user