Forráskód Böngészése

feat(certs): add certificate manager, Let's Encrypt support and dynamic HTTPS web service

Proxmox Router 1 hete
szülő
commit
eef9143f35

BIN
bin/debtik-cli


BIN
bin/proxmox-router


+ 97 - 0
cmd/server/main.go

@@ -2,12 +2,14 @@ package main
 
 import (
 	"context"
+	"crypto/tls"
 	"flag"
 	"fmt"
 	"log"
 	"net/http"
 	"os"
 	"os/signal"
+	"sync"
 	"syscall"
 	"time"
 
@@ -15,6 +17,7 @@ import (
 	"proxmox-router/internal/auth"
 	"proxmox-router/internal/bridge"
 	"proxmox-router/internal/bwtest"
+	"proxmox-router/internal/certs"
 	"proxmox-router/internal/cli"
 	"proxmox-router/internal/dhcp"
 	"proxmox-router/internal/dns"
@@ -58,6 +61,10 @@ func main() {
 	dnsMgr := dns.NewManager()
 	authMgr := auth.NewManager("")
 	servicesMgr := services.NewManager("")
+	certMgr, err := certs.NewCertManager("")
+	if err != nil {
+		log.Fatalf("Failed to initialize certificate manager: %v", err)
+	}
 	webHandler := web.Handler()
 
 	// Build Unified RouterOS CLI Executor
@@ -75,6 +82,7 @@ func main() {
 		vrrpMgr,
 		bwtestMgr,
 		servicesMgr,
+		certMgr,
 	)
 
 	// Determine Telnet & SSH addresses from services config or flags
@@ -155,9 +163,98 @@ func main() {
 		dnsMgr,
 		authMgr,
 		servicesMgr,
+		certMgr,
 		webHandler,
 	)
 
+	// HTTPS (www-ssl) Dynamic Server Listener
+	var (
+		httpsServer  *http.Server
+		httpsMu      sync.Mutex
+		httpsRunning bool
+	)
+
+	startOrRestartHTTPS := func(port int, disabled bool) {
+		httpsMu.Lock()
+		defer httpsMu.Unlock()
+
+		if httpsRunning && httpsServer != nil {
+			ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+			_ = httpsServer.Shutdown(ctx)
+			cancel()
+			httpsRunning = false
+			httpsServer = nil
+		}
+
+		if disabled {
+			log.Printf("[HTTPS] Web SSL (www-ssl) listener stopped")
+			return
+		}
+
+		sslSvc, ok := servicesMgr.GetService("www-ssl")
+		if !ok || sslSvc.Certificate == "" {
+			log.Printf("[HTTPS] Cannot start www-ssl: no certificate configured (set certificate=<name>)")
+			return
+		}
+
+		tlsCert, err := certMgr.GetTLSCertificate(sslSvc.Certificate)
+		if err != nil {
+			log.Printf("[HTTPS] Cannot start www-ssl: failed loading certificate %q: %v", sslSvc.Certificate, err)
+			return
+		}
+
+		tlsConfig := &tls.Config{
+			Certificates: []tls.Certificate{tlsCert},
+			GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
+				curSvc, ok := servicesMgr.GetService("www-ssl")
+				if ok && curSvc.Certificate != "" {
+					if c, err := certMgr.GetTLSCertificate(curSvc.Certificate); err == nil {
+						return &c, nil
+					}
+				}
+				return &tlsCert, nil
+			},
+		}
+
+		listenAddr := fmt.Sprintf(":%d", port)
+		newSrv := &http.Server{
+			Addr:         listenAddr,
+			Handler:      srv,
+			TLSConfig:    tlsConfig,
+			ReadTimeout:  60 * time.Second,
+			WriteTimeout: 60 * time.Second,
+			IdleTimeout:  120 * time.Second,
+		}
+		httpsServer = newSrv
+		httpsRunning = true
+
+		go func() {
+			log.Printf("[HTTPS] Web SSL (www-ssl) server listening on %s with certificate %q", listenAddr, sslSvc.Certificate)
+			if err := newSrv.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed {
+				log.Printf("[HTTPS] Server error: %v", err)
+				httpsMu.Lock()
+				httpsRunning = false
+				httpsMu.Unlock()
+			}
+		}()
+	}
+
+	servicesMgr.RegisterHook("www-ssl", func(serviceName string, port int, disabled bool) error {
+		startOrRestartHTTPS(port, disabled)
+		return nil
+	})
+
+	if sslSvc, ok := servicesMgr.GetService("www-ssl"); ok && !sslSvc.Disabled {
+		startOrRestartHTTPS(sslSvc.Port, sslSvc.Disabled)
+	}
+	defer func() {
+		httpsMu.Lock()
+		if httpsRunning && httpsServer != nil {
+			_ = httpsServer.Close()
+		}
+		httpsMu.Unlock()
+	}()
+
 	httpServer := &http.Server{
 		Addr:         *addr,
 		Handler:      srv,

+ 17 - 1
internal/api/api.go

@@ -11,6 +11,7 @@ import (
 	"proxmox-router/internal/auth"
 	"proxmox-router/internal/bridge"
 	"proxmox-router/internal/bwtest"
+	"proxmox-router/internal/certs"
 	"proxmox-router/internal/dhcp"
 	"proxmox-router/internal/dns"
 	"proxmox-router/internal/dynamicrouting"
@@ -41,11 +42,12 @@ type Server struct {
 	dnsMgr        *dns.Manager
 	authMgr       *auth.Manager
 	servicesMgr   *services.Manager
+	certMgr       *certs.CertManager
 	mux           *http.ServeMux
 	fsHandler     http.Handler
 }
 
-func NewServer(netMgr *network.Manager, persistMgr *network.PersistenceManager, metaMgr *network.MetadataManager, vpnMgr *vpn.Manager, fwMgr *firewall.FirewallManager, routingMgr *routing.RoutingManager, dynRoutingMgr *dynamicrouting.Manager, bridgeMgr *bridge.BridgeManager, toolsMgr *tools.Manager, dhcpMgr *dhcp.Manager, dhcpServerMgr *dhcp.ServerManager, vrrpMgr *vrrp.Manager, bwtestMgr *bwtest.Manager, dnsMgr *dns.Manager, authMgr *auth.Manager, servicesMgr *services.Manager, fsHandler http.Handler) *Server {
+func NewServer(netMgr *network.Manager, persistMgr *network.PersistenceManager, metaMgr *network.MetadataManager, vpnMgr *vpn.Manager, fwMgr *firewall.FirewallManager, routingMgr *routing.RoutingManager, dynRoutingMgr *dynamicrouting.Manager, bridgeMgr *bridge.BridgeManager, toolsMgr *tools.Manager, dhcpMgr *dhcp.Manager, dhcpServerMgr *dhcp.ServerManager, vrrpMgr *vrrp.Manager, bwtestMgr *bwtest.Manager, dnsMgr *dns.Manager, authMgr *auth.Manager, servicesMgr *services.Manager, certMgr *certs.CertManager, fsHandler http.Handler) *Server {
 	s := &Server{
 		netMgr:        netMgr,
 		persistMgr:    persistMgr,
@@ -63,6 +65,7 @@ func NewServer(netMgr *network.Manager, persistMgr *network.PersistenceManager,
 		dnsMgr:        dnsMgr,
 		authMgr:       authMgr,
 		servicesMgr:   servicesMgr,
+		certMgr:       certMgr,
 		mux:           http.NewServeMux(),
 		fsHandler:     fsHandler,
 	}
@@ -425,6 +428,19 @@ func (s *Server) registerRoutes() {
 	s.mux.HandleFunc("PUT /api/system/services/{name}", s.handleUpdateService)
 	s.mux.HandleFunc("POST /api/system/services/{name}/state", s.handleSetServiceState)
 
+	// Certificate Management (/certificate)
+	s.mux.HandleFunc("GET /api/certificates", s.handleListCertificates)
+	s.mux.HandleFunc("POST /api/certificates/ca", s.handleCreateCA)
+	s.mux.HandleFunc("POST /api/certificates/generate", s.handleCreateCertificate)
+	s.mux.HandleFunc("POST /api/certificates/import", s.handleImportCertificate)
+	s.mux.HandleFunc("POST /api/certificates/letsencrypt", s.handleIssueLetsEncrypt)
+	s.mux.HandleFunc("GET /api/certificates/{name}", s.handleGetCertificate)
+	s.mux.HandleFunc("GET /api/certificates/{name}/export", s.handleExportCertificate)
+	s.mux.HandleFunc("DELETE /api/certificates/{name}", s.handleDeleteCertificate)
+
+	// ACME HTTP-01 Challenge responder for Let's Encrypt
+	s.mux.HandleFunc("GET /.well-known/acme-challenge/{token}", s.handleACMEChallenge)
+
 	// Logging (/log)
 	s.mux.HandleFunc("GET /api/system/logs", s.handleListLogs)
 

+ 175 - 0
internal/api/certs_handlers.go

@@ -0,0 +1,175 @@
+package api
+
+import (
+	"context"
+	"encoding/json"
+	"net/http"
+	"strings"
+	"time"
+
+	"proxmox-router/internal/certs"
+)
+
+func (s *Server) handleListCertificates(w http.ResponseWriter, r *http.Request) {
+	if s.certMgr == nil {
+		writeError(w, http.StatusServiceUnavailable, "certificates manager not configured")
+		return
+	}
+	list := s.certMgr.ListCertificates()
+	writeJSON(w, http.StatusOK, list)
+}
+
+func (s *Server) handleGetCertificate(w http.ResponseWriter, r *http.Request) {
+	if s.certMgr == nil {
+		writeError(w, http.StatusServiceUnavailable, "certificates manager not configured")
+		return
+	}
+	name := r.PathValue("name")
+	c, err := s.certMgr.GetCertificate(name)
+	if err != nil {
+		writeError(w, http.StatusNotFound, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, c)
+}
+
+func (s *Server) handleCreateCA(w http.ResponseWriter, r *http.Request) {
+	if s.certMgr == nil {
+		writeError(w, http.StatusServiceUnavailable, "certificates manager not configured")
+		return
+	}
+	var req certs.CreateCARequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		writeError(w, http.StatusBadRequest, "invalid request payload")
+		return
+	}
+
+	info, err := s.certMgr.CreateCA(req)
+	if err != nil {
+		writeError(w, http.StatusBadRequest, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusCreated, info)
+}
+
+func (s *Server) handleCreateCertificate(w http.ResponseWriter, r *http.Request) {
+	if s.certMgr == nil {
+		writeError(w, http.StatusServiceUnavailable, "certificates manager not configured")
+		return
+	}
+	var req certs.CreateCertRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		writeError(w, http.StatusBadRequest, "invalid request payload")
+		return
+	}
+
+	info, err := s.certMgr.CreateCertificate(req)
+	if err != nil {
+		writeError(w, http.StatusBadRequest, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusCreated, info)
+}
+
+func (s *Server) handleImportCertificate(w http.ResponseWriter, r *http.Request) {
+	if s.certMgr == nil {
+		writeError(w, http.StatusServiceUnavailable, "certificates manager not configured")
+		return
+	}
+	var req certs.ImportCertRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		writeError(w, http.StatusBadRequest, "invalid request payload")
+		return
+	}
+
+	info, err := s.certMgr.ImportCertificate(req)
+	if err != nil {
+		writeError(w, http.StatusBadRequest, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusCreated, info)
+}
+
+func (s *Server) handleIssueLetsEncrypt(w http.ResponseWriter, r *http.Request) {
+	if s.certMgr == nil {
+		writeError(w, http.StatusServiceUnavailable, "certificates manager not configured")
+		return
+	}
+	var req certs.LetsEncryptRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		writeError(w, http.StatusBadRequest, "invalid request payload")
+		return
+	}
+
+	ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute)
+	defer cancel()
+
+	info, err := s.certMgr.IssueLetsEncrypt(ctx, req)
+	if err != nil {
+		writeError(w, http.StatusBadRequest, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusCreated, info)
+}
+
+func (s *Server) handleExportCertificate(w http.ResponseWriter, r *http.Request) {
+	if s.certMgr == nil {
+		writeError(w, http.StatusServiceUnavailable, "certificates manager not configured")
+		return
+	}
+	name := r.PathValue("name")
+	certPEM, keyPEM, err := s.certMgr.ExportPEM(name)
+	if err != nil {
+		writeError(w, http.StatusNotFound, err.Error())
+		return
+	}
+
+	writeJSON(w, http.StatusOK, map[string]string{
+		"name":     name,
+		"cert_pem": certPEM,
+		"key_pem":  keyPEM,
+	})
+}
+
+func (s *Server) handleDeleteCertificate(w http.ResponseWriter, r *http.Request) {
+	if s.certMgr == nil {
+		writeError(w, http.StatusServiceUnavailable, "certificates manager not configured")
+		return
+	}
+	name := r.PathValue("name")
+
+	// Prevent deletion if in use by www-ssl
+	if s.servicesMgr != nil {
+		if sslSvc, ok := s.servicesMgr.GetService("www-ssl"); ok {
+			if strings.EqualFold(sslSvc.Certificate, name) && !sslSvc.Disabled {
+				writeError(w, http.StatusBadRequest, "cannot delete certificate: actively in use by enabled www-ssl service")
+				return
+			}
+		}
+	}
+
+	if err := s.certMgr.DeleteCertificate(name); err != nil {
+		writeError(w, http.StatusBadRequest, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]string{"message": "certificate deleted"})
+}
+
+func (s *Server) handleACMEChallenge(w http.ResponseWriter, r *http.Request) {
+	token := r.PathValue("token")
+	if token == "" {
+		token = strings.TrimPrefix(r.URL.Path, "/.well-known/acme-challenge/")
+	}
+	if s.certMgr == nil {
+		http.NotFound(w, r)
+		return
+	}
+	keyAuth, ok := s.certMgr.GetHTTP01Challenge(token)
+	if !ok {
+		http.NotFound(w, r)
+		return
+	}
+	w.Header().Set("Content-Type", "text/plain")
+	w.WriteHeader(http.StatusOK)
+	_, _ = w.Write([]byte(keyAuth))
+}

+ 5 - 4
internal/api/services_handlers.go

@@ -24,16 +24,17 @@ func (s *Server) handleUpdateService(w http.ResponseWriter, r *http.Request) {
 	}
 	name := r.PathValue("name")
 	var req struct {
-		Port     int    `json:"port"`
-		Disabled bool   `json:"disabled"`
-		Comment  string `json:"comment"`
+		Port        int    `json:"port"`
+		Disabled    bool   `json:"disabled"`
+		Certificate string `json:"certificate"`
+		Comment     string `json:"comment"`
 	}
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
 		writeError(w, http.StatusBadRequest, "invalid request body")
 		return
 	}
 
-	if err := s.servicesMgr.SetService(name, req.Port, req.Disabled); err != nil {
+	if err := s.servicesMgr.SetService(name, req.Port, req.Disabled, req.Certificate); err != nil {
 		writeError(w, http.StatusBadRequest, err.Error())
 		return
 	}

+ 933 - 0
internal/certs/manager.go

@@ -0,0 +1,933 @@
+package certs
+
+import (
+	"context"
+	"crypto"
+	"crypto/ecdsa"
+	"crypto/elliptic"
+	"crypto/rand"
+	"crypto/rsa"
+	"crypto/sha256"
+	"crypto/tls"
+	"crypto/x509"
+	"crypto/x509/pkix"
+	"encoding/hex"
+	"encoding/json"
+	"encoding/pem"
+	"errors"
+	"fmt"
+	"math/big"
+	"net"
+	"os"
+	"path/filepath"
+	"strings"
+	"sync"
+	"time"
+
+	"golang.org/x/crypto/acme"
+	"proxmox-router/internal/logging"
+)
+
+const (
+	DefaultCertsDir     = "/etc/proxmox-router/certs"
+	DefaultMetadataFile = "certs.json"
+
+	TypeCA     = "ca"
+	TypeServer = "server"
+	TypeClient = "client"
+	TypeACME   = "acme"
+
+	KeyRSA2048   = "RSA-2048"
+	KeyRSA4096   = "RSA-4096"
+	KeyECDSAP256 = "ECDSA-P256"
+)
+
+type CertificateInfo struct {
+	ID          string    `json:"id"`
+	Name        string    `json:"name"`
+	CommonName  string    `json:"common_name"`
+	SANs        []string  `json:"sans"`
+	IsCA        bool      `json:"is_ca"`
+	Issuer      string    `json:"issuer"`
+	KeyType     string    `json:"key_type"`
+	ValidFrom   time.Time `json:"valid_from"`
+	ValidTo     time.Time `json:"valid_to"`
+	Fingerprint string    `json:"fingerprint"`
+	CertPath    string    `json:"cert_path"`
+	KeyPath     string    `json:"key_path"`
+	Type        string    `json:"type"`
+	Comment     string    `json:"comment,omitempty"`
+}
+
+type CreateCARequest struct {
+	Name         string `json:"name"`
+	CommonName   string `json:"common_name"`
+	Organization string `json:"organization,omitempty"`
+	Country      string `json:"country,omitempty"`
+	Days         int    `json:"days,omitempty"`
+	KeyType      string `json:"key_type,omitempty"`
+	Comment      string `json:"comment,omitempty"`
+}
+
+type CreateCertRequest struct {
+	Name         string   `json:"name"`
+	CommonName   string   `json:"common_name"`
+	CAName       string   `json:"ca_name,omitempty"`
+	SANs         []string `json:"sans,omitempty"`
+	Days         int      `json:"days,omitempty"`
+	KeyType      string   `json:"key_type,omitempty"`
+	IsServer     bool     `json:"is_server"`
+	Organization string   `json:"organization,omitempty"`
+	Country      string   `json:"country,omitempty"`
+	Comment      string   `json:"comment,omitempty"`
+}
+
+type ImportCertRequest struct {
+	Name    string `json:"name"`
+	CertPEM string `json:"cert_pem"`
+	KeyPEM  string `json:"key_pem"`
+	Comment string `json:"comment,omitempty"`
+}
+
+type LetsEncryptRequest struct {
+	Name    string `json:"name"`
+	Domain  string `json:"domain"`
+	Email   string `json:"email"`
+	Staging bool   `json:"staging"`
+	Comment string `json:"comment,omitempty"`
+}
+
+type CertManager struct {
+	dir         string
+	metaPath    string
+	mu          sync.RWMutex
+	certs       map[string]*CertificateInfo
+	challenges  map[string]string // token -> keyAuth
+	challengeMu sync.RWMutex
+}
+
+func NewCertManager(customDir string) (*CertManager, error) {
+	d := customDir
+	if d == "" {
+		d = DefaultCertsDir
+	}
+	if err := os.MkdirAll(d, 0700); err != nil {
+		return nil, fmt.Errorf("failed to create certs directory %s: %w", d, err)
+	}
+
+	cm := &CertManager{
+		dir:        d,
+		metaPath:   filepath.Join(d, DefaultMetadataFile),
+		certs:      make(map[string]*CertificateInfo),
+		challenges: make(map[string]string),
+	}
+
+	if err := cm.load(); err != nil {
+		logging.Log("certs,warning", "Warning loading cert metadata: %v", err)
+	}
+
+	return cm, nil
+}
+
+func (cm *CertManager) load() error {
+	cm.mu.Lock()
+	defer cm.mu.Unlock()
+
+	data, err := os.ReadFile(cm.metaPath)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return nil
+		}
+		return err
+	}
+
+	var list []*CertificateInfo
+	if err := json.Unmarshal(data, &list); err != nil {
+		return err
+	}
+
+	for _, c := range list {
+		if c.Name != "" {
+			cm.certs[strings.ToLower(c.Name)] = c
+		}
+	}
+	return nil
+}
+
+func (cm *CertManager) saveLocked() error {
+	var list []*CertificateInfo
+	for _, c := range cm.certs {
+		list = append(list, c)
+	}
+
+	data, err := json.MarshalIndent(list, "", "  ")
+	if err != nil {
+		return err
+	}
+
+	tmp := fmt.Sprintf("%s.tmp.%d", cm.metaPath, time.Now().UnixNano())
+	if err := os.WriteFile(tmp, data, 0600); err != nil {
+		return err
+	}
+	return os.Rename(tmp, cm.metaPath)
+}
+
+// RegisterHTTP01Challenge stores an ACME challenge token and its response for HTTP-01 verification.
+func (cm *CertManager) RegisterHTTP01Challenge(token, keyAuth string) {
+	cm.challengeMu.Lock()
+	defer cm.challengeMu.Unlock()
+	cm.challenges[token] = keyAuth
+}
+
+// UnregisterHTTP01Challenge removes an ACME challenge token.
+func (cm *CertManager) UnregisterHTTP01Challenge(token string) {
+	cm.challengeMu.Lock()
+	defer cm.challengeMu.Unlock()
+	delete(cm.challenges, token)
+}
+
+// GetHTTP01Challenge looks up an ACME challenge token for HTTP-01 verification.
+func (cm *CertManager) GetHTTP01Challenge(token string) (string, bool) {
+	cm.challengeMu.RLock()
+	defer cm.challengeMu.RUnlock()
+	auth, ok := cm.challenges[token]
+	return auth, ok
+}
+
+// ListCertificates returns all certificates known to the manager.
+func (cm *CertManager) ListCertificates() []*CertificateInfo {
+	cm.mu.RLock()
+	defer cm.mu.RUnlock()
+
+	var result []*CertificateInfo
+	for _, c := range cm.certs {
+		copyItem := *c
+		result = append(result, &copyItem)
+	}
+	return result
+}
+
+// GetCertificate returns metadata for a certificate by name.
+func (cm *CertManager) GetCertificate(name string) (*CertificateInfo, error) {
+	cm.mu.RLock()
+	defer cm.mu.RUnlock()
+
+	c, ok := cm.certs[strings.ToLower(name)]
+	if !ok {
+		return nil, fmt.Errorf("certificate %q not found", name)
+	}
+	copyItem := *c
+	return &copyItem, nil
+}
+
+// GetTLSCertificate loads a parsed tls.Certificate ready for TLS listeners.
+func (cm *CertManager) GetTLSCertificate(name string) (tls.Certificate, error) {
+	cm.mu.RLock()
+	c, ok := cm.certs[strings.ToLower(name)]
+	cm.mu.RUnlock()
+
+	if !ok {
+		return tls.Certificate{}, fmt.Errorf("certificate %q not found", name)
+	}
+
+	if _, err := os.Stat(c.CertPath); err != nil {
+		return tls.Certificate{}, fmt.Errorf("certificate file missing: %w", err)
+	}
+	if _, err := os.Stat(c.KeyPath); err != nil {
+		return tls.Certificate{}, fmt.Errorf("private key file missing: %w", err)
+	}
+
+	return tls.LoadX509KeyPair(c.CertPath, c.KeyPath)
+}
+
+// ExportPEM returns the PEM encoded certificate and private key content.
+func (cm *CertManager) ExportPEM(name string) (certPEM string, keyPEM string, err error) {
+	cm.mu.RLock()
+	c, ok := cm.certs[strings.ToLower(name)]
+	cm.mu.RUnlock()
+
+	if !ok {
+		return "", "", fmt.Errorf("certificate %q not found", name)
+	}
+
+	certBytes, err := os.ReadFile(c.CertPath)
+	if err != nil {
+		return "", "", fmt.Errorf("failed to read cert file: %w", err)
+	}
+
+	keyBytes, err := os.ReadFile(c.KeyPath)
+	if err != nil {
+		return "", "", fmt.Errorf("failed to read key file: %w", err)
+	}
+
+	return string(certBytes), string(keyBytes), nil
+}
+
+// DeleteCertificate removes a certificate, its files, and metadata.
+func (cm *CertManager) DeleteCertificate(name string) error {
+	cm.mu.Lock()
+	defer cm.mu.Unlock()
+
+	key := strings.ToLower(name)
+	c, ok := cm.certs[key]
+	if !ok {
+		return fmt.Errorf("certificate %q not found", name)
+	}
+
+	_ = os.Remove(c.CertPath)
+	_ = os.Remove(c.KeyPath)
+	delete(cm.certs, key)
+
+	if err := cm.saveLocked(); err != nil {
+		return err
+	}
+
+	logging.Log("certs,info", "Certificate %s deleted", name)
+	return nil
+}
+
+// CreateCA creates a new Self-Signed Certificate Authority (CA).
+func (cm *CertManager) CreateCA(req CreateCARequest) (*CertificateInfo, error) {
+	name := strings.TrimSpace(req.Name)
+	if name == "" {
+		return nil, errors.New("certificate name is required")
+	}
+	cn := strings.TrimSpace(req.CommonName)
+	if cn == "" {
+		cn = name
+	}
+
+	cm.mu.Lock()
+	defer cm.mu.Unlock()
+
+	if _, exists := cm.certs[strings.ToLower(name)]; exists {
+		return nil, fmt.Errorf("certificate %q already exists", name)
+	}
+
+	days := req.Days
+	if days <= 0 {
+		days = 3650 // 10 years default for CA
+	}
+
+	keyType := req.KeyType
+	if keyType == "" {
+		keyType = KeyRSA2048
+	}
+
+	privKey, pubKey, err := generateKeyPair(keyType)
+	if err != nil {
+		return nil, fmt.Errorf("failed generating key: %w", err)
+	}
+
+	serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
+	if err != nil {
+		return nil, fmt.Errorf("failed generating serial: %w", err)
+	}
+
+	org := req.Organization
+	if org == "" {
+		org = "DebTik RouterOS Appliance"
+	}
+	country := req.Country
+	if country == "" {
+		country = "GR"
+	}
+
+	now := time.Now().UTC()
+	template := x509.Certificate{
+		SerialNumber: serialNumber,
+		Subject: pkix.Name{
+			CommonName:   cn,
+			Organization: []string{org},
+			Country:      []string{country},
+		},
+		NotBefore:             now.Add(-1 * time.Hour),
+		NotAfter:              now.AddDate(0, 0, days),
+		IsCA:                  true,
+		KeyUsage:              x509.KeyUsageCertSign | x509.KeyUsageCRLSign | x509.KeyUsageDigitalSignature,
+		BasicConstraintsValid: true,
+		MaxPathLenZero:        false,
+	}
+
+	certBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, pubKey, privKey)
+	if err != nil {
+		return nil, fmt.Errorf("failed creating CA certificate: %w", err)
+	}
+
+	certPath := filepath.Join(cm.dir, fmt.Sprintf("%s.crt", name))
+	keyPath := filepath.Join(cm.dir, fmt.Sprintf("%s.key", name))
+
+	if err := savePEM(certPath, "CERTIFICATE", certBytes); err != nil {
+		return nil, err
+	}
+	if err := savePrivateKeyPEM(keyPath, privKey); err != nil {
+		_ = os.Remove(certPath)
+		return nil, err
+	}
+
+	fp := sha256.Sum256(certBytes)
+	info := &CertificateInfo{
+		ID:          fmt.Sprintf("cert-%d", time.Now().UnixNano()),
+		Name:        name,
+		CommonName:  cn,
+		IsCA:        true,
+		Issuer:      "Self-Signed CA",
+		KeyType:     keyType,
+		ValidFrom:   template.NotBefore,
+		ValidTo:     template.NotAfter,
+		Fingerprint: formatFingerprint(fp[:]),
+		CertPath:    certPath,
+		KeyPath:     keyPath,
+		Type:        TypeCA,
+		Comment:     req.Comment,
+	}
+
+	cm.certs[strings.ToLower(name)] = info
+	if err := cm.saveLocked(); err != nil {
+		return nil, err
+	}
+
+	logging.Log("certs,info", "Created new Certificate Authority: %s (CN: %s)", name, cn)
+	return info, nil
+}
+
+// CreateCertificate issues a server or client certificate signed by a CA or self-signed.
+func (cm *CertManager) CreateCertificate(req CreateCertRequest) (*CertificateInfo, error) {
+	name := strings.TrimSpace(req.Name)
+	if name == "" {
+		return nil, errors.New("certificate name is required")
+	}
+	cn := strings.TrimSpace(req.CommonName)
+	if cn == "" {
+		return nil, errors.New("common name is required")
+	}
+
+	cm.mu.Lock()
+	defer cm.mu.Unlock()
+
+	if _, exists := cm.certs[strings.ToLower(name)]; exists {
+		return nil, fmt.Errorf("certificate %q already exists", name)
+	}
+
+	days := req.Days
+	if days <= 0 {
+		days = 365 // 1 year default
+	}
+
+	keyType := req.KeyType
+	if keyType == "" {
+		keyType = KeyRSA2048
+	}
+
+	privKey, pubKey, err := generateKeyPair(keyType)
+	if err != nil {
+		return nil, fmt.Errorf("failed generating key: %w", err)
+	}
+
+	serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
+	if err != nil {
+		return nil, fmt.Errorf("failed generating serial: %w", err)
+	}
+
+	org := req.Organization
+	if org == "" {
+		org = "DebTik RouterOS Appliance"
+	}
+	country := req.Country
+	if country == "" {
+		country = "GR"
+	}
+
+	now := time.Now().UTC()
+	template := x509.Certificate{
+		SerialNumber: serialNumber,
+		Subject: pkix.Name{
+			CommonName:   cn,
+			Organization: []string{org},
+			Country:      []string{country},
+		},
+		NotBefore:             now.Add(-1 * time.Hour),
+		NotAfter:              now.AddDate(0, 0, days),
+		KeyUsage:              x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
+		BasicConstraintsValid: true,
+		IsCA:                  false,
+	}
+
+	if req.IsServer {
+		template.ExtKeyUsage = append(template.ExtKeyUsage, x509.ExtKeyUsageServerAuth)
+	} else {
+		template.ExtKeyUsage = append(template.ExtKeyUsage, x509.ExtKeyUsageClientAuth)
+	}
+
+	// Process SANs (DNS names & IP addresses)
+	sansMap := make(map[string]bool)
+	if ip := net.ParseIP(cn); ip != nil {
+		template.IPAddresses = append(template.IPAddresses, ip)
+		sansMap[cn] = true
+	} else if strings.Contains(cn, ".") || cn != "" {
+		template.DNSNames = append(template.DNSNames, cn)
+		sansMap[cn] = true
+	}
+
+	for _, s := range req.SANs {
+		s = strings.TrimSpace(s)
+		if s == "" || sansMap[s] {
+			continue
+		}
+		sansMap[s] = true
+		if ip := net.ParseIP(s); ip != nil {
+			template.IPAddresses = append(template.IPAddresses, ip)
+		} else {
+			template.DNSNames = append(template.DNSNames, s)
+		}
+	}
+
+	var sansList []string
+	for s := range sansMap {
+		sansList = append(sansList, s)
+	}
+
+	var signerCert *x509.Certificate
+	var signerKey crypto.PrivateKey
+	issuerName := "Self-Signed"
+	var caPEMBytes []byte
+
+	if req.CAName != "" {
+		caInfo, ok := cm.certs[strings.ToLower(req.CAName)]
+		if !ok || !caInfo.IsCA {
+			return nil, fmt.Errorf("signing CA %q not found or is not a CA", req.CAName)
+		}
+		caCert, caKey, err := loadCertAndKey(caInfo.CertPath, caInfo.KeyPath)
+		if err != nil {
+			return nil, fmt.Errorf("failed loading CA cert/key: %w", err)
+		}
+		signerCert = caCert
+		signerKey = caKey
+		issuerName = caInfo.Name
+		caPEMBytes, _ = os.ReadFile(caInfo.CertPath)
+	} else {
+		signerCert = &template
+		signerKey = privKey
+	}
+
+	certBytes, err := x509.CreateCertificate(rand.Reader, &template, signerCert, pubKey, signerKey)
+	if err != nil {
+		return nil, fmt.Errorf("failed creating certificate: %w", err)
+	}
+
+	certPath := filepath.Join(cm.dir, fmt.Sprintf("%s.crt", name))
+	keyPath := filepath.Join(cm.dir, fmt.Sprintf("%s.key", name))
+
+	// If signed by CA, build chain: [Cert] + [CA Cert]
+	certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certBytes})
+	if len(caPEMBytes) > 0 {
+		certPEM = append(certPEM, caPEMBytes...)
+	}
+	if err := os.WriteFile(certPath, certPEM, 0644); err != nil {
+		return nil, err
+	}
+
+	if err := savePrivateKeyPEM(keyPath, privKey); err != nil {
+		_ = os.Remove(certPath)
+		return nil, err
+	}
+
+	fp := sha256.Sum256(certBytes)
+	info := &CertificateInfo{
+		ID:          fmt.Sprintf("cert-%d", time.Now().UnixNano()),
+		Name:        name,
+		CommonName:  cn,
+		SANs:        sansList,
+		IsCA:        false,
+		Issuer:      issuerName,
+		KeyType:     keyType,
+		ValidFrom:   template.NotBefore,
+		ValidTo:     template.NotAfter,
+		Fingerprint: formatFingerprint(fp[:]),
+		CertPath:    certPath,
+		KeyPath:     keyPath,
+		Type:        TypeServer,
+		Comment:     req.Comment,
+	}
+	if !req.IsServer {
+		info.Type = TypeClient
+	}
+
+	cm.certs[strings.ToLower(name)] = info
+	if err := cm.saveLocked(); err != nil {
+		return nil, err
+	}
+
+	logging.Log("certs,info", "Created new certificate %s (Issuer: %s, CN: %s)", name, issuerName, cn)
+	return info, nil
+}
+
+// ImportCertificate imports user-provided PEM certificate and private key.
+func (cm *CertManager) ImportCertificate(req ImportCertRequest) (*CertificateInfo, error) {
+	name := strings.TrimSpace(req.Name)
+	if name == "" {
+		return nil, errors.New("certificate name is required")
+	}
+
+	certBlock, _ := pem.Decode([]byte(req.CertPEM))
+	if certBlock == nil || certBlock.Type != "CERTIFICATE" {
+		return nil, errors.New("invalid or empty PEM certificate")
+	}
+
+	parsedCert, err := x509.ParseCertificate(certBlock.Bytes)
+	if err != nil {
+		return nil, fmt.Errorf("failed to parse certificate: %w", err)
+	}
+
+	// Verify key matches certificate
+	_, err = tls.X509KeyPair([]byte(req.CertPEM), []byte(req.KeyPEM))
+	if err != nil {
+		return nil, fmt.Errorf("private key does not match certificate: %w", err)
+	}
+
+	cm.mu.Lock()
+	defer cm.mu.Unlock()
+
+	if _, exists := cm.certs[strings.ToLower(name)]; exists {
+		return nil, fmt.Errorf("certificate %q already exists", name)
+	}
+
+	certPath := filepath.Join(cm.dir, fmt.Sprintf("%s.crt", name))
+	keyPath := filepath.Join(cm.dir, fmt.Sprintf("%s.key", name))
+
+	if err := os.WriteFile(certPath, []byte(strings.TrimSpace(req.CertPEM)+"\n"), 0644); err != nil {
+		return nil, err
+	}
+	if err := os.WriteFile(keyPath, []byte(strings.TrimSpace(req.KeyPEM)+"\n"), 0600); err != nil {
+		_ = os.Remove(certPath)
+		return nil, err
+	}
+
+	var sans []string
+	for _, dns := range parsedCert.DNSNames {
+		sans = append(sans, dns)
+	}
+	for _, ip := range parsedCert.IPAddresses {
+		sans = append(sans, ip.String())
+	}
+
+	fp := sha256.Sum256(parsedCert.Raw)
+	cType := TypeServer
+	if parsedCert.IsCA {
+		cType = TypeCA
+	}
+
+	keyType := "Unknown"
+	switch parsedCert.PublicKeyAlgorithm {
+	case x509.RSA:
+		keyType = "RSA"
+	case x509.ECDSA:
+		keyType = "ECDSA"
+	}
+
+	info := &CertificateInfo{
+		ID:          fmt.Sprintf("cert-%d", time.Now().UnixNano()),
+		Name:        name,
+		CommonName:  parsedCert.Subject.CommonName,
+		SANs:        sans,
+		IsCA:        parsedCert.IsCA,
+		Issuer:      parsedCert.Issuer.CommonName,
+		KeyType:     keyType,
+		ValidFrom:   parsedCert.NotBefore,
+		ValidTo:     parsedCert.NotAfter,
+		Fingerprint: formatFingerprint(fp[:]),
+		CertPath:    certPath,
+		KeyPath:     keyPath,
+		Type:        cType,
+		Comment:     req.Comment,
+	}
+
+	cm.certs[strings.ToLower(name)] = info
+	if err := cm.saveLocked(); err != nil {
+		return nil, err
+	}
+
+	logging.Log("certs,info", "Imported certificate: %s (CN: %s)", name, info.CommonName)
+	return info, nil
+}
+
+// IssueLetsEncrypt requests and issues a certificate from Let's Encrypt via ACME HTTP-01 challenge.
+func (cm *CertManager) IssueLetsEncrypt(ctx context.Context, req LetsEncryptRequest) (*CertificateInfo, error) {
+	name := strings.TrimSpace(req.Name)
+	domain := strings.TrimSpace(req.Domain)
+	email := strings.TrimSpace(req.Email)
+
+	if domain == "" {
+		return nil, errors.New("domain is required for Let's Encrypt")
+	}
+	if name == "" {
+		name = domain
+	}
+
+	cm.mu.Lock()
+	if _, exists := cm.certs[strings.ToLower(name)]; exists {
+		cm.mu.Unlock()
+		return nil, fmt.Errorf("certificate %q already exists", name)
+	}
+	cm.mu.Unlock()
+
+	logging.Log("certs,info", "Starting Let's Encrypt order for domain %s (Staging=%t)", domain, req.Staging)
+
+	// Generate ACME account key
+	accountKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+	if err != nil {
+		return nil, fmt.Errorf("failed generating ACME account key: %w", err)
+	}
+
+	client := &acme.Client{
+		Key: accountKey,
+	}
+	if req.Staging {
+		client.DirectoryURL = "https://acme-staging-v02.api.letsencrypt.org/directory"
+	} else {
+		client.DirectoryURL = acme.LetsEncryptURL
+	}
+
+	// Register account
+	account := &acme.Account{}
+	if email != "" {
+		account.Contact = []string{"mailto:" + email}
+	}
+	_, err = client.Register(ctx, account, acme.AcceptTOS)
+	if err != nil && !strings.Contains(err.Error(), "already exists") {
+		return nil, fmt.Errorf("failed registering ACME account: %w", err)
+	}
+
+	// Create order for domain
+	order, err := client.AuthorizeOrder(ctx, acme.DomainIDs(domain))
+	if err != nil {
+		return nil, fmt.Errorf("failed requesting order for %s: %w", domain, err)
+	}
+
+	// Complete authorization for each authorization URL
+	for _, authzURL := range order.AuthzURLs {
+		authz, err := client.GetAuthorization(ctx, authzURL)
+		if err != nil {
+			return nil, fmt.Errorf("failed getting authorization %s: %w", authzURL, err)
+		}
+
+		if authz.Status == acme.StatusValid {
+			continue
+		}
+
+		var challenge *acme.Challenge
+		for _, ch := range authz.Challenges {
+			if ch.Type == "http-01" {
+				challenge = ch
+				break
+			}
+		}
+		if challenge == nil {
+			return nil, fmt.Errorf("no http-01 challenge available for domain %s", domain)
+		}
+
+		keyAuth, err := client.HTTP01ChallengeResponse(challenge.Token)
+		if err != nil {
+			return nil, fmt.Errorf("failed generating challenge response: %w", err)
+		}
+
+		// Register in our web server challenge map
+		cm.RegisterHTTP01Challenge(challenge.Token, keyAuth)
+		defer cm.UnregisterHTTP01Challenge(challenge.Token)
+
+		// Inform ACME server challenge is ready
+		if _, err := client.Accept(ctx, challenge); err != nil {
+			return nil, fmt.Errorf("failed accepting challenge: %w", err)
+		}
+
+		// Wait for authorization to succeed
+		if _, err := client.WaitAuthorization(ctx, challenge.URI); err != nil {
+			return nil, fmt.Errorf("ACME authorization failed for %s: %w", domain, err)
+		}
+	}
+
+	// Generate certificate private key
+	certPrivKey, err := rsa.GenerateKey(rand.Reader, 2048)
+	if err != nil {
+		return nil, fmt.Errorf("failed generating certificate key: %w", err)
+	}
+
+	csrTemplate := x509.CertificateRequest{
+		Subject:  pkix.Name{CommonName: domain},
+		DNSNames: []string{domain},
+	}
+	csr, err := x509.CreateCertificateRequest(rand.Reader, &csrTemplate, certPrivKey)
+	if err != nil {
+		return nil, fmt.Errorf("failed creating CSR: %w", err)
+	}
+
+	// Finalize order
+	certDerList, certURL, err := client.CreateOrderCert(ctx, order.FinalizeURL, csr, true)
+	if err != nil {
+		return nil, fmt.Errorf("failed finalizing ACME order: %w", err)
+	}
+	if len(certDerList) == 0 && certURL != "" {
+		certDerList, err = client.FetchCert(ctx, certURL, true)
+		if err != nil {
+			return nil, fmt.Errorf("failed fetching issued cert: %w", err)
+		}
+	}
+
+	// Build certificate chain in PEM format
+	var certPEM []byte
+	var firstCert *x509.Certificate
+	for idx, der := range certDerList {
+		certPEM = append(certPEM, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})...)
+		if idx == 0 {
+			firstCert, _ = x509.ParseCertificate(der)
+		}
+	}
+
+	certPath := filepath.Join(cm.dir, fmt.Sprintf("%s.crt", name))
+	keyPath := filepath.Join(cm.dir, fmt.Sprintf("%s.key", name))
+
+	if err := os.WriteFile(certPath, certPEM, 0644); err != nil {
+		return nil, err
+	}
+	if err := savePrivateKeyPEM(keyPath, certPrivKey); err != nil {
+		_ = os.Remove(certPath)
+		return nil, err
+	}
+
+	cm.mu.Lock()
+	defer cm.mu.Unlock()
+
+	var fp [32]byte
+	validFrom := time.Now()
+	validTo := time.Now().AddDate(0, 0, 90)
+	if firstCert != nil {
+		fp = sha256.Sum256(firstCert.Raw)
+		validFrom = firstCert.NotBefore
+		validTo = firstCert.NotAfter
+	}
+
+	issuer := "Let's Encrypt"
+	if req.Staging {
+		issuer = "Let's Encrypt (Staging)"
+	}
+
+	info := &CertificateInfo{
+		ID:          fmt.Sprintf("cert-%d", time.Now().UnixNano()),
+		Name:        name,
+		CommonName:  domain,
+		SANs:        []string{domain},
+		IsCA:        false,
+		Issuer:      issuer,
+		KeyType:     KeyRSA2048,
+		ValidFrom:   validFrom,
+		ValidTo:     validTo,
+		Fingerprint: formatFingerprint(fp[:]),
+		CertPath:    certPath,
+		KeyPath:     keyPath,
+		Type:        TypeACME,
+		Comment:     req.Comment,
+	}
+
+	cm.certs[strings.ToLower(name)] = info
+	if err := cm.saveLocked(); err != nil {
+		return nil, err
+	}
+
+	logging.Log("certs,info", "Successfully issued Let's Encrypt certificate for %s", domain)
+	return info, nil
+}
+
+// Helpers
+func generateKeyPair(keyType string) (crypto.PrivateKey, crypto.PublicKey, error) {
+	switch strings.ToUpper(keyType) {
+	case KeyRSA4096:
+		priv, err := rsa.GenerateKey(rand.Reader, 4096)
+		if err != nil {
+			return nil, nil, err
+		}
+		return priv, &priv.PublicKey, nil
+	case KeyECDSAP256:
+		priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+		if err != nil {
+			return nil, nil, err
+		}
+		return priv, &priv.PublicKey, nil
+	default: // KeyRSA2048
+		priv, err := rsa.GenerateKey(rand.Reader, 2048)
+		if err != nil {
+			return nil, nil, err
+		}
+		return priv, &priv.PublicKey, nil
+	}
+}
+
+func savePEM(path, blockType string, data []byte) error {
+	block := &pem.Block{Type: blockType, Bytes: data}
+	return os.WriteFile(path, pem.EncodeToMemory(block), 0644)
+}
+
+func savePrivateKeyPEM(path string, key crypto.PrivateKey) error {
+	var block *pem.Block
+	switch k := key.(type) {
+	case *rsa.PrivateKey:
+		block = &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k)}
+	case *ecdsa.PrivateKey:
+		b, err := x509.MarshalECPrivateKey(k)
+		if err != nil {
+			return err
+		}
+		block = &pem.Block{Type: "EC PRIVATE KEY", Bytes: b}
+	default:
+		b, err := x509.MarshalPKCS8PrivateKey(key)
+		if err != nil {
+			return err
+		}
+		block = &pem.Block{Type: "PRIVATE KEY", Bytes: b}
+	}
+	return os.WriteFile(path, pem.EncodeToMemory(block), 0600)
+}
+
+func loadCertAndKey(certPath, keyPath string) (*x509.Certificate, crypto.PrivateKey, error) {
+	certPEM, err := os.ReadFile(certPath)
+	if err != nil {
+		return nil, nil, err
+	}
+	block, _ := pem.Decode(certPEM)
+	if block == nil {
+		return nil, nil, errors.New("failed decoding certificate PEM")
+	}
+	cert, err := x509.ParseCertificate(block.Bytes)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	keyPEM, err := os.ReadFile(keyPath)
+	if err != nil {
+		return nil, nil, err
+	}
+	keyBlock, _ := pem.Decode(keyPEM)
+	if keyBlock == nil {
+		return nil, nil, errors.New("failed decoding private key PEM")
+	}
+
+	if k, err := x509.ParsePKCS1PrivateKey(keyBlock.Bytes); err == nil {
+		return cert, k, nil
+	}
+	if k, err := x509.ParseECPrivateKey(keyBlock.Bytes); err == nil {
+		return cert, k, nil
+	}
+	if k, err := x509.ParsePKCS8PrivateKey(keyBlock.Bytes); err == nil {
+		return cert, k, nil
+	}
+
+	return nil, nil, errors.New("unrecognized private key format")
+}
+
+func formatFingerprint(b []byte) string {
+	hexStr := hex.EncodeToString(b)
+	var parts []string
+	for i := 0; i < len(hexStr); i += 2 {
+		parts = append(parts, strings.ToUpper(hexStr[i:i+2]))
+	}
+	return strings.Join(parts, ":")
+}

+ 144 - 0
internal/certs/manager_test.go

@@ -0,0 +1,144 @@
+package certs
+
+import (
+	"crypto/x509"
+	"os"
+	"testing"
+)
+
+func TestCertManagerCAAndCert(t *testing.T) {
+	tmpDir, err := os.MkdirTemp("", "certs-test-*")
+	if err != nil {
+		t.Fatalf("failed creating temp dir: %v", err)
+	}
+	defer os.RemoveAll(tmpDir)
+
+	cm, err := NewCertManager(tmpDir)
+	if err != nil {
+		t.Fatalf("NewCertManager failed: %v", err)
+	}
+
+	// 1. Create CA
+	caInfo, err := cm.CreateCA(CreateCARequest{
+		Name:         "root-ca",
+		CommonName:   "DebTik Test Root CA",
+		Organization: "Test Org",
+		Days:         365,
+	})
+	if err != nil {
+		t.Fatalf("CreateCA failed: %v", err)
+	}
+	if !caInfo.IsCA || caInfo.Name != "root-ca" {
+		t.Fatalf("Unexpected CA info: %+v", caInfo)
+	}
+
+	// 2. Issue server cert signed by root-ca
+	certInfo, err := cm.CreateCertificate(CreateCertRequest{
+		Name:       "web-cert",
+		CommonName: "router.lan",
+		CAName:     "root-ca",
+		SANs:       []string{"127.0.0.1", "192.168.1.1", "router.local"},
+		Days:       90,
+		IsServer:   true,
+	})
+	if err != nil {
+		t.Fatalf("CreateCertificate failed: %v", err)
+	}
+	if certInfo.IsCA || certInfo.Issuer != "root-ca" {
+		t.Fatalf("Unexpected cert info: %+v", certInfo)
+	}
+
+	// 3. Verify TLS KeyPair loading
+	tlsCert, err := cm.GetTLSCertificate("web-cert")
+	if err != nil {
+		t.Fatalf("GetTLSCertificate failed: %v", err)
+	}
+	if len(tlsCert.Certificate) < 2 {
+		t.Fatalf("Expected cert chain of at least 2 certs (cert + CA), got %d", len(tlsCert.Certificate))
+	}
+
+	// Verify x509 chain against root-ca
+	caPEM, _, err := cm.ExportPEM("root-ca")
+	if err != nil {
+		t.Fatalf("ExportPEM CA failed: %v", err)
+	}
+	roots := x509.NewCertPool()
+	if !roots.AppendCertsFromPEM([]byte(caPEM)) {
+		t.Fatalf("Failed to append CA PEM to pool")
+	}
+
+	serverCert, err := x509.ParseCertificate(tlsCert.Certificate[0])
+	if err != nil {
+		t.Fatalf("Parse server cert failed: %v", err)
+	}
+	opts := x509.VerifyOptions{
+		Roots:   roots,
+		DNSName: "router.lan",
+	}
+	if _, err := serverCert.Verify(opts); err != nil {
+		t.Fatalf("Cert verification failed: %v", err)
+	}
+
+	// 4. Test Export PEM
+	certPEM, keyPEM, err := cm.ExportPEM("web-cert")
+	if err != nil {
+		t.Fatalf("ExportPEM web-cert failed: %v", err)
+	}
+	if len(certPEM) == 0 || len(keyPEM) == 0 {
+		t.Fatalf("ExportPEM returned empty")
+	}
+
+	// 5. Test Import
+	importInfo, err := cm.ImportCertificate(ImportCertRequest{
+		Name:    "imported-cert",
+		CertPEM: certPEM,
+		KeyPEM:  keyPEM,
+		Comment: "imported test",
+	})
+	if err != nil {
+		t.Fatalf("ImportCertificate failed: %v", err)
+	}
+	if importInfo.CommonName != "router.lan" {
+		t.Fatalf("Imported cert common name mismatch: %s", importInfo.CommonName)
+	}
+
+	// 6. Test Delete
+	if err := cm.DeleteCertificate("imported-cert"); err != nil {
+		t.Fatalf("DeleteCertificate failed: %v", err)
+	}
+	if _, err := cm.GetCertificate("imported-cert"); err == nil {
+		t.Fatalf("Expected error getting deleted certificate")
+	}
+}
+
+func TestCertManagerPersistence(t *testing.T) {
+	tmpDir, err := os.MkdirTemp("", "certs-persist-*")
+	if err != nil {
+		t.Fatalf("failed creating temp dir: %v", err)
+	}
+	defer os.RemoveAll(tmpDir)
+
+	cm1, err := NewCertManager(tmpDir)
+	if err != nil {
+		t.Fatalf("NewCertManager failed: %v", err)
+	}
+
+	_, err = cm1.CreateCA(CreateCARequest{Name: "persisted-ca", CommonName: "Persisted CA"})
+	if err != nil {
+		t.Fatalf("CreateCA failed: %v", err)
+	}
+
+	// New manager with same dir should reload metadata
+	cm2, err := NewCertManager(tmpDir)
+	if err != nil {
+		t.Fatalf("Reload NewCertManager failed: %v", err)
+	}
+
+	c, err := cm2.GetCertificate("persisted-ca")
+	if err != nil {
+		t.Fatalf("Failed to retrieve persisted CA: %v", err)
+	}
+	if c.Name != "persisted-ca" || !c.IsCA {
+		t.Fatalf("Persisted CA mismatch: %+v", c)
+	}
+}

+ 56 - 4
internal/cli/completer.go

@@ -345,12 +345,12 @@ func colorizeCandidate(s string) string {
 	switch s {
 	case "print", "add", "remove", "enable", "disable", "set", "reboot", "flush", "renew", "release", "connect", "disconnect":
 		return ColorGreen + s + ColorReset
-	case "interface", "ip", "routing", "tool", "log", "user", "system",
+	case "interface", "ip", "routing", "tool", "log", "user", "system", "certificate", "certificates",
 		"vlan", "bridge", "wireguard", "eoip", "vrrp", "pppoe-client",
 		"address", "route", "dhcp-client", "dhcp-server", "dns", "firewall", "service",
 		"filter", "nat", "mangle", "address-list", "bgp", "ospf", "port", "peers", "lease", "network", "cache", "static", "rule", "table", "instance", "area", "neighbor", "resource", "identity":
 		return ColorCyan + s + ColorReset
-	case "www", "ssh", "telnet", "web":
+	case "www", "www-ssl", "ssh", "telnet", "web", "web-ssl":
 		return ColorMagenta + s + ColorReset
 	default:
 		return ColorWhite + s + ColorReset
@@ -413,7 +413,32 @@ func (c *Completer) buildCommandTree() *Node {
 			}
 			return names
 		}
-		return []string{"www", "telnet", "ssh"}
+		return []string{"www", "www-ssl", "telnet", "ssh"}
+	}
+
+	// Helper for certificates
+	dynCertificates := func() []string {
+		if c.executor != nil && c.executor.certMgr != nil {
+			var names []string
+			for _, cert := range c.executor.certMgr.ListCertificates() {
+				names = append(names, cert.Name)
+			}
+			return names
+		}
+		return nil
+	}
+
+	dynCAs := func() []string {
+		if c.executor != nil && c.executor.certMgr != nil {
+			var names []string
+			for _, cert := range c.executor.certMgr.ListCertificates() {
+				if cert.IsCA {
+					names = append(names, cert.Name)
+				}
+			}
+			return names
+		}
+		return nil
 	}
 
 	// Common standard actions
@@ -675,6 +700,7 @@ func (c *Completer) buildCommandTree() *Node {
 	serviceSet := serviceNode.AddChild(&Node{Name: "set", Dynamic: dynServices})
 	serviceSet.AddChild(&Node{Name: "port="})
 	serviceSet.AddChild(&Node{Name: "disabled=", Dynamic: func() []string { return []string{"yes", "no"} }})
+	serviceSet.AddChild(&Node{Name: "certificate=", Dynamic: dynCertificates})
 
 	// 3. /routing
 	routingNode := root.AddChild(&Node{Name: "routing"})
@@ -796,7 +822,33 @@ func (c *Completer) buildCommandTree() *Node {
 
 	sysNode.AddChild(&Node{Name: "reboot"})
 
-	// 8. Direct root utility commands
+	// 8. /certificate
+	certNode := root.AddChild(&Node{Name: "certificate", Aliases: []string{"certificates", "cert"}})
+	addPrint(certNode)
+	certNode.AddChild(&Node{Name: "remove", Aliases: []string{"rem", "delete"}, Dynamic: dynCertificates})
+	certNode.AddChild(&Node{Name: "export", Dynamic: dynCertificates})
+
+	certAddCA := certNode.AddChild(&Node{Name: "add-ca"})
+	certAddCA.AddChild(&Node{Name: "name="})
+	certAddCA.AddChild(&Node{Name: "common-name="})
+	certAddCA.AddChild(&Node{Name: "days="})
+	certAddCA.AddChild(&Node{Name: "key-type=", Dynamic: func() []string { return []string{"RSA-2048", "RSA-4096", "ECDSA-P256"} }})
+
+	certAdd := certNode.AddChild(&Node{Name: "add"})
+	certAdd.AddChild(&Node{Name: "name="})
+	certAdd.AddChild(&Node{Name: "common-name="})
+	certAdd.AddChild(&Node{Name: "ca=", Dynamic: dynCAs})
+	certAdd.AddChild(&Node{Name: "sans="})
+	certAdd.AddChild(&Node{Name: "days="})
+	certAdd.AddChild(&Node{Name: "key-type=", Dynamic: func() []string { return []string{"RSA-2048", "RSA-4096", "ECDSA-P256"} }})
+
+	certLE := certNode.AddChild(&Node{Name: "issue-letsencrypt"})
+	certLE.AddChild(&Node{Name: "domain="})
+	certLE.AddChild(&Node{Name: "email="})
+	certLE.AddChild(&Node{Name: "name="})
+	certLE.AddChild(&Node{Name: "staging=", Dynamic: func() []string { return []string{"yes", "no"} }})
+
+	// 9. Direct root utility commands
 	root.AddChild(&Node{Name: "clear"})
 	root.AddChild(&Node{Name: "help", Aliases: []string{"?"}})
 	root.AddChild(&Node{Name: "quit"})

+ 6 - 1
internal/cli/completer_test.go

@@ -156,9 +156,14 @@ func TestCompleterSubsectionsDoubleTab(t *testing.T) {
 		},
 		{
 			input:       "/ip service enable ",
-			expectedIn:  []string{"ssh", "telnet", "www"},
+			expectedIn:  []string{"ssh", "telnet", "www", "www-ssl"},
 			description: "/ip service enable service choices",
 		},
+		{
+			input:       "/certificate ",
+			expectedIn:  []string{"add", "add-ca", "export", "issue-letsencrypt", "print", "remove"},
+			description: "/certificate subsections",
+		},
 		{
 			input:       "/routing ",
 			expectedIn:  []string{"bgp", "filter", "ospf"},

+ 183 - 5
internal/cli/executor.go

@@ -13,6 +13,7 @@ import (
 	"proxmox-router/internal/auth"
 	"proxmox-router/internal/bridge"
 	"proxmox-router/internal/bwtest"
+	"proxmox-router/internal/certs"
 	"proxmox-router/internal/dhcp"
 	"proxmox-router/internal/dns"
 	"proxmox-router/internal/dynamicrouting"
@@ -40,6 +41,7 @@ type Executor struct {
 	vrrpMgr       *vrrp.Manager
 	bwtestMgr     *bwtest.Manager
 	servicesMgr   *services.Manager
+	certMgr       *certs.CertManager
 }
 
 func NewExecutor(
@@ -56,6 +58,7 @@ func NewExecutor(
 	vrrpMgr *vrrp.Manager,
 	bwtestMgr *bwtest.Manager,
 	servicesMgr *services.Manager,
+	certMgr *certs.CertManager,
 ) *Executor {
 	return &Executor{
 		authMgr:       authMgr,
@@ -71,6 +74,7 @@ func NewExecutor(
 		vrrpMgr:       vrrpMgr,
 		bwtestMgr:     bwtestMgr,
 		servicesMgr:   servicesMgr,
+		certMgr:       certMgr,
 	}
 }
 
@@ -125,6 +129,9 @@ func (e *Executor) Execute(rawCmd string, user *auth.PublicUser) (string, bool)
 	case "user":
 		return e.handleUser(parts[1:], user), false
 
+	case "certificate", "certificates":
+		return e.handleCertificate(parts[1:], user), false
+
 	default:
 		return fmt.Sprintf(ColorRed+"Unknown command or syntax error: %q. Type 'help' or '?' for command list.\n"+ColorReset, cmd), false
 	}
@@ -1750,13 +1757,17 @@ func (e *Executor) handleIPService(parts []string, user *auth.PublicUser) string
 
 	if len(parts) == 0 || parts[0] == "print" || parts[0] == "pr" {
 		svcs := e.servicesMgr.ListServices()
-		tbl := NewTable("NAME", "PORT", "DISABLED", "COMMENT")
+		tbl := NewTable("NAME", "PORT", "DISABLED", "CERTIFICATE", "COMMENT")
 		for _, s := range svcs {
 			disStr := ColorGreen + "no" + ColorReset
 			if s.Disabled {
 				disStr = ColorRed + "yes" + ColorReset
 			}
-			tbl.AddRow(s.Name, fmt.Sprintf("%d", s.Port), disStr, s.Comment)
+			certStr := "-"
+			if s.Certificate != "" {
+				certStr = ColorCyan + s.Certificate + ColorReset
+			}
+			tbl.AddRow(s.Name, fmt.Sprintf("%d", s.Port), disStr, certStr, s.Comment)
 		}
 		return tbl.String()
 	}
@@ -1792,7 +1803,7 @@ func (e *Executor) handleIPService(parts []string, user *auth.PublicUser) string
 	case "set":
 		pos, params := parseArgs(parts[1:])
 		if len(pos) == 0 {
-			return ColorRed + "Usage: /ip service set <name> [port=<port>] [disabled=yes|no]\n" + ColorReset
+			return ColorRed + "Usage: /ip service set <name> [port=<port>] [disabled=yes|no] [certificate=<cert_name>]\n" + ColorReset
 		}
 		name := pos[0]
 		svc, ok := e.servicesMgr.GetService(name)
@@ -1801,6 +1812,7 @@ func (e *Executor) handleIPService(parts []string, user *auth.PublicUser) string
 		}
 		port := svc.Port
 		disabled := svc.Disabled
+		certName := svc.Certificate
 		if pStr, ok := params["port"]; ok {
 			if pVal, err := strconv.Atoi(pStr); err == nil && pVal > 0 && pVal < 65536 {
 				port = pVal
@@ -1809,10 +1821,13 @@ func (e *Executor) handleIPService(parts []string, user *auth.PublicUser) string
 		if dStr, ok := params["disabled"]; ok {
 			disabled = (strings.ToLower(dStr) == "yes" || strings.ToLower(dStr) == "true")
 		}
-		if err := e.servicesMgr.SetService(name, port, disabled); err != nil {
+		if cStr, ok := params["certificate"]; ok {
+			certName = cStr
+		}
+		if err := e.servicesMgr.SetService(name, port, disabled, certName); err != nil {
 			return ColorRed + "Error: " + err.Error() + ColorReset + "\n"
 		}
-		return ColorGreen + fmt.Sprintf("Service %s updated (port=%d, disabled=%t).\n", name, port, disabled) + ColorReset
+		return ColorGreen + fmt.Sprintf("Service %s updated (port=%d, disabled=%t, certificate=%q).\n", name, port, disabled, certName) + ColorReset
 
 	default:
 		return ColorRed + "Usage: /ip service [print|enable|disable|set]\n" + ColorReset
@@ -2302,6 +2317,169 @@ func (e *Executor) showHelp() string {
 	return b.String()
 }
 
+// ==================== CERTIFICATES (/certificate) ====================
+
+func (e *Executor) handleCertificate(parts []string, user *auth.PublicUser) string {
+	if e.certMgr == nil {
+		return ColorRed + "Certificates manager not available.\n" + ColorReset
+	}
+
+	if len(parts) == 0 || parts[0] == "print" || parts[0] == "pr" {
+		list := e.certMgr.ListCertificates()
+		tbl := NewTable("#", "NAME", "COMMON-NAME", "TYPE", "ISSUER", "EXPIRES", "SANS")
+		for idx, c := range list {
+			tStr := ColorGreen + c.Type + ColorReset
+			if c.IsCA {
+				tStr = ColorCyan + "ca" + ColorReset
+			}
+			expStr := c.ValidTo.Format("Jan/02/2006")
+			if time.Now().After(c.ValidTo) {
+				expStr = ColorRed + expStr + " (EXPIRED)" + ColorReset
+			}
+			sansStr := strings.Join(c.SANs, ",")
+			if sansStr == "" {
+				sansStr = "-"
+			}
+			tbl.AddRow(fmt.Sprintf("%d", idx), c.Name, c.CommonName, tStr, c.Issuer, expStr, sansStr)
+		}
+		return tbl.String()
+	}
+
+	if user.Role != auth.RoleFull {
+		return ColorRed + "Permission denied: admin role required to manage certificates\n" + ColorReset
+	}
+
+	sub := strings.ToLower(parts[0])
+	switch sub {
+	case "add-ca":
+		_, params := parseArgs(parts[1:])
+		name := params["name"]
+		cn := params["common-name"]
+		if name == "" || cn == "" {
+			return ColorRed + "Usage: /certificate add-ca name=<name> common-name=<cn> [days=<days>] [key-type=RSA-2048|RSA-4096|ECDSA-P256]\n" + ColorReset
+		}
+		days := 3650
+		if dStr, ok := params["days"]; ok {
+			if dVal, err := strconv.Atoi(dStr); err == nil && dVal > 0 {
+				days = dVal
+			}
+		}
+		keyType := params["key-type"]
+		if keyType == "" {
+			keyType = "RSA-2048"
+		}
+		info, err := e.certMgr.CreateCA(certs.CreateCARequest{
+			Name:       name,
+			CommonName: cn,
+			Days:       days,
+			KeyType:    keyType,
+			Comment:    params["comment"],
+		})
+		if err != nil {
+			return ColorRed + "Error: " + err.Error() + ColorReset + "\n"
+		}
+		return ColorGreen + fmt.Sprintf("Certificate Authority %q created successfully.\n", info.Name) + ColorReset
+
+	case "add":
+		_, params := parseArgs(parts[1:])
+		name := params["name"]
+		cn := params["common-name"]
+		if name == "" || cn == "" {
+			return ColorRed + "Usage: /certificate add name=<name> common-name=<cn> [ca=<ca_name>] [sans=<san1,san2>] [days=<days>] [key-type=...]\n" + ColorReset
+		}
+		days := 365
+		if dStr, ok := params["days"]; ok {
+			if dVal, err := strconv.Atoi(dStr); err == nil && dVal > 0 {
+				days = dVal
+			}
+		}
+		var sans []string
+		if sStr, ok := params["sans"]; ok {
+			for _, s := range strings.Split(sStr, ",") {
+				if s = strings.TrimSpace(s); s != "" {
+					sans = append(sans, s)
+				}
+			}
+		}
+		keyType := params["key-type"]
+		if keyType == "" {
+			keyType = "RSA-2048"
+		}
+		info, err := e.certMgr.CreateCertificate(certs.CreateCertRequest{
+			Name:       name,
+			CommonName: cn,
+			CAName:     params["ca"],
+			SANs:       sans,
+			Days:       days,
+			KeyType:    keyType,
+			IsServer:   true,
+			Comment:    params["comment"],
+		})
+		if err != nil {
+			return ColorRed + "Error: " + err.Error() + ColorReset + "\n"
+		}
+		return ColorGreen + fmt.Sprintf("Certificate %q created successfully (Issuer: %s).\n", info.Name, info.Issuer) + ColorReset
+
+	case "issue-letsencrypt":
+		_, params := parseArgs(parts[1:])
+		domain := params["domain"]
+		email := params["email"]
+		if domain == "" || email == "" {
+			return ColorRed + "Usage: /certificate issue-letsencrypt domain=<domain> email=<email> [name=<name>] [staging=yes|no]\n" + ColorReset
+		}
+		name := params["name"]
+		if name == "" {
+			name = domain
+		}
+		staging := strings.ToLower(params["staging"]) == "yes" || strings.ToLower(params["staging"]) == "true"
+		ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
+		defer cancel()
+		info, err := e.certMgr.IssueLetsEncrypt(ctx, certs.LetsEncryptRequest{
+			Name:    name,
+			Domain:  domain,
+			Email:   email,
+			Staging: staging,
+			Comment: params["comment"],
+		})
+		if err != nil {
+			return ColorRed + "Let's Encrypt Error: " + err.Error() + ColorReset + "\n"
+		}
+		return ColorGreen + fmt.Sprintf("Let's Encrypt certificate %q issued successfully!\n", info.Name) + ColorReset
+
+	case "export":
+		pos, _ := parseArgs(parts[1:])
+		if len(pos) == 0 {
+			return ColorRed + "Usage: /certificate export <name>\n" + ColorReset
+		}
+		certPEM, keyPEM, err := e.certMgr.ExportPEM(pos[0])
+		if err != nil {
+			return ColorRed + "Error: " + err.Error() + ColorReset + "\n"
+		}
+		return fmt.Sprintf("----- CERTIFICATE -----\n%s\n----- PRIVATE KEY -----\n%s\n", certPEM, keyPEM)
+
+	case "remove", "rem", "delete":
+		pos, _ := parseArgs(parts[1:])
+		if len(pos) == 0 {
+			return ColorRed + "Usage: /certificate remove <name>\n" + ColorReset
+		}
+		name := pos[0]
+		if e.servicesMgr != nil {
+			if sslSvc, ok := e.servicesMgr.GetService("www-ssl"); ok {
+				if strings.EqualFold(sslSvc.Certificate, name) && !sslSvc.Disabled {
+					return ColorRed + "Cannot remove certificate: actively used by enabled www-ssl service\n" + ColorReset
+				}
+			}
+		}
+		if err := e.certMgr.DeleteCertificate(name); err != nil {
+			return ColorRed + "Error: " + err.Error() + ColorReset + "\n"
+		}
+		return ColorGreen + fmt.Sprintf("Certificate %s removed.\n", name) + ColorReset
+
+	default:
+		return ColorRed + "Usage: /certificate [print|add-ca|add|issue-letsencrypt|export|remove]\n" + ColorReset
+	}
+}
+
 func formatUptime(seconds int64) string {
 	d := seconds / 86400
 	h := (seconds % 86400) / 3600

+ 57 - 11
internal/services/manager.go

@@ -18,13 +18,15 @@ const (
 	ServiceTelnet = "telnet"
 	ServiceSSH    = "ssh"
 	ServiceWWW    = "www"
+	ServiceWWWSSL = "www-ssl"
 )
 
 type Service struct {
-	Name     string `json:"name"`               // "telnet", "ssh", "www"
-	Port     int    `json:"port"`               // Port number
-	Disabled bool   `json:"disabled"`           // Active status
-	Comment  string `json:"comment,omitempty"`  // Description
+	Name        string `json:"name"`                  // "telnet", "ssh", "www", "www-ssl"
+	Port        int    `json:"port"`                  // Port number
+	Disabled    bool   `json:"disabled"`              // Active status
+	Certificate string `json:"certificate,omitempty"` // TLS certificate name for www-ssl
+	Comment     string `json:"comment,omitempty"`     // Description
 }
 
 type ServiceHook func(serviceName string, port int, disabled bool) error
@@ -60,6 +62,13 @@ func (m *Manager) initDefaults() {
 		Disabled: false,
 		Comment:  "HTTP Web Management & REST API",
 	}
+	m.services[ServiceWWWSSL] = Service{
+		Name:        ServiceWWWSSL,
+		Port:        443,
+		Disabled:    true,
+		Certificate: "",
+		Comment:     "HTTPS Secure Web Management & REST API",
+	}
 	m.services[ServiceTelnet] = Service{
 		Name:     ServiceTelnet,
 		Port:     8282,
@@ -95,10 +104,13 @@ func (m *Manager) load() error {
 	}
 
 	for _, s := range list {
-		name := strings.ToLower(s.Name)
+		name := normalizeServiceName(s.Name)
 		if cur, ok := m.services[name]; ok {
 			cur.Port = s.Port
 			cur.Disabled = s.Disabled
+			if s.Certificate != "" {
+				cur.Certificate = s.Certificate
+			}
 			if s.Comment != "" {
 				cur.Comment = s.Comment
 			}
@@ -117,15 +129,15 @@ func (m *Manager) saveLocked() error {
 	}
 
 	list := make([]Service, 0, len(m.services))
-	// Keep ordered: www, telnet, ssh
-	order := []string{ServiceWWW, ServiceTelnet, ServiceSSH}
+	// Keep ordered: www, www-ssl, telnet, ssh
+	order := []string{ServiceWWW, ServiceWWWSSL, ServiceTelnet, ServiceSSH}
 	for _, name := range order {
 		if s, ok := m.services[name]; ok {
 			list = append(list, s)
 		}
 	}
 	for name, s := range m.services {
-		if name != ServiceWWW && name != ServiceTelnet && name != ServiceSSH {
+		if name != ServiceWWW && name != ServiceWWWSSL && name != ServiceTelnet && name != ServiceSSH {
 			list = append(list, s)
 		}
 	}
@@ -147,7 +159,7 @@ func (m *Manager) ListServices() []Service {
 	defer m.mu.RUnlock()
 
 	var list []Service
-	order := []string{ServiceWWW, ServiceTelnet, ServiceSSH}
+	order := []string{ServiceWWW, ServiceWWWSSL, ServiceTelnet, ServiceSSH}
 	for _, name := range order {
 		if s, ok := m.services[name]; ok {
 			list = append(list, s)
@@ -161,6 +173,9 @@ func normalizeServiceName(name string) string {
 	if k == "web" {
 		return ServiceWWW
 	}
+	if k == "https" || k == "ssl" || k == "web-ssl" || k == "www-ssl" {
+		return ServiceWWWSSL
+	}
 	return k
 }
 
@@ -201,7 +216,7 @@ func (m *Manager) SetServiceState(name string, disabled bool) error {
 	return nil
 }
 
-func (m *Manager) SetService(name string, port int, disabled bool) error {
+func (m *Manager) SetService(name string, port int, disabled bool, certificate ...string) error {
 	m.mu.Lock()
 	defer m.mu.Unlock()
 
@@ -215,13 +230,44 @@ func (m *Manager) SetService(name string, port int, disabled bool) error {
 		svc.Port = port
 	}
 	svc.Disabled = disabled
+	if len(certificate) > 0 {
+		svc.Certificate = certificate[0]
+	}
+	m.services[key] = svc
+
+	if err := m.saveLocked(); err != nil {
+		return err
+	}
+
+	logging.Log("system,info", "Service %s updated: port=%d, disabled=%t, certificate=%s", key, svc.Port, svc.Disabled, svc.Certificate)
+
+	if hook, exists := m.hooks[key]; exists {
+		go func() {
+			_ = hook(key, svc.Port, svc.Disabled)
+		}()
+	}
+
+	return nil
+}
+
+func (m *Manager) SetServiceCertificate(name string, certName string) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	key := normalizeServiceName(name)
+	svc, ok := m.services[key]
+	if !ok {
+		return fmt.Errorf("service %q not found", name)
+	}
+
+	svc.Certificate = certName
 	m.services[key] = svc
 
 	if err := m.saveLocked(); err != nil {
 		return err
 	}
 
-	logging.Log("system,info", "Service %s updated: port=%d, disabled=%t", key, svc.Port, svc.Disabled)
+	logging.Log("system,info", "Service %s certificate set to %q", key, certName)
 
 	if hook, exists := m.hooks[key]; exists {
 		go func() {

+ 427 - 8
internal/web/static/app.js

@@ -330,6 +330,7 @@ function navigate(tabName) {
     persistence: { title: 'Debian Configuration', sub: 'Native /etc/network/interfaces.d/ persistence manager' },
     settings: { title: 'Router Settings & DNS', sub: 'System DNS forwarder, upstream resolvers, static host mappings, and appliance identity' },
     users: { title: 'Local User Manager', sub: 'Manage local system administrator credentials and access permissions' },
+    certificates: { title: 'Certificates & SSL/TLS (/certificate)', sub: 'Root Certificate Authorities (CA), Server/Client certificates and automated Let\'s Encrypt' },
     services: { title: 'RouterOS Management Services (/ip service)', sub: 'Enable or disable Web, SSH, and Telnet management listeners and listening ports' },
     logs: { title: 'System Activity Logs (/log)', sub: 'Real-time circular buffer log viewer and topic-based event monitoring' }
   };
@@ -383,6 +384,7 @@ function navigate(tabName) {
   if (tabName === 'bwtest') loadBwTestView();
   if (tabName === 'settings') loadSettingsData();
   if (tabName === 'users') loadUsers();
+  if (tabName === 'certificates') loadCertificates();
   if (tabName === 'services') loadServices();
   if (tabName === 'logs') loadLogs();
   setTimeout(initTableSorting, 50);
@@ -484,6 +486,10 @@ async function fetchData(isManual = false) {
     if (state.activeTab === 'vrrp') loadVRRP(false);
     if (state.activeTab === 'settings') loadSettingsData(false);
     if (state.activeTab === 'users') loadUsers(false);
+    if (state.activeTab === 'certificates') loadCertificates(false);
+    if (state.activeTab === 'services') loadServices(false);
+    loadCertificates(false).catch(() => {});
+    loadServices(false).catch(() => {});
     fetchBwTestServerStatus();
 
   } catch (err) {
@@ -16445,6 +16451,7 @@ async function loadServices(manual = false) {
     const data = await res.json();
     state.services = data || [];
     renderServicesTable();
+    updateCertificatesBadges();
     if (manual) showToast('Services Updated', 'Service status synchronized', 'success');
   } catch (err) {
     if (manual) showToast('Error', err.message, 'error');
@@ -16470,13 +16477,24 @@ function renderServicesTable() {
       ? `<button onclick="toggleServiceState('${s.name}', false)" class="px-2.5 py-1 rounded-lg bg-emerald-500/20 text-emerald-300 hover:bg-emerald-500/30 border border-emerald-500/40 text-[11px] font-bold transition">Enable</button>`
       : `<button onclick="toggleServiceState('${s.name}', true)" class="px-2.5 py-1 rounded-lg bg-rose-500/20 text-rose-300 hover:bg-rose-500/30 border border-rose-500/40 text-[11px] font-bold transition">Disable</button>`;
 
+    let certBadge = '';
+    if (s.name === 'www-ssl') {
+      if (s.certificate) {
+        certBadge = `<span class="ml-2 inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] bg-slate-800 text-amber-300 border border-slate-700 font-mono">🔒 ${escapeHtml(s.certificate)}</span>`;
+      } else {
+        certBadge = `<span class="ml-2 inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] bg-rose-500/10 text-rose-400 border border-rose-500/20 font-mono">⚠️ No Cert Selected</span>`;
+      }
+    }
+
+    const certArg = s.certificate ? `'${escapeHtml(s.certificate)}'` : "''";
+
     return `
       <tr class="hover:bg-slate-800/40 transition">
-        <td class="py-3 px-4 font-bold text-white uppercase">${s.name}</td>
+        <td class="py-3 px-4 font-bold text-white uppercase">${s.name} ${certBadge}</td>
         <td class="py-3 px-4">
           <div class="flex items-center gap-2">
             <span class="text-cyan-400 font-mono font-bold">${s.port}</span>
-            <button onclick="openEditServiceModal('${s.name}', ${s.port}, ${s.disabled})" title="Edit Port" class="text-slate-500 hover:text-cyan-400 p-1 rounded hover:bg-slate-800/80 transition">
+            <button onclick="openEditServiceModal('${s.name}', ${s.port}, ${s.disabled}, ${certArg})" title="Edit Service" class="text-slate-500 hover:text-cyan-400 p-1 rounded hover:bg-slate-800/80 transition">
               <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"/></svg>
             </button>
           </div>
@@ -16485,9 +16503,9 @@ function renderServicesTable() {
         <td class="py-3 px-4 text-slate-400 text-xs font-sans">${s.comment || '-'}</td>
         <td class="py-3 px-4 text-right">
           <div class="flex items-center justify-end gap-2">
-            <button onclick="openEditServiceModal('${s.name}', ${s.port}, ${s.disabled})" class="px-2.5 py-1 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 hover:text-white border border-slate-700 text-[11px] font-medium transition inline-flex items-center gap-1">
+            <button onclick="openEditServiceModal('${s.name}', ${s.port}, ${s.disabled}, ${certArg})" class="px-2.5 py-1 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 hover:text-white border border-slate-700 text-[11px] font-medium transition inline-flex items-center gap-1">
               <svg class="w-3.5 h-3.5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"/></svg>
-              <span>Edit Port</span>
+              <span>Edit</span>
             </button>
             ${toggleBtn}
           </div>
@@ -16497,7 +16515,7 @@ function renderServicesTable() {
   }).join('');
 }
 
-function openEditServiceModal(serviceName, currentPort, isDisabled) {
+function openEditServiceModal(serviceName, currentPort, isDisabled, currentCert = '') {
   document.getElementById('modal-service-name').value = serviceName;
   document.getElementById('modal-service-disabled').value = String(isDisabled);
   document.getElementById('modal-service-display-name').textContent = serviceName.toUpperCase();
@@ -16505,19 +16523,48 @@ function openEditServiceModal(serviceName, currentPort, isDisabled) {
 
   const hintEl = document.getElementById('modal-service-port-hint');
   const warnEl = document.getElementById('modal-service-warning');
+  const certGroup = document.getElementById('modal-service-cert-group');
+  const certSelect = document.getElementById('modal-service-cert-select');
 
   if (serviceName === 'www') {
     hintEl.textContent = 'Default HTTP web port: 80';
     warnEl.classList.remove('hidden');
+    certGroup.classList.add('hidden');
+  } else if (serviceName === 'www-ssl') {
+    hintEl.textContent = 'Default HTTPS web port: 443';
+    warnEl.classList.remove('hidden');
+    certGroup.classList.remove('hidden');
+
+    const populateCertOptions = (certs) => {
+      certSelect.innerHTML = '<option value="">-- No Certificate Selected --</option>';
+      (certs || []).forEach(c => {
+        const opt = document.createElement('option');
+        opt.value = c.name;
+        opt.textContent = `${c.name} (${c.common_name})${c.is_ca ? ' [CA]' : ''}`;
+        if (c.name === currentCert) opt.selected = true;
+        certSelect.appendChild(opt);
+      });
+    };
+
+    if (state.certificates && state.certificates.length > 0) {
+      populateCertOptions(state.certificates);
+    } else {
+      loadCertificates(false).then(() => {
+        populateCertOptions(state.certificates);
+      });
+    }
   } else if (serviceName === 'telnet') {
     hintEl.textContent = 'Default Telnet / debtik-cli console port: 8282';
     warnEl.classList.add('hidden');
+    certGroup.classList.add('hidden');
   } else if (serviceName === 'ssh') {
     hintEl.textContent = 'Default CLI SSH port: 2222';
     warnEl.classList.add('hidden');
+    certGroup.classList.add('hidden');
   } else {
     hintEl.textContent = '';
     warnEl.classList.add('hidden');
+    certGroup.classList.add('hidden');
   }
 
   const modal = document.getElementById('modal-service-edit');
@@ -16541,19 +16588,22 @@ async function submitSaveServicePort(event) {
     return;
   }
 
+  const certSelect = document.getElementById('modal-service-cert-select');
+  const certVal = (name === 'www-ssl' && certSelect) ? certSelect.value : '';
+
   try {
     const res = await fetch(`${API_BASE}/system/services/${name}`, {
       method: 'POST',
       headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({ port: port, disabled: disabled })
+      body: JSON.stringify({ port: port, disabled: disabled, certificate: certVal })
     });
     if (!res.ok) {
       const errData = await res.json();
-      throw new Error(errData.message || 'Failed to update service port');
+      throw new Error(errData.message || 'Failed to update service settings');
     }
 
     closeEditServiceModal();
-    showToast('Port Updated', `Service ${name.toUpperCase()} port changed to ${port}`, 'success');
+    showToast('Service Updated', `Service ${name.toUpperCase()} settings saved`, 'success');
     await loadServices();
   } catch (err) {
     showToast('Error', err.message, 'error');
@@ -16567,6 +16617,15 @@ async function toggleServiceState(serviceName, newDisabled) {
     }
   }
 
+  if (serviceName === 'www-ssl' && !newDisabled) {
+    const sslSvc = (state.services || []).find(s => s.name === 'www-ssl');
+    if (!sslSvc || !sslSvc.certificate) {
+      showToast('Certificate Required', 'Please assign an SSL/TLS certificate to www-ssl before enabling HTTPS.', 'warning');
+      openEditServiceModal('www-ssl', sslSvc ? sslSvc.port : 443, false, '');
+      return;
+    }
+  }
+
   try {
     const res = await fetch(`${API_BASE}/system/services/${serviceName}/state`, {
       method: 'POST',
@@ -16583,6 +16642,7 @@ async function toggleServiceState(serviceName, newDisabled) {
     showToast('Error', err.message, 'error');
   }
 }
+}
 
 // ==================== SYSTEM LOGS (/log) ====================
 state.logs = [];
@@ -16671,6 +16731,365 @@ function renderLogsTable() {
   }).join('');
 }
 
+// ==================== CERTIFICATES & SSL/TLS (/certificate) ====================
+state.certificates = [];
+
+async function loadCertificates(manual = false) {
+  try {
+    const res = await fetch(`${API_BASE}/certificates`);
+    if (!res.ok) throw new Error('Failed to load certificates');
+    state.certificates = await res.json() || [];
+    updateCertificatesBadges();
+    renderCertificatesTable();
+    if (manual) showToast('Certificates Updated', `Loaded ${state.certificates.length} certificates`, 'success');
+  } catch (err) {
+    if (manual) showToast('Error', err.message, 'error');
+  }
+}
+
+function updateCertificatesBadges() {
+  const total = (state.certificates || []).length;
+  const caCount = (state.certificates || []).filter(c => c.is_ca).length;
+
+  const bCount = document.getElementById('badge-certs-count');
+  if (bCount) bCount.innerText = total;
+
+  const kpiTotal = document.getElementById('kpi-certs-total');
+  if (kpiTotal) kpiTotal.innerText = total;
+
+  const kpiCA = document.getElementById('kpi-certs-ca');
+  if (kpiCA) kpiCA.innerText = caCount;
+
+  const kpiHttps = document.getElementById('kpi-https-status');
+  if (kpiHttps) {
+    const sslSvc = (state.services || []).find(s => s.name === 'www-ssl');
+    if (sslSvc) {
+      if (!sslSvc.disabled) {
+        kpiHttps.innerHTML = `<span class="text-emerald-400 font-bold">Enabled (Port ${sslSvc.port})</span><div class="text-[10px] text-slate-400 font-mono truncate">Cert: ${escapeHtml(sslSvc.certificate || 'None')}</div>`;
+      } else {
+        kpiHttps.innerHTML = `<span class="text-rose-400 font-bold">Disabled (Port ${sslSvc.port})</span><div class="text-[10px] text-slate-500 font-mono truncate">Cert: ${escapeHtml(sslSvc.certificate || 'None')}</div>`;
+      }
+    }
+  }
+}
+
+function renderCertificatesTable() {
+  const tbody = document.getElementById('certs-table-body');
+  if (!tbody) return;
+
+  if (!state.certificates || state.certificates.length === 0) {
+    tbody.innerHTML = '<tr><td colspan="6" class="py-8 text-center text-slate-500 text-xs">No certificates configured yet. Click "Create CA" or "Issue Cert" to begin.</td></tr>';
+    return;
+  }
+
+  tbody.innerHTML = state.certificates.map(c => {
+    let typeBadge = '';
+    if (c.is_ca) {
+      typeBadge = '<span class="px-2 py-0.5 rounded text-[10px] bg-blue-500/20 text-blue-300 border border-blue-500/30 font-bold uppercase">🏛 CA</span>';
+    } else if (c.type === 'acme') {
+      typeBadge = '<span class="px-2 py-0.5 rounded text-[10px] bg-emerald-500/20 text-emerald-300 border border-emerald-500/30 font-bold uppercase">🔒 Let\'s Encrypt</span>';
+    } else if (c.issuer && c.issuer !== c.common_name) {
+      typeBadge = '<span class="px-2 py-0.5 rounded text-[10px] bg-cyan-500/20 text-cyan-300 border border-cyan-500/30 font-bold uppercase">📜 Signed</span>';
+    } else {
+      typeBadge = '<span class="px-2 py-0.5 rounded text-[10px] bg-slate-700/60 text-slate-300 border border-slate-600 font-bold uppercase">📜 Self-Signed</span>';
+    }
+
+    const sansHtml = (c.sans && c.sans.length > 0)
+      ? c.sans.map(s => `<span class="inline-block px-1.5 py-0.2 rounded text-[10px] bg-slate-800 text-slate-300 border border-slate-700 mr-1 mb-0.5">${escapeHtml(s)}</span>`).join('')
+      : '<span class="text-slate-500">-</span>';
+
+    const validTo = c.valid_to ? new Date(c.valid_to) : null;
+    let validStr = '-';
+    let validBadge = '';
+    if (validTo && !isNaN(validTo.getTime())) {
+      const now = new Date();
+      const daysLeft = Math.round((validTo - now) / (1000 * 60 * 60 * 24));
+      validStr = validTo.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
+      if (daysLeft < 0) {
+        validBadge = '<span class="ml-1 text-[10px] text-rose-400 font-bold">(Expired)</span>';
+      } else if (daysLeft < 30) {
+        validBadge = `<span class="ml-1 text-[10px] text-amber-400 font-bold">(${daysLeft}d left)</span>`;
+      } else {
+        validBadge = `<span class="ml-1 text-[10px] text-slate-400">(${daysLeft}d left)</span>`;
+      }
+    }
+
+    return `
+      <tr class="hover:bg-slate-800/40 transition">
+        <td class="py-3 px-4">${typeBadge}</td>
+        <td class="py-3 px-4">
+          <div class="font-bold text-white">${escapeHtml(c.name)}</div>
+          <div class="text-[10px] text-slate-400 font-sans truncate max-w-xs" title="${escapeHtml(c.common_name)}">${escapeHtml(c.common_name)}</div>
+        </td>
+        <td class="py-3 px-4 text-slate-300 text-xs truncate max-w-xs" title="${escapeHtml(c.issuer || '-')}">${escapeHtml(c.issuer || '-')}</td>
+        <td class="py-3 px-4 max-w-xs">${sansHtml}</td>
+        <td class="py-3 px-4 whitespace-nowrap text-xs text-slate-300">
+          <span>${validStr}</span> ${validBadge}
+          <div class="text-[10px] text-slate-500">${c.key_type || ''}</div>
+        </td>
+        <td class="py-3 px-4 text-right whitespace-nowrap">
+          <div class="flex items-center justify-end gap-1.5">
+            <button onclick="openViewCertModal('${c.name}')" title="View & Export PEM" class="px-2.5 py-1 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 hover:text-white border border-slate-700 text-[11px] font-medium transition inline-flex items-center gap-1">
+              <svg class="w-3.5 h-3.5 text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
+              <span>View</span>
+            </button>
+            <button onclick="deleteCertificate('${c.name}')" title="Delete Certificate" class="px-2 py-1 rounded-lg bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 border border-rose-500/30 text-[11px] transition">
+              <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
+            </button>
+          </div>
+        </td>
+      </tr>
+    `;
+  }).join('');
+}
+
+// Create CA
+function openCreateCAModal() {
+  document.getElementById('form-create-ca').reset();
+  document.getElementById('ca-days').value = '3650';
+  document.getElementById('ca-org').value = 'DebTik RouterOS Appliance';
+  document.getElementById('ca-country').value = 'GR';
+  document.getElementById('ca-key-type').value = 'RSA-2048';
+  document.getElementById('modal-create-ca').classList.remove('hidden');
+  setTimeout(() => document.getElementById('ca-name').focus(), 50);
+}
+function closeCreateCAModal() {
+  document.getElementById('modal-create-ca').classList.add('hidden');
+}
+async function submitCreateCA(event) {
+  event.preventDefault();
+  const payload = {
+    name: document.getElementById('ca-name').value.trim(),
+    common_name: document.getElementById('ca-cn').value.trim(),
+    organization: document.getElementById('ca-org').value.trim(),
+    country: document.getElementById('ca-country').value.trim(),
+    days: parseInt(document.getElementById('ca-days').value, 10) || 3650,
+    key_type: document.getElementById('ca-key-type').value
+  };
+  try {
+    const res = await fetch(`${API_BASE}/certificates/ca`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify(payload)
+    });
+    if (!res.ok) {
+      const errData = await res.json();
+      throw new Error(errData.message || 'Failed to create CA');
+    }
+    closeCreateCAModal();
+    showToast('CA Created', `Root CA ${payload.name} generated successfully`, 'success');
+    await loadCertificates();
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+// Create Cert
+function openCreateCertModal() {
+  document.getElementById('form-create-cert').reset();
+  document.getElementById('cert-days').value = '730';
+  document.getElementById('cert-key-type').value = 'RSA-2048';
+
+  const caSelect = document.getElementById('cert-ca-select');
+  caSelect.innerHTML = '<option value="">[ Self-Signed Certificate ]</option>';
+  (state.certificates || []).filter(c => c.is_ca).forEach(ca => {
+    const opt = document.createElement('option');
+    opt.value = ca.name;
+    opt.textContent = `${ca.name} (${ca.common_name})`;
+    caSelect.appendChild(opt);
+  });
+
+  document.getElementById('modal-create-cert').classList.remove('hidden');
+  setTimeout(() => document.getElementById('cert-name').focus(), 50);
+}
+function closeCreateCertModal() {
+  document.getElementById('modal-create-cert').classList.add('hidden');
+}
+async function submitCreateCert(event) {
+  event.preventDefault();
+  const rawSans = document.getElementById('cert-sans').value;
+  const sansList = rawSans ? rawSans.split(',').map(s => s.trim()).filter(Boolean) : [];
+  const caName = document.getElementById('cert-ca-select').value;
+
+  const payload = {
+    name: document.getElementById('cert-name').value.trim(),
+    common_name: document.getElementById('cert-cn').value.trim(),
+    ca_name: caName,
+    sans: sansList,
+    days: parseInt(document.getElementById('cert-days').value, 10) || 730,
+    key_type: document.getElementById('cert-key-type').value,
+    is_server: true
+  };
+  try {
+    const res = await fetch(`${API_BASE}/certificates/generate`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify(payload)
+    });
+    if (!res.ok) {
+      const errData = await res.json();
+      throw new Error(errData.message || 'Failed to issue certificate');
+    }
+    closeCreateCertModal();
+    showToast('Certificate Issued', `Certificate ${payload.name} generated successfully`, 'success');
+    await loadCertificates();
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+// Let's Encrypt
+function openLetsEncryptModal() {
+  document.getElementById('form-letsencrypt').reset();
+  document.getElementById('modal-letsencrypt').classList.remove('hidden');
+  setTimeout(() => document.getElementById('le-domain').focus(), 50);
+}
+function closeLetsEncryptModal() {
+  document.getElementById('modal-letsencrypt').classList.add('hidden');
+}
+async function submitLetsEncrypt(event) {
+  event.preventDefault();
+  const domain = document.getElementById('le-domain').value.trim();
+  const email = document.getElementById('le-email').value.trim();
+  let name = document.getElementById('le-name').value.trim();
+  if (!name) name = domain.replace(/[^a-zA-Z0-9_-]/g, '-');
+  const staging = document.getElementById('le-staging').checked;
+
+  const btn = document.getElementById('btn-submit-letsencrypt');
+  const origBtnHtml = btn.innerHTML;
+  btn.disabled = true;
+  btn.innerHTML = `<svg class="w-4 h-4 animate-spin text-white" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"></path></svg> <span>Issuing (may take up to 60s)...</span>`;
+
+  try {
+    const res = await fetch(`${API_BASE}/certificates/letsencrypt`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({
+        name: name,
+        domain: domain,
+        email: email,
+        staging: staging
+      })
+    });
+    if (!res.ok) {
+      const errData = await res.json();
+      throw new Error(errData.message || 'Let\'s Encrypt challenge failed');
+    }
+    closeLetsEncryptModal();
+    showToast('Let\'s Encrypt Issued', `Successfully issued trusted certificate for ${domain}`, 'success');
+    await loadCertificates();
+  } catch (err) {
+    showToast('Let\'s Encrypt Error', err.message, 'error');
+  } finally {
+    btn.disabled = false;
+    btn.innerHTML = origBtnHtml;
+  }
+}
+
+// Import Cert
+function openImportCertModal() {
+  document.getElementById('form-import-cert').reset();
+  document.getElementById('modal-import-cert').classList.remove('hidden');
+  setTimeout(() => document.getElementById('import-cert-name').focus(), 50);
+}
+function closeImportCertModal() {
+  document.getElementById('modal-import-cert').classList.add('hidden');
+}
+async function submitImportCert(event) {
+  event.preventDefault();
+  const payload = {
+    name: document.getElementById('import-cert-name').value.trim(),
+    cert_pem: document.getElementById('import-cert-pem').value.trim(),
+    key_pem: document.getElementById('import-key-pem').value.trim()
+  };
+  try {
+    const res = await fetch(`${API_BASE}/certificates/import`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify(payload)
+    });
+    if (!res.ok) {
+      const errData = await res.json();
+      throw new Error(errData.message || 'Failed to import certificate');
+    }
+    closeImportCertModal();
+    showToast('Certificate Imported', `Imported ${payload.name} successfully`, 'success');
+    await loadCertificates();
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+// View / Export Modal
+async function openViewCertModal(name) {
+  const c = (state.certificates || []).find(x => x.name === name);
+  if (!c) return;
+
+  document.getElementById('view-cert-modal-title').textContent = `Certificate: ${c.name}`;
+  document.getElementById('view-cert-cn').textContent = c.common_name || '-';
+  document.getElementById('view-cert-issuer').textContent = c.issuer || '-';
+  document.getElementById('view-cert-type').textContent = `${c.is_ca ? 'CA' : 'Server/Client'} (${c.key_type || 'Unknown'})`;
+  document.getElementById('view-cert-validto').textContent = c.valid_to ? new Date(c.valid_to).toLocaleString() : '-';
+  document.getElementById('view-cert-sans').textContent = (c.sans && c.sans.length > 0) ? c.sans.join(', ') : 'None';
+  document.getElementById('view-cert-fp').textContent = c.fingerprint || '-';
+
+  document.getElementById('view-cert-pem-text').value = 'Loading PEM...';
+  document.getElementById('view-key-pem-text').value = 'Loading Key...';
+
+  document.getElementById('modal-view-cert').classList.remove('hidden');
+
+  try {
+    const res = await fetch(`${API_BASE}/certificates/${encodeURIComponent(name)}/export`);
+    if (!res.ok) throw new Error('Failed to export certificate PEM');
+    const data = await res.json();
+    document.getElementById('view-cert-pem-text').value = data.cert_pem || '';
+    document.getElementById('view-key-pem-text').value = data.key_pem || '(Private key not available)';
+  } catch (err) {
+    document.getElementById('view-cert-pem-text').value = 'Error loading certificate PEM: ' + err.message;
+    document.getElementById('view-key-pem-text').value = '';
+  }
+}
+function closeViewCertModal() {
+  document.getElementById('modal-view-cert').classList.add('hidden');
+}
+function copyCertPEM() {
+  const text = document.getElementById('view-cert-pem-text').value;
+  if (!text) return;
+  navigator.clipboard.writeText(text).then(() => {
+    showToast('Copied', 'Certificate PEM copied to clipboard', 'info');
+  }).catch(() => {
+    showToast('Error', 'Failed to copy to clipboard', 'error');
+  });
+}
+function copyKeyPEM() {
+  const text = document.getElementById('view-key-pem-text').value;
+  if (!text) return;
+  navigator.clipboard.writeText(text).then(() => {
+    showToast('Copied', 'Private Key PEM copied to clipboard', 'info');
+  }).catch(() => {
+    showToast('Error', 'Failed to copy to clipboard', 'error');
+  });
+}
+
+async function deleteCertificate(name) {
+  if (!confirm(`Are you sure you want to delete certificate "${name}"? This action cannot be undone.`)) {
+    return;
+  }
+  try {
+    const res = await fetch(`${API_BASE}/certificates/${encodeURIComponent(name)}`, {
+      method: 'DELETE'
+    });
+    if (!res.ok) {
+      const errData = await res.json();
+      throw new Error(errData.message || 'Failed to delete certificate');
+    }
+    showToast('Certificate Deleted', `Certificate "${name}" removed`, 'success');
+    await loadCertificates();
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
 
 
 

+ 315 - 2
internal/web/static/index.html

@@ -446,6 +446,17 @@
           <span id="badge-users-count" class="text-[10px] bg-rose-950 text-rose-300 border border-rose-800/40 px-1.5 py-0.2 rounded-full font-mono">1</span>
         </a>
 
+        <a href="#certificates" onclick="navigate('certificates')" id="nav-certificates"
+           class="nav-link flex items-center justify-between px-3 py-2 rounded-lg text-xs font-medium transition-all group text-slate-300 hover:bg-slate-800/80 hover:text-white">
+          <div class="flex items-center space-x-2.5">
+            <svg class="w-4 h-4 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
+            </svg>
+            <span>Certificates & SSL</span>
+          </div>
+          <span id="badge-certs-count" class="text-[10px] bg-amber-950 text-amber-300 border border-amber-800/40 px-1.5 py-0.2 rounded-full font-mono">0</span>
+        </a>
+
         <a href="#services" onclick="navigate('services')" id="nav-services"
            class="nav-link flex items-center justify-between px-3 py-2 rounded-lg text-xs font-medium transition-all group text-slate-300 hover:bg-slate-800/80 hover:text-white">
           <div class="flex items-center space-x-2.5">
@@ -4577,6 +4588,84 @@
           </div>
         </section>
 
+        <!-- ================= SECTION: CERTIFICATES (/certificate) ================= -->
+        <section id="section-certificates" class="hidden space-y-6">
+          <div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-5 shadow-lg flex flex-wrap items-center justify-between gap-4">
+            <div>
+              <h3 class="text-base font-bold text-white flex items-center gap-2">
+                <svg class="w-5 h-5 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>
+                <span>Certificates & SSL/TLS (/certificate)</span>
+              </h3>
+              <p class="text-xs text-slate-400 mt-1">Manage Root Certificate Authorities (CA), issue Server/Client certificates, and request automated Let's Encrypt certificates.</p>
+            </div>
+            <div class="flex items-center gap-2 flex-wrap">
+              <button onclick="openCreateCAModal()" class="px-3 py-1.5 rounded-lg bg-blue-600 hover:bg-blue-500 text-xs font-semibold text-white flex items-center gap-1.5 transition shadow">
+                <span>+ Create CA</span>
+              </button>
+              <button onclick="openCreateCertModal()" class="px-3 py-1.5 rounded-lg bg-cyan-600 hover:bg-cyan-500 text-xs font-semibold text-white flex items-center gap-1.5 transition shadow">
+                <span>+ Issue Cert</span>
+              </button>
+              <button onclick="openLetsEncryptModal()" class="px-3 py-1.5 rounded-lg bg-emerald-600 hover:bg-emerald-500 text-xs font-semibold text-white flex items-center gap-1.5 transition shadow">
+                <span>🔒 Let's Encrypt</span>
+              </button>
+              <button onclick="openImportCertModal()" class="px-3 py-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 text-xs font-medium text-slate-300 transition">
+                <span>📥 Import</span>
+              </button>
+              <button onclick="loadCertificates(true)" class="px-2.5 py-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 text-xs text-slate-400 hover:text-white transition">
+                <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
+              </button>
+            </div>
+          </div>
+
+          <!-- Cards Summary -->
+          <div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
+            <div class="bg-slate-900/60 border border-slate-800 rounded-xl p-4 flex items-center justify-between">
+              <div>
+                <span class="text-[11px] font-mono uppercase text-slate-400">Total Certificates</span>
+                <div class="text-xl font-bold font-mono text-white mt-1" id="kpi-certs-total">0</div>
+              </div>
+              <div class="w-9 h-9 rounded-lg bg-amber-500/10 border border-amber-500/30 flex items-center justify-center text-amber-400 font-bold">📜</div>
+            </div>
+            <div class="bg-slate-900/60 border border-slate-800 rounded-xl p-4 flex items-center justify-between">
+              <div>
+                <span class="text-[11px] font-mono uppercase text-slate-400">Certificate Authorities</span>
+                <div class="text-xl font-bold font-mono text-cyan-400 mt-1" id="kpi-certs-ca">0</div>
+              </div>
+              <div class="w-9 h-9 rounded-lg bg-cyan-500/10 border border-cyan-500/30 flex items-center justify-center text-cyan-400 font-bold">🏛</div>
+            </div>
+            <div class="bg-slate-900/60 border border-slate-800 rounded-xl p-4 flex items-center justify-between">
+              <div>
+                <span class="text-[11px] font-mono uppercase text-slate-400">HTTPS Web SSL (www-ssl)</span>
+                <div class="text-xs font-mono font-bold mt-1" id="kpi-https-status">
+                  <span class="text-rose-400">Disabled (Port 443)</span>
+                </div>
+              </div>
+              <a href="#services" onclick="navigate('services')" title="Configure in Services" class="text-xs text-slate-400 hover:text-cyan-400 underline">Configure</a>
+            </div>
+          </div>
+
+          <!-- Certificates Table -->
+          <div class="bg-slate-900/80 border border-slate-800 rounded-2xl overflow-hidden shadow-xl">
+            <div class="overflow-x-auto">
+              <table class="w-full text-left text-xs">
+                <thead class="bg-slate-950/60 text-slate-400 uppercase font-mono text-[10px] tracking-wider border-b border-slate-800">
+                  <tr>
+                    <th class="py-3 px-4">Type</th>
+                    <th class="py-3 px-4">Name / Common Name</th>
+                    <th class="py-3 px-4">Issuer</th>
+                    <th class="py-3 px-4">SANs</th>
+                    <th class="py-3 px-4">Valid Until</th>
+                    <th class="py-3 px-4 text-right">Actions</th>
+                  </tr>
+                </thead>
+                <tbody id="certs-table-body" class="divide-y divide-slate-800 font-mono">
+                  <!-- Rendered dynamically -->
+                </tbody>
+              </table>
+            </div>
+          </div>
+        </section>
+
         <!-- ================= SECTION: SERVICES (/ip service) ================= -->
         <section id="section-services" class="hidden space-y-6">
           <div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-5 shadow-lg flex items-center justify-between">
@@ -11683,18 +11772,242 @@
           <input type="number" id="modal-service-port" min="1" max="65535" required class="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-cyan-500">
           <p id="modal-service-port-hint" class="text-[10px] text-slate-500 mt-1"></p>
         </div>
+        <div id="modal-service-cert-group" class="hidden">
+          <label class="block text-xs font-semibold text-slate-300 mb-1">SSL/TLS Certificate *</label>
+          <select id="modal-service-cert-select" class="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-cyan-500">
+            <option value="">-- No Certificate Selected --</option>
+          </select>
+          <p class="text-[10px] text-slate-500 mt-1">Select an issued or imported certificate from the Certificates section.</p>
+        </div>
         <div id="modal-service-warning" class="hidden p-3 rounded-lg bg-amber-500/10 border border-amber-500/30 text-amber-300 text-xs">
           ⚠️ <strong>Προσοχή:</strong> Η αλλαγή της πόρτας του Web UI θα αλλάξει τη διεύθυνση URL με την οποία συνδέεσαι.
         </div>
         <div class="pt-2 flex items-center justify-end space-x-2">
           <button type="button" onclick="closeEditServiceModal()" class="px-3.5 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 text-xs font-medium text-slate-300 transition">Cancel</button>
-          <button type="submit" class="px-4 py-2 rounded-lg bg-emerald-600 hover:bg-emerald-500 text-xs font-semibold text-white shadow transition">Save Port</button>
+          <button type="submit" class="px-4 py-2 rounded-lg bg-emerald-600 hover:bg-emerald-500 text-xs font-semibold text-white shadow transition">Save Settings</button>
+        </div>
+      </form>
+    </div>
+  </div>
+
+  <!-- ================= MODAL: CREATE CA ================= -->
+  <div id="modal-create-ca" class="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
+    <div class="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-md shadow-2xl overflow-hidden">
+      <div class="px-5 py-4 border-b border-slate-800 flex items-center justify-between bg-slate-950/50">
+        <h3 class="text-sm font-bold text-white flex items-center gap-2">
+          <span class="text-blue-400">🏛</span>
+          <span>Create Certificate Authority (CA)</span>
+        </h3>
+        <button type="button" onclick="closeCreateCAModal()" class="text-slate-400 hover:text-white p-1 rounded-lg">✕</button>
+      </div>
+      <form id="form-create-ca" onsubmit="submitCreateCA(event)" class="p-5 space-y-4">
+        <div>
+          <label class="block text-xs font-semibold text-slate-300 mb-1">CA Identifier / Name *</label>
+          <input type="text" id="ca-name" placeholder="e.g. root-ca or debtik-ca" required class="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-blue-500">
+        </div>
+        <div>
+          <label class="block text-xs font-semibold text-slate-300 mb-1">Common Name (CN) *</label>
+          <input type="text" id="ca-cn" placeholder="e.g. DebTik Root Certificate Authority" required class="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-blue-500">
+        </div>
+        <div class="grid grid-cols-2 gap-3">
+          <div>
+            <label class="block text-xs font-semibold text-slate-300 mb-1">Organization</label>
+            <input type="text" id="ca-org" value="DebTik RouterOS Appliance" class="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-blue-500">
+          </div>
+          <div>
+            <label class="block text-xs font-semibold text-slate-300 mb-1">Country</label>
+            <input type="text" id="ca-country" value="GR" maxlength="2" class="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-blue-500">
+          </div>
+        </div>
+        <div class="grid grid-cols-2 gap-3">
+          <div>
+            <label class="block text-xs font-semibold text-slate-300 mb-1">Validity (Days)</label>
+            <input type="number" id="ca-days" value="3650" min="30" max="36500" class="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-blue-500">
+          </div>
+          <div>
+            <label class="block text-xs font-semibold text-slate-300 mb-1">Key Algorithm</label>
+            <select id="ca-key-type" class="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-blue-500">
+              <option value="RSA-2048">RSA 2048-bit (Standard)</option>
+              <option value="RSA-4096">RSA 4096-bit (High Security)</option>
+              <option value="ECDSA-P256">ECDSA P-256 (Fast/Modern)</option>
+            </select>
+          </div>
+        </div>
+        <div class="pt-2 flex items-center justify-end space-x-2">
+          <button type="button" onclick="closeCreateCAModal()" class="px-3.5 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 text-xs font-medium text-slate-300 transition">Cancel</button>
+          <button type="submit" class="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-500 text-xs font-semibold text-white shadow transition">Generate CA</button>
         </div>
       </form>
     </div>
   </div>
 
+  <!-- ================= MODAL: ISSUE CERTIFICATE ================= -->
+  <div id="modal-create-cert" class="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
+    <div class="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-md shadow-2xl overflow-hidden">
+      <div class="px-5 py-4 border-b border-slate-800 flex items-center justify-between bg-slate-950/50">
+        <h3 class="text-sm font-bold text-white flex items-center gap-2">
+          <span class="text-cyan-400">📜</span>
+          <span>Issue TLS Certificate</span>
+        </h3>
+        <button type="button" onclick="closeCreateCertModal()" class="text-slate-400 hover:text-white p-1 rounded-lg">✕</button>
+      </div>
+      <form id="form-create-cert" onsubmit="submitCreateCert(event)" class="p-5 space-y-4">
+        <div>
+          <label class="block text-xs font-semibold text-slate-300 mb-1">Certificate Identifier / Name *</label>
+          <input type="text" id="cert-name" placeholder="e.g. web-cert or router-lan" required class="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-cyan-500">
+        </div>
+        <div>
+          <label class="block text-xs font-semibold text-slate-300 mb-1">Common Name (CN) * (Hostname, Domain, or IP)</label>
+          <input type="text" id="cert-cn" placeholder="e.g. router.lan or 192.168.1.1" required class="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-cyan-500">
+        </div>
+        <div>
+          <label class="block text-xs font-semibold text-slate-300 mb-1">Sign with Certificate Authority (CA)</label>
+          <select id="cert-ca-select" class="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-cyan-500">
+            <option value="">[ Self-Signed Certificate ]</option>
+          </select>
+        </div>
+        <div>
+          <label class="block text-xs font-semibold text-slate-300 mb-1">Subject Alternative Names (SANs) - comma separated</label>
+          <input type="text" id="cert-sans" placeholder="e.g. 192.168.1.1, router.local, router.lan, 127.0.0.1" class="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-cyan-500">
+          <p class="text-[10px] text-slate-500 mt-1">Browser validation requires matching SANs for all IPs or domains used to access the router.</p>
+        </div>
+        <div class="grid grid-cols-2 gap-3">
+          <div>
+            <label class="block text-xs font-semibold text-slate-300 mb-1">Validity (Days)</label>
+            <input type="number" id="cert-days" value="730" min="1" max="7300" class="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-cyan-500">
+          </div>
+          <div>
+            <label class="block text-xs font-semibold text-slate-300 mb-1">Key Algorithm</label>
+            <select id="cert-key-type" class="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-cyan-500">
+              <option value="RSA-2048">RSA 2048-bit (Standard)</option>
+              <option value="RSA-4096">RSA 4096-bit (High Security)</option>
+              <option value="ECDSA-P256">ECDSA P-256 (Fast/Modern)</option>
+            </select>
+          </div>
+        </div>
+        <div class="pt-2 flex items-center justify-end space-x-2">
+          <button type="button" onclick="closeCreateCertModal()" class="px-3.5 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 text-xs font-medium text-slate-300 transition">Cancel</button>
+          <button type="submit" class="px-4 py-2 rounded-lg bg-cyan-600 hover:bg-cyan-500 text-xs font-semibold text-white shadow transition">Issue Certificate</button>
+        </div>
+      </form>
+    </div>
+  </div>
+
+  <!-- ================= MODAL: LET'S ENCRYPT ================= -->
+  <div id="modal-letsencrypt" class="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
+    <div class="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-md shadow-2xl overflow-hidden">
+      <div class="px-5 py-4 border-b border-slate-800 flex items-center justify-between bg-slate-950/50">
+        <h3 class="text-sm font-bold text-white flex items-center gap-2">
+          <span class="text-emerald-400">🔒</span>
+          <span>Let's Encrypt Automated Certificate</span>
+        </h3>
+        <button type="button" onclick="closeLetsEncryptModal()" class="text-slate-400 hover:text-white p-1 rounded-lg">✕</button>
+      </div>
+      <form id="form-letsencrypt" onsubmit="submitLetsEncrypt(event)" class="p-5 space-y-4">
+        <div class="p-3 bg-emerald-500/10 border border-emerald-500/30 rounded-xl text-emerald-300 text-xs space-y-1">
+          <div class="font-bold">Automated HTTP-01 Validation</div>
+          <div class="text-[11px] text-emerald-300/80">To issue a trusted Let's Encrypt certificate, your domain name must publicly point to this router on port 80. The router will automatically answer the ACME challenge.</div>
+        </div>
+        <div>
+          <label class="block text-xs font-semibold text-slate-300 mb-1">Domain Name (FQDN) *</label>
+          <input type="text" id="le-domain" placeholder="e.g. router.mydomain.com" required class="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-emerald-500">
+        </div>
+        <div>
+          <label class="block text-xs font-semibold text-slate-300 mb-1">Contact Email *</label>
+          <input type="email" id="le-email" placeholder="e.g. admin@mydomain.com" required class="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-emerald-500">
+        </div>
+        <div>
+          <label class="block text-xs font-semibold text-slate-300 mb-1">Certificate Name / Alias</label>
+          <input type="text" id="le-name" placeholder="Leave blank to use domain name" class="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-emerald-500">
+        </div>
+        <div class="flex items-center gap-2 pt-1">
+          <input type="checkbox" id="le-staging" class="rounded bg-slate-950 border-slate-800 text-emerald-500">
+          <label for="le-staging" class="text-xs text-slate-300 cursor-pointer">Use Let's Encrypt Staging API (for testing without rate limits)</label>
+        </div>
+        <div class="pt-2 flex items-center justify-end space-x-2">
+          <button type="button" onclick="closeLetsEncryptModal()" class="px-3.5 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 text-xs font-medium text-slate-300 transition">Cancel</button>
+          <button type="submit" id="btn-submit-letsencrypt" class="px-4 py-2 rounded-lg bg-emerald-600 hover:bg-emerald-500 text-xs font-semibold text-white shadow transition flex items-center gap-2">
+            <span>Request Certificate</span>
+          </button>
+        </div>
+      </form>
+    </div>
+  </div>
+
+  <!-- ================= MODAL: IMPORT CERTIFICATE ================= -->
+  <div id="modal-import-cert" class="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
+    <div class="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-lg shadow-2xl overflow-hidden">
+      <div class="px-5 py-4 border-b border-slate-800 flex items-center justify-between bg-slate-950/50">
+        <h3 class="text-sm font-bold text-white flex items-center gap-2">
+          <span class="text-slate-400">📥</span>
+          <span>Import PEM Certificate & Private Key</span>
+        </h3>
+        <button type="button" onclick="closeImportCertModal()" class="text-slate-400 hover:text-white p-1 rounded-lg">✕</button>
+      </div>
+      <form id="form-import-cert" onsubmit="submitImportCert(event)" class="p-5 space-y-4">
+        <div>
+          <label class="block text-xs font-semibold text-slate-300 mb-1">Certificate Identifier / Name *</label>
+          <input type="text" id="import-cert-name" placeholder="e.g. wildcard-cert" required class="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-cyan-500">
+        </div>
+        <div>
+          <label class="block text-xs font-semibold text-slate-300 mb-1">Certificate (PEM Format) *</label>
+          <textarea id="import-cert-pem" rows="5" placeholder="-----BEGIN CERTIFICATE-----&#10;...&#10;-----END CERTIFICATE-----" required class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-white font-mono focus:outline-none focus:border-cyan-500"></textarea>
+        </div>
+        <div>
+          <label class="block text-xs font-semibold text-slate-300 mb-1">Private Key (PEM Format) *</label>
+          <textarea id="import-key-pem" rows="5" placeholder="-----BEGIN PRIVATE KEY-----&#10;...&#10;-----END PRIVATE KEY-----" required class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-white font-mono focus:outline-none focus:border-cyan-500"></textarea>
+        </div>
+        <div class="pt-2 flex items-center justify-end space-x-2">
+          <button type="button" onclick="closeImportCertModal()" class="px-3.5 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 text-xs font-medium text-slate-300 transition">Cancel</button>
+          <button type="submit" class="px-4 py-2 rounded-lg bg-cyan-600 hover:bg-cyan-500 text-xs font-semibold text-white shadow transition">Import Certificate</button>
+        </div>
+      </form>
+    </div>
+  </div>
+
+  <!-- ================= MODAL: VIEW / EXPORT CERTIFICATE ================= -->
+  <div id="modal-view-cert" class="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
+    <div class="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-xl shadow-2xl overflow-hidden">
+      <div class="px-5 py-4 border-b border-slate-800 flex items-center justify-between bg-slate-950/50">
+        <h3 id="view-cert-modal-title" class="text-sm font-bold text-white flex items-center gap-2">
+          <span>Certificate Details</span>
+        </h3>
+        <button type="button" onclick="closeViewCertModal()" class="text-slate-400 hover:text-white p-1 rounded-lg">✕</button>
+      </div>
+      <div class="p-5 space-y-4">
+        <div class="grid grid-cols-2 gap-3 text-xs font-mono bg-slate-950/60 p-3 rounded-xl border border-slate-800/80">
+          <div><span class="text-slate-500">Common Name:</span> <span id="view-cert-cn" class="text-white font-bold">-</span></div>
+          <div><span class="text-slate-500">Issuer:</span> <span id="view-cert-issuer" class="text-cyan-400">-</span></div>
+          <div><span class="text-slate-500">Type / Key:</span> <span id="view-cert-type" class="text-emerald-400">-</span></div>
+          <div><span class="text-slate-500">Valid Until:</span> <span id="view-cert-validto" class="text-white">-</span></div>
+          <div class="col-span-2 truncate"><span class="text-slate-500">SANs:</span> <span id="view-cert-sans" class="text-slate-300">-</span></div>
+          <div class="col-span-2 truncate"><span class="text-slate-500">SHA256 Fingerprint:</span> <span id="view-cert-fp" class="text-amber-400 text-[10px]">-</span></div>
+        </div>
+
+        <div>
+          <div class="flex items-center justify-between mb-1">
+            <span class="text-xs font-semibold text-slate-300">Certificate (PEM)</span>
+            <button onclick="copyCertPEM()" class="text-[11px] text-cyan-400 hover:text-cyan-300 font-mono">Copy Cert</button>
+          </div>
+          <textarea id="view-cert-pem-text" readonly rows="6" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2 text-[11px] text-slate-300 font-mono"></textarea>
+        </div>
+
+        <div>
+          <div class="flex items-center justify-between mb-1">
+            <span class="text-xs font-semibold text-slate-300">Private Key (PEM)</span>
+            <button onclick="copyKeyPEM()" class="text-[11px] text-cyan-400 hover:text-cyan-300 font-mono">Copy Key</button>
+          </div>
+          <textarea id="view-key-pem-text" readonly rows="4" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2 text-[11px] text-slate-300 font-mono"></textarea>
+        </div>
+
+        <div class="flex justify-end pt-2">
+          <button type="button" onclick="closeViewCertModal()" class="px-4 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 text-xs font-medium text-slate-300 transition">Close</button>
+        </div>
+      </div>
+    </div>
+  </div>
+
   <datalist id="fw-addrlist-datalist"></datalist>
-  <script src="app.js?v=16.0"></script>
+  <script src="app.js?v=17.0"></script>
 </body>
 </html>