增加SSL支持HTTPS/WSS

This commit is contained in:
MengMengCode
2026-06-09 13:06:26 +08:00
parent 835bb51c6e
commit c46f84c66e
7 changed files with 1089 additions and 105 deletions
+580
View File
@@ -0,0 +1,580 @@
package api
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"math/big"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"clicd/internal/config"
)
type sslSettingsRequest struct {
Enabled bool `json:"enabled"`
Mode string `json:"mode"`
Target string `json:"target"`
Email string `json:"email"`
CertPEM string `json:"cert_pem"`
KeyPEM string `json:"key_pem"`
ApplyNow bool `json:"apply_now"`
}
type sslCertificateInfo struct {
Subject string `json:"subject"`
Issuer string `json:"issuer"`
DNSNames []string `json:"dns_names"`
IPNames []string `json:"ip_names"`
NotBefore string `json:"not_before"`
NotAfter string `json:"not_after"`
Valid bool `json:"valid"`
}
type sslSavedCertificateStatus struct {
config.SSLConfig
Certificate *sslCertificateInfo `json:"certificate,omitempty"`
}
type sslSettingsResponse struct {
config.SSLConfig
DetectedHost string `json:"detected_host"`
Certificate *sslCertificateInfo `json:"certificate,omitempty"`
ModeCertificates map[string]sslSavedCertificateStatus `json:"mode_certificates"`
NeedsRestart bool `json:"needs_restart,omitempty"`
}
func HandleSSLSettings(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: sslSettingsStatus(r, false)})
case http.MethodPut:
updateSSLSettings(w, r)
default:
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
}
}
func updateSSLSettings(w http.ResponseWriter, r *http.Request) {
var req sslSettingsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
mode := config.NormalizeSSLMode(req.Mode)
if !req.Enabled || mode == config.SSLModeDisabled {
saveCurrentSSLSlot()
config.AppConfig.SSL = config.SSLConfig{Enabled: false, Mode: config.SSLModeDisabled}
if err := config.SaveConfig(); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Save SSL settings failed"})
return
}
restartIfRequested(req.ApplyNow)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "SSL disabled", Data: sslSettingsStatus(r, true)})
return
}
target := strings.TrimSpace(req.Target)
if target == "" {
target = detectedRequestHost(r)
}
if target == "" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "SSL target is required"})
return
}
next, err := resolveSSLModeCertificate(mode, target, strings.TrimSpace(req.Email), req.CertPEM, req.KeyPEM)
if err != nil {
_ = config.SaveConfig()
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error(), Data: sslSettingsStatus(r, false)})
return
}
if err := validateCertificatePair(next.CertPath, next.KeyPath); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
next.LastIssuedAt = time.Now().Format(time.RFC3339)
next.Enabled = true
config.AppConfig.SSL = next
saveSSLSlot(next)
if err := config.SaveConfig(); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Save SSL settings failed"})
return
}
restartIfRequested(req.ApplyNow)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "SSL settings saved", Data: sslSettingsStatus(r, true)})
}
func sslSettingsStatus(r *http.Request, needsRestart bool) sslSettingsResponse {
cfg := config.AppConfig.SSL
cfg.KeyPath = maskExistingPath(cfg.KeyPath)
resp := sslSettingsResponse{
SSLConfig: cfg,
DetectedHost: detectedRequestHost(r),
ModeCertificates: sslModeCertificatesStatus(),
NeedsRestart: needsRestart,
}
if cert, err := readCertificateInfo(config.AppConfig.SSL.CertPath); err == nil {
resp.Certificate = cert
}
return resp
}
func resolveSSLModeCertificate(mode, target, email, certPEM, keyPEM string) (config.SSLConfig, error) {
if config.AppConfig.SSLCertificates == nil {
config.AppConfig.SSLCertificates = map[string]config.SSLConfig{}
}
next := config.AppConfig.SSLCertificates[mode]
next.Mode = mode
next.Target = target
if email != "" || next.Email == "" {
next.Email = email
}
var err error
switch mode {
case config.SSLModeUploaded:
if strings.TrimSpace(certPEM) != "" || strings.TrimSpace(keyPEM) != "" {
next.CertPath, next.KeyPath, err = saveUploadedCertificate(certPEM, keyPEM)
} else if next.CertPath == "" || next.KeyPath == "" {
err = fmt.Errorf("certificate and private key are required")
} else if !certificateUsable(next.CertPath, next.KeyPath, target) {
err = fmt.Errorf("uploaded certificate is expired, invalid, or does not match the target")
}
case config.SSLModeSelfSigned:
if !certificateUsable(next.CertPath, next.KeyPath, target) {
next.CertPath, next.KeyPath, err = generateSelfSignedCertificate(target)
}
case config.SSLModeLetsEncrypt:
if !certificateUsable(next.CertPath, next.KeyPath, target) {
next.CertPath, next.KeyPath, err = requestLetsEncryptCertificate(target, next.Email)
}
default:
err = fmt.Errorf("unsupported SSL mode")
}
if err != nil {
next.LastError = err.Error()
saveSSLSlot(next)
return next, err
}
next.LastError = ""
return next, nil
}
func sslModeCertificatesStatus() map[string]sslSavedCertificateStatus {
result := map[string]sslSavedCertificateStatus{}
for _, mode := range []string{config.SSLModeLetsEncrypt, config.SSLModeSelfSigned, config.SSLModeUploaded} {
cfg := config.AppConfig.SSLCertificates[mode]
cfg.KeyPath = maskExistingPath(cfg.KeyPath)
status := sslSavedCertificateStatus{SSLConfig: cfg}
if cert, err := readCertificateInfo(config.AppConfig.SSLCertificates[mode].CertPath); err == nil {
status.Certificate = cert
}
result[mode] = status
}
return result
}
func saveCurrentSSLSlot() {
if config.AppConfig.SSL.Mode == config.SSLModeDisabled || config.AppConfig.SSL.CertPath == "" {
return
}
saveSSLSlot(config.AppConfig.SSL)
}
func saveSSLSlot(ssl config.SSLConfig) {
mode := config.NormalizeSSLMode(ssl.Mode)
if mode == config.SSLModeDisabled {
return
}
if config.AppConfig.SSLCertificates == nil {
config.AppConfig.SSLCertificates = map[string]config.SSLConfig{}
}
ssl.Mode = mode
ssl.Enabled = false
config.AppConfig.SSLCertificates[mode] = ssl
}
func saveUploadedCertificate(certPEM, keyPEM string) (string, string, error) {
certPEM = strings.TrimSpace(certPEM)
keyPEM = strings.TrimSpace(keyPEM)
if certPEM == "" || keyPEM == "" {
return "", "", fmt.Errorf("certificate and private key are required")
}
if _, err := tls.X509KeyPair([]byte(certPEM), []byte(keyPEM)); err != nil {
return "", "", fmt.Errorf("certificate/private key mismatch: %v", err)
}
dir := sslStorageDir()
if err := os.MkdirAll(dir, 0700); err != nil {
return "", "", err
}
certPath := filepath.Join(dir, "uploaded-fullchain.pem")
keyPath := filepath.Join(dir, "uploaded-privkey.pem")
if err := os.WriteFile(certPath, []byte(certPEM+"\n"), 0600); err != nil {
return "", "", err
}
if err := os.WriteFile(keyPath, []byte(keyPEM+"\n"), 0600); err != nil {
return "", "", err
}
return certPath, keyPath, nil
}
func generateSelfSignedCertificate(target string) (string, string, error) {
target = strings.TrimSpace(target)
if target == "" {
return "", "", fmt.Errorf("self-signed certificate target is required")
}
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return "", "", err
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return "", "", err
}
now := time.Now()
tpl := x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{
CommonName: target,
},
NotBefore: now.Add(-time.Hour),
NotAfter: now.AddDate(1, 0, 0),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
if ip := net.ParseIP(target); ip != nil {
tpl.IPAddresses = []net.IP{ip}
} else {
tpl.DNSNames = []string{target}
}
der, err := x509.CreateCertificate(rand.Reader, &tpl, &tpl, &key.PublicKey, key)
if err != nil {
return "", "", err
}
keyDER, err := x509.MarshalECPrivateKey(key)
if err != nil {
return "", "", err
}
dir := sslStorageDir()
if err := os.MkdirAll(dir, 0700); err != nil {
return "", "", err
}
certPath := filepath.Join(dir, "self-signed-fullchain.pem")
keyPath := filepath.Join(dir, "self-signed-privkey.pem")
certOut := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyOut := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
if err := os.WriteFile(certPath, certOut, 0600); err != nil {
return "", "", err
}
if err := os.WriteFile(keyPath, keyOut, 0600); err != nil {
return "", "", err
}
return certPath, keyPath, nil
}
func requestLetsEncryptCertificate(target, email string) (string, string, error) {
if _, err := exec.LookPath("certbot"); err != nil {
return "", "", fmt.Errorf("certbot is not installed on this server")
}
target = strings.TrimSpace(target)
if target == "" {
return "", "", fmt.Errorf("Let's Encrypt target is required")
}
args := []string{"certonly", "--non-interactive", "--agree-tos", "--standalone"}
if email != "" {
args = append(args, "--email", email)
} else {
args = append(args, "--register-unsafely-without-email")
}
if net.ParseIP(target) != nil {
if err := ensureCertbotSupportsIPCertificates(); err != nil {
return "", "", err
}
args = append(args, "--preferred-profile", "shortlived", "--ip-address", target)
} else {
args = append(args, "-d", target)
}
cmd := exec.Command("certbot", args...)
output, err := cmd.CombinedOutput()
if err != nil {
return "", "", fmt.Errorf("Let's Encrypt request failed: %s", strings.TrimSpace(string(output)))
}
certPath := filepath.Join("/etc/letsencrypt/live", target, "fullchain.pem")
keyPath := filepath.Join("/etc/letsencrypt/live", target, "privkey.pem")
if _, err := os.Stat(certPath); err != nil {
return "", "", fmt.Errorf("Let's Encrypt certificate file not found after issuance: %s", certPath)
}
if _, err := os.Stat(keyPath); err != nil {
return "", "", fmt.Errorf("Let's Encrypt private key file not found after issuance: %s", keyPath)
}
return certPath, keyPath, nil
}
func ensureCertbotSupportsIPCertificates() error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "certbot", "--help", "all")
output, err := cmd.CombinedOutput()
if ctx.Err() == context.DeadlineExceeded {
return fmt.Errorf("certbot check timed out")
}
if err != nil {
return fmt.Errorf("certbot capability check failed: %s", strings.TrimSpace(string(output)))
}
help := string(output)
if !strings.Contains(help, "--ip-address") || !strings.Contains(help, "--preferred-profile") {
return fmt.Errorf("current certbot does not support IP certificates; install Certbot 5.4+ from snap or another current source")
}
return nil
}
func validateCertificatePair(certPath, keyPath string) error {
certPEM, err := os.ReadFile(certPath)
if err != nil {
return err
}
keyPEM, err := os.ReadFile(keyPath)
if err != nil {
return err
}
if _, err := tls.X509KeyPair(certPEM, keyPEM); err != nil {
return fmt.Errorf("certificate/private key mismatch: %v", err)
}
return nil
}
func certificateUsable(certPath, keyPath, target string) bool {
if certPath == "" || keyPath == "" {
return false
}
if err := validateCertificatePair(certPath, keyPath); err != nil {
return false
}
cert, err := readLeafCertificate(certPath)
if err != nil {
return false
}
now := time.Now()
if now.Before(cert.NotBefore) || !now.Before(cert.NotAfter) {
return false
}
return certificateMatchesTarget(cert, target)
}
func certificateNeedsRenewal(certPath, keyPath, target string, renewBefore time.Duration) bool {
if certPath == "" || keyPath == "" {
return true
}
if err := validateCertificatePair(certPath, keyPath); err != nil {
return true
}
cert, err := readLeafCertificate(certPath)
if err != nil {
return true
}
now := time.Now()
if now.Before(cert.NotBefore) || !now.Before(cert.NotAfter) {
return true
}
if !certificateMatchesTarget(cert, target) {
return true
}
return cert.NotAfter.Sub(now) <= renewBefore
}
func certificateMatchesTarget(cert *x509.Certificate, target string) bool {
target = strings.TrimSpace(strings.Trim(target, "[]"))
if target == "" {
return true
}
if ip := net.ParseIP(target); ip != nil {
for _, certIP := range cert.IPAddresses {
if certIP.Equal(ip) {
return true
}
}
return false
}
if err := cert.VerifyHostname(target); err != nil {
return false
}
return true
}
func readCertificateInfo(certPath string) (*sslCertificateInfo, error) {
cert, err := readLeafCertificate(certPath)
if err != nil {
return nil, err
}
ipNames := make([]string, 0, len(cert.IPAddresses))
for _, ip := range cert.IPAddresses {
ipNames = append(ipNames, ip.String())
}
return &sslCertificateInfo{
Subject: cert.Subject.String(),
Issuer: cert.Issuer.String(),
DNSNames: cert.DNSNames,
IPNames: ipNames,
NotBefore: cert.NotBefore.Format(time.RFC3339),
NotAfter: cert.NotAfter.Format(time.RFC3339),
Valid: time.Now().After(cert.NotBefore) && time.Now().Before(cert.NotAfter),
}, nil
}
func readLeafCertificate(certPath string) (*x509.Certificate, error) {
if certPath == "" {
return nil, errors.New("certificate path is empty")
}
data, err := os.ReadFile(certPath)
if err != nil {
return nil, err
}
block, _ := pem.Decode(data)
if block == nil {
return nil, errors.New("certificate PEM is invalid")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, err
}
return cert, nil
}
func detectedRequestHost(r *http.Request) string {
host := strings.TrimSpace(r.Host)
if host == "" {
return firstPublicInterfaceIP()
}
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
host = strings.Trim(host, "[]")
if host == "localhost" || net.ParseIP(host).IsLoopback() {
if ip := firstPublicInterfaceIP(); ip != "" {
return ip
}
}
return host
}
func firstPublicInterfaceIP() string {
addrs, err := net.InterfaceAddrs()
if err != nil {
return ""
}
for _, addr := range addrs {
ipNet, ok := addr.(*net.IPNet)
if !ok {
continue
}
ip := ipNet.IP.To4()
if ip == nil || ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() {
continue
}
return ip.String()
}
return ""
}
func sslStorageDir() string {
dataDir := config.AppConfig.DataDir
if dataDir == "" {
dataDir = "/root/.clicd"
}
return filepath.Join(dataDir, "ssl")
}
func maskExistingPath(path string) string {
if path == "" {
return ""
}
return path
}
func restartIfRequested(applyNow bool) {
if !applyNow {
return
}
go func() {
time.Sleep(500 * time.Millisecond)
_ = exec.Command("systemctl", "restart", "clicd").Start()
}()
}
func StartSSLRenewalMonitor() {
go func() {
time.Sleep(30 * time.Second)
renewSavedSSLCertificates()
ticker := time.NewTicker(6 * time.Hour)
defer ticker.Stop()
for range ticker.C {
renewSavedSSLCertificates()
}
}()
}
func renewSavedSSLCertificates() {
if config.AppConfig == nil || len(config.AppConfig.SSLCertificates) == 0 {
return
}
changed := false
for mode, cert := range config.AppConfig.SSLCertificates {
mode = config.NormalizeSSLMode(mode)
if cert.Target == "" || mode == config.SSLModeDisabled || mode == config.SSLModeUploaded {
continue
}
var certPath, keyPath string
var err error
switch mode {
case config.SSLModeLetsEncrypt:
if !certificateNeedsRenewal(cert.CertPath, cert.KeyPath, cert.Target, 48*time.Hour) {
continue
}
certPath, keyPath, err = requestLetsEncryptCertificate(cert.Target, cert.Email)
case config.SSLModeSelfSigned:
if !certificateNeedsRenewal(cert.CertPath, cert.KeyPath, cert.Target, 30*24*time.Hour) {
continue
}
certPath, keyPath, err = generateSelfSignedCertificate(cert.Target)
}
if err != nil {
cert.LastError = err.Error()
config.AppConfig.SSLCertificates[mode] = cert
changed = true
continue
}
cert.CertPath = certPath
cert.KeyPath = keyPath
cert.LastIssuedAt = time.Now().Format(time.RFC3339)
cert.LastError = ""
config.AppConfig.SSLCertificates[mode] = cert
if config.AppConfig.SSL.Enabled && config.AppConfig.SSL.Mode == mode {
active := cert
active.Enabled = true
config.AppConfig.SSL = active
}
changed = true
}
if changed {
_ = config.SaveConfig()
}
}
+121 -23
View File
@@ -205,26 +205,46 @@ type Snapshot struct {
SizeBytes int64 `json:"size_bytes"`
}
const (
SSLModeDisabled = "disabled"
SSLModeLetsEncrypt = "letsencrypt"
SSLModeSelfSigned = "self_signed"
SSLModeUploaded = "uploaded"
)
type SSLConfig struct {
Enabled bool `json:"enabled"`
Mode string `json:"mode"`
Target string `json:"target"`
Email string `json:"email,omitempty"`
CertPath string `json:"cert_path,omitempty"`
KeyPath string `json:"key_path,omitempty"`
LastIssuedAt string `json:"last_issued_at,omitempty"`
LastError string `json:"last_error,omitempty"`
}
// ClicdConfig is the main configuration structure
type ClicdConfig struct {
AdminUser string `json:"admin_user"`
AdminPassHash string `json:"admin_pass_hash"`
JWTSecret string `json:"jwt_secret"`
Port int `json:"port"`
DataDir string `json:"data_dir"`
Containers []Container `json:"containers"`
NextContainerID int `json:"next_container_id"`
NextVNCPort int `json:"next_vnc_port"`
NextSSHPort int `json:"next_ssh_port"`
SetupComplete bool `json:"setup_complete"`
SubUsers []SubUser `json:"sub_users"`
ApiKeys []ApiKeyConfig `json:"api_keys"`
AuditLogs []AuditLog `json:"audit_logs"`
Tasks []SavedTask `json:"tasks"`
LoginLogs []SavedLoginLog `json:"login_logs"`
EnabledImages []string `json:"enabled_images"`
Snapshots []Snapshot `json:"snapshots"`
SecurityAutoShutdown bool `json:"security_auto_shutdown"`
AdminUser string `json:"admin_user"`
AdminPassHash string `json:"admin_pass_hash"`
JWTSecret string `json:"jwt_secret"`
Port int `json:"port"`
DataDir string `json:"data_dir"`
Containers []Container `json:"containers"`
NextContainerID int `json:"next_container_id"`
NextVNCPort int `json:"next_vnc_port"`
NextSSHPort int `json:"next_ssh_port"`
SetupComplete bool `json:"setup_complete"`
SubUsers []SubUser `json:"sub_users"`
ApiKeys []ApiKeyConfig `json:"api_keys"`
AuditLogs []AuditLog `json:"audit_logs"`
Tasks []SavedTask `json:"tasks"`
LoginLogs []SavedLoginLog `json:"login_logs"`
EnabledImages []string `json:"enabled_images"`
Snapshots []Snapshot `json:"snapshots"`
SecurityAutoShutdown bool `json:"security_auto_shutdown"`
SSL SSLConfig `json:"ssl"`
SSLCertificates map[string]SSLConfig `json:"ssl_certificates"`
}
var configPath string
@@ -302,8 +322,11 @@ func InitConfig() (*ClicdConfig, error) {
}
if ok {
AppConfig = cfg
normalizeConfigDefaults(dataDir)
changed := normalizeConfigDefaults(dataDir)
if migrateLoadedConfig() {
changed = true
}
if changed {
if err := SaveConfig(); err != nil {
return nil, err
}
@@ -318,9 +341,8 @@ func InitConfig() (*ClicdConfig, error) {
if ok {
AppConfig = legacy
normalizeConfigDefaults(dataDir)
if migrateLoadedConfig() {
// Save below persists normalized legacy data into SQLite.
}
migrateLoadedConfig()
// Always save legacy JSON data into SQLite.
if err := SaveConfig(); err != nil {
return nil, err
}
@@ -371,51 +393,127 @@ func InitConfig() (*ClicdConfig, error) {
return AppConfig, nil
}
func normalizeConfigDefaults(dataDir string) {
func normalizeConfigDefaults(dataDir string) bool {
changed := false
if AppConfig.Port == 0 {
AppConfig.Port = 8999
changed = true
}
if AppConfig.NextVNCPort == 0 {
AppConfig.NextVNCPort = 5900
changed = true
}
if AppConfig.NextSSHPort == 0 {
AppConfig.NextSSHPort = 22000
changed = true
}
if AppConfig.NextContainerID == 0 {
AppConfig.NextContainerID = 1
changed = true
}
if AppConfig.DataDir == "" {
AppConfig.DataDir = dataDir
changed = true
}
if AppConfig.Containers == nil {
AppConfig.Containers = make([]Container, 0)
changed = true
}
if AppConfig.Snapshots == nil {
AppConfig.Snapshots = make([]Snapshot, 0)
changed = true
}
if AppConfig.SubUsers == nil {
AppConfig.SubUsers = make([]SubUser, 0)
changed = true
}
if AppConfig.ApiKeys == nil {
AppConfig.ApiKeys = make([]ApiKeyConfig, 0)
changed = true
} else {
for i := range AppConfig.ApiKeys {
if len(AppConfig.ApiKeys[i].Scopes) == 0 {
AppConfig.ApiKeys[i].Scopes = []string{"*"}
changed = true
}
}
}
if AppConfig.AuditLogs == nil {
AppConfig.AuditLogs = make([]AuditLog, 0)
changed = true
}
if AppConfig.Tasks == nil {
AppConfig.Tasks = make([]SavedTask, 0)
changed = true
}
if AppConfig.LoginLogs == nil {
AppConfig.LoginLogs = make([]SavedLoginLog, 0)
changed = true
}
if AppConfig.EnabledImages == nil {
AppConfig.EnabledImages = make([]string, 0)
changed = true
}
if normalizeSSLDefaults() {
changed = true
}
return changed
}
func normalizeSSLDefaults() bool {
changed := false
previousMode := AppConfig.SSL.Mode
AppConfig.SSL.Mode = NormalizeSSLMode(AppConfig.SSL.Mode)
if AppConfig.SSL.Mode != previousMode {
changed = true
}
if AppConfig.SSL.Mode == SSLModeDisabled {
if AppConfig.SSL.Enabled {
changed = true
}
AppConfig.SSL.Enabled = false
}
if AppConfig.SSLCertificates == nil {
AppConfig.SSLCertificates = map[string]SSLConfig{}
changed = true
}
for mode, cert := range AppConfig.SSLCertificates {
cert.Mode = NormalizeSSLMode(cert.Mode)
if cert.Mode == SSLModeDisabled {
delete(AppConfig.SSLCertificates, mode)
changed = true
continue
}
if AppConfig.SSLCertificates[cert.Mode] != cert {
changed = true
}
AppConfig.SSLCertificates[cert.Mode] = cert
if mode != cert.Mode {
delete(AppConfig.SSLCertificates, mode)
changed = true
}
}
if AppConfig.SSL.Mode != SSLModeDisabled && AppConfig.SSL.CertPath != "" && AppConfig.SSL.KeyPath != "" {
cert := AppConfig.SSL
cert.Enabled = false
if AppConfig.SSLCertificates[cert.Mode] != cert {
changed = true
}
AppConfig.SSLCertificates[cert.Mode] = cert
}
return changed
}
func NormalizeSSLMode(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case SSLModeLetsEncrypt:
return SSLModeLetsEncrypt
case SSLModeSelfSigned:
return SSLModeSelfSigned
case SSLModeUploaded:
return SSLModeUploaded
default:
return SSLModeDisabled
}
}
+10
View File
@@ -374,6 +374,12 @@ func loadConfigFromDB() (*ClicdConfig, bool, error) {
SetupComplete: atob(meta["setup_complete"]),
SecurityAutoShutdown: atob(meta["security_auto_shutdown"]),
}
if raw := strings.TrimSpace(meta["ssl"]); raw != "" {
_ = json.Unmarshal([]byte(raw), &cfg.SSL)
}
if raw := strings.TrimSpace(meta["ssl_certificates"]); raw != "" {
_ = json.Unmarshal([]byte(raw), &cfg.SSLCertificates)
}
if cfg.Containers, err = loadContainers(); err != nil {
return nil, false, err
@@ -466,6 +472,8 @@ func saveConfigToDB() error {
}
func saveMeta(tx *sql.Tx) error {
sslJSON, _ := json.Marshal(AppConfig.SSL)
sslCertificatesJSON, _ := json.Marshal(AppConfig.SSLCertificates)
values := map[string]string{
"admin_user": AppConfig.AdminUser,
"admin_pass_hash": AppConfig.AdminPassHash,
@@ -477,6 +485,8 @@ func saveMeta(tx *sql.Tx) error {
"next_ssh_port": strconv.Itoa(AppConfig.NextSSHPort),
"setup_complete": btoa(AppConfig.SetupComplete),
"security_auto_shutdown": btoa(AppConfig.SecurityAutoShutdown),
"ssl": string(sslJSON),
"ssl_certificates": string(sslCertificatesJSON),
"schema_version": "1",
"updated_at": time.Now().Format("2006-01-02 15:04:05"),
}
+32
View File
@@ -1,11 +1,13 @@
package server
import (
"crypto/tls"
"fmt"
"log"
"net"
"net/http"
"net/url"
"os"
"strings"
"clicd/internal/api"
@@ -75,6 +77,7 @@ func setupRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/change-password", corsMiddleware(api.AdminMiddleware(api.HandleAdminPasswordChange)))
mux.HandleFunc("/api/change-username", corsMiddleware(api.AdminMiddleware(api.HandleAdminUsernameChange)))
mux.HandleFunc("/api/login-logs", corsMiddleware(api.AdminMiddleware(api.HandleLoginLogs)))
mux.HandleFunc("/api/ssl", corsMiddleware(api.AdminMiddleware(api.HandleSSLSettings)))
mux.HandleFunc("/api/containers", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainers))))
mux.HandleFunc("/api/containers/list", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainerListAlias))))
mux.HandleFunc("/api/containers/", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleSingleContainer))))
@@ -141,6 +144,7 @@ func setupRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/sub-users/", corsMiddleware(api.AuthMiddleware(api.HandleSubUserAction)))
mux.HandleFunc("/api/v1/audit-logs", corsMiddleware(api.AuthMiddleware(api.HandleAuditLogs)))
mux.HandleFunc("/api/v1/login-logs", corsMiddleware(api.AuthMiddleware(api.HandleLoginLogs)))
mux.HandleFunc("/api/v1/ssl", corsMiddleware(api.AdminMiddleware(api.HandleSSLSettings)))
mux.HandleFunc("/api/v1/security/alerts", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("security:read", api.HandleSecurityAlerts))))
mux.HandleFunc("/api/v1/security/check", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("security:check", api.HandleSecurityCheck))))
mux.HandleFunc("/api/v1/security/logs", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("security:read", api.HandleSecurityLogs))))
@@ -208,5 +212,33 @@ func Run() error {
Handler: mux,
}
if sslEnabled() {
server.TLSConfig = &tls.Config{
MinVersion: tls.VersionTLS12,
GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
cert, err := tls.LoadX509KeyPair(config.AppConfig.SSL.CertPath, config.AppConfig.SSL.KeyPath)
return &cert, err
},
}
log.Printf("CLICD Web Server SSL enabled on https://0.0.0.0:%d", config.AppConfig.Port)
return server.ListenAndServeTLS("", "")
}
return server.ListenAndServe()
}
func sslEnabled() bool {
ssl := config.AppConfig.SSL
if !ssl.Enabled || ssl.CertPath == "" || ssl.KeyPath == "" {
return false
}
if _, err := os.Stat(ssl.CertPath); err != nil {
log.Printf("SSL certificate is not readable, falling back to HTTP: %v", err)
return false
}
if _, err := os.Stat(ssl.KeyPath); err != nil {
log.Printf("SSL private key is not readable, falling back to HTTP: %v", err)
return false
}
return true
}
+1
View File
@@ -50,6 +50,7 @@ func main() {
// Start security scanner
api.InitScanner()
api.StartSSLRenewalMonitor()
// Ensure iptables FORWARD rules allow managed bridge traffic.
lxc.EnsureForwardRules("lxcbr0")
+304 -82
View File
@@ -1,10 +1,13 @@
import { useCallback, useEffect, useState } from 'react'
import { Clock, Globe, LogIn, Monitor, UserCog } from 'lucide-react'
import { Dispatch, SetStateAction, useCallback, useEffect, useState } from 'react'
import { Clock, Globe, Lock, LogIn, Monitor, RefreshCw, ShieldCheck, Upload, UserCog } from 'lucide-react'
import {
changePassword,
changeUsername,
getLoginLogs,
getSSLSettings,
LoginLog,
SSLSettings,
updateSSLSettings,
} from '../services/api'
import { useDialog } from '../components/Dialog'
import { useAuth } from '../contexts/AuthContext'
@@ -21,6 +24,16 @@ export default function Settings() {
const [newPwd, setNewPwd] = useState('')
const [newUsername, setNewUsername] = useState('')
const [ssl, setSSL] = useState<SSLSettings | null>(null)
const [sslEnabled, setSSLEnabled] = useState(false)
const [sslMode, setSSLMode] = useState<SSLSettings['mode']>('disabled')
const [sslTarget, setSSLTarget] = useState('')
const [sslEmail, setSSLEmail] = useState('')
const [certPEM, setCertPEM] = useState('')
const [keyPEM, setKeyPEM] = useState('')
const [applyNow, setApplyNow] = useState(true)
const [savingSSL, setSavingSSL] = useState(false)
const fetchLogs = useCallback(async () => {
try {
const res = await getLoginLogs()
@@ -32,11 +45,61 @@ export default function Settings() {
}
}, [])
const fetchSSL = useCallback(async () => {
try {
const res = await getSSLSettings()
const data = res.data.data
if (!data) return
setSSL(data)
setSSLEnabled(data.enabled)
setSSLMode(data.mode || 'disabled')
setSSLTarget(data.target || data.detected_host || '')
setSSLEmail(data.email || '')
} catch (err) {
console.error(err)
}
}, [])
useEffect(() => {
fetchLogs()
fetchSSL()
const timer = setInterval(fetchLogs, 15000)
return () => clearInterval(timer)
}, [fetchLogs])
}, [fetchLogs, fetchSSL])
const handleSSLModeChange = (mode: SSLSettings['mode']) => {
setSSLMode(mode)
const saved = ssl?.mode_certificates?.[mode]
setSSLTarget(saved?.target || ssl?.detected_host || sslTarget)
setSSLEmail(saved?.email || '')
}
const handleSaveSSL = async () => {
setSavingSSL(true)
try {
const enabled = sslEnabled && sslMode !== 'disabled'
const res = await updateSSLSettings({
enabled,
mode: enabled ? sslMode : 'disabled',
target: sslTarget,
email: sslEmail,
cert_pem: certPEM,
key_pem: keyPEM,
apply_now: applyNow,
})
if (res.data.data) {
setSSL(res.data.data)
setCertPEM('')
setKeyPEM('')
}
dialog.alert('完成', applyNow ? 'SSL 设置已保存,服务正在重启。稍后请用新的协议重新打开面板。' : 'SSL 设置已保存,重启 clicd 服务后生效。')
} catch (err: unknown) {
const e = err as { response?: { data?: { message?: string } } }
dialog.alert('失败', e.response?.data?.message || 'SSL 设置保存失败')
} finally {
setSavingSSL(false)
}
}
const handleSaveAccount = async () => {
if (!oldPwd) {
@@ -92,93 +155,252 @@ export default function Settings() {
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-black"></h1>
<p className="mt-1 text-sm text-gray-500"></p>
<p className="mt-1 text-sm text-gray-500"></p>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-5">
<h2 className="mb-4 flex items-center gap-2 text-sm font-semibold text-black">
<UserCog className="h-4 w-4" />
</h2>
<div className="space-y-4">
<div>
<label className="mb-1 block text-xs text-gray-500"></label>
<input type="text" value={username || ''} disabled className="w-full rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-400" />
<div className="grid items-start gap-6 xl:grid-cols-[minmax(0,1.15fr)_minmax(360px,0.85fr)]">
<SSLCard
ssl={ssl}
sslEnabled={sslEnabled}
sslMode={sslMode}
sslTarget={sslTarget}
sslEmail={sslEmail}
certPEM={certPEM}
keyPEM={keyPEM}
applyNow={applyNow}
savingSSL={savingSSL}
onRefresh={fetchSSL}
onEnabledChange={setSSLEnabled}
onModeChange={handleSSLModeChange}
onTargetChange={setSSLTarget}
onEmailChange={setSSLEmail}
onCertChange={setCertPEM}
onKeyChange={setKeyPEM}
onApplyNowChange={setApplyNow}
onSave={handleSaveSSL}
/>
<div className="rounded-lg border border-gray-200 bg-white p-5">
<h2 className="mb-4 flex items-center gap-2 text-sm font-semibold text-black">
<UserCog className="h-4 w-4" />
</h2>
<div className="space-y-4">
<div>
<label className="mb-1 block text-xs text-gray-500"></label>
<input type="text" value={username || ''} disabled className="w-full rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-400" />
</div>
<div>
<label className="mb-1 block text-xs text-gray-500"></label>
<input type="text" value={newUsername} onChange={(e) => setNewUsername(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="至少 3 位" />
</div>
<div className="border-t border-gray-100 pt-3">
<label className="mb-1 block text-xs text-gray-500"></label>
<input type="password" value={newPwd} onChange={(e) => setNewPwd(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="至少 6 位" />
</div>
<div>
<label className="mb-1 block text-xs text-gray-500"></label>
<input type="password" value={oldPwd} onChange={(e) => setOldPwd(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="输入当前密码以确认修改" />
</div>
<button onClick={handleSaveAccount} className="w-full rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800"></button>
</div>
<div>
<label className="mb-1 block text-xs text-gray-500"></label>
<input type="text" value={newUsername} onChange={(e) => setNewUsername(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="至少 3 位" />
</div>
<div className="border-t border-gray-100 pt-3">
<label className="mb-1 block text-xs text-gray-500"></label>
<input type="password" value={newPwd} onChange={(e) => setNewPwd(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="至少 6 位" />
</div>
<div>
<label className="mb-1 block text-xs text-gray-500"></label>
<input type="password" value={oldPwd} onChange={(e) => setOldPwd(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="输入当前密码以确认修改" />
</div>
<button onClick={handleSaveAccount} className="w-full rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800"></button>
</div>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-5">
<h2 className="mb-4 flex items-center gap-2 text-sm font-semibold text-black">
<LogIn className="h-4 w-4" />
<LoginLogCard logs={logs} logPage={logPage} pageSize={pageSize} totalPages={totalPages} setLogPage={setLogPage} />
</div>
)
}
interface SSLCardProps {
ssl: SSLSettings | null
sslEnabled: boolean
sslMode: SSLSettings['mode']
sslTarget: string
sslEmail: string
certPEM: string
keyPEM: string
applyNow: boolean
savingSSL: boolean
onRefresh: () => void
onEnabledChange: (enabled: boolean) => void
onModeChange: (mode: SSLSettings['mode']) => void
onTargetChange: (target: string) => void
onEmailChange: (email: string) => void
onCertChange: (cert: string) => void
onKeyChange: (key: string) => void
onApplyNowChange: (apply: boolean) => void
onSave: () => void
}
function SSLCard(props: SSLCardProps) {
const selectedSSL = props.ssl?.mode_certificates?.[props.sslMode]
const modeOptions: Array<{ value: SSLSettings['mode']; label: string }> = [
{ value: 'letsencrypt', label: 'Lets Encrypt' },
{ value: 'self_signed', label: '自签证书' },
{ value: 'uploaded', label: '上传证书' },
]
return (
<div className="rounded-lg border border-gray-200 bg-white p-5">
<div className="mb-4 flex items-center justify-between gap-3">
<h2 className="flex items-center gap-2 text-sm font-semibold text-black">
<ShieldCheck className="h-4 w-4" />SSL
</h2>
{logs.length === 0 ? (
<p className="text-sm text-gray-400"></p>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-gray-100 text-gray-400">
<th className="w-40 py-2 text-left font-medium"><span className="inline-flex items-center gap-1"><Clock className="h-3 w-3" /></span></th>
<th className="py-2 text-left font-medium"></th>
<th className="py-2 text-left font-medium"><span className="inline-flex items-center gap-1"><Globe className="h-3 w-3" />IP</span></th>
<th className="py-2 text-left font-medium"><span className="inline-flex items-center gap-1"><Monitor className="h-3 w-3" /></span></th>
<th className="py-2 text-left font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{logs.slice((logPage - 1) * pageSize, logPage * pageSize).map((log, index) => (
<tr key={`${log.time}-${index}`}>
<td className="whitespace-nowrap py-1.5 font-mono text-gray-500">{log.time}</td>
<td className="py-1.5 text-gray-700">{log.username}</td>
<td className="py-1.5 font-mono text-gray-500">{log.ip}</td>
<td className="max-w-[180px] truncate py-1.5 text-gray-500" title={log.user_agent}>{formatUA(log.user_agent)}</td>
<td className="py-1.5">
<span className={`rounded px-1.5 py-0.5 text-xs ${log.success ? 'bg-gray-100 text-gray-700' : 'bg-red-50 text-red-600'}`}>
{log.success ? '成功' : '失败'}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
{logs.length > pageSize && (
<div className="mt-3 flex items-center justify-between border-t border-gray-100 pt-3">
<span className="text-xs text-gray-400"> {logs.length} {logPage}/{totalPages} </span>
<div className="flex items-center gap-1">
<button onClick={() => setLogPage(1)} disabled={logPage === 1} className="rounded border border-gray-200 px-2 py-1 text-xs hover:bg-gray-50 disabled:opacity-30"></button>
<button onClick={() => setLogPage(p => Math.max(1, p - 1))} disabled={logPage === 1} className="rounded border border-gray-200 px-2 py-1 text-xs hover:bg-gray-50 disabled:opacity-30"></button>
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
let start = Math.max(1, logPage - 2)
if (start + 4 > totalPages) start = Math.max(1, totalPages - 4)
const page = start + i
if (page > totalPages) return null
return (
<button key={page} onClick={() => setLogPage(page)} className={`h-7 w-7 rounded text-xs ${page === logPage ? 'bg-black text-white' : 'border border-gray-200 hover:bg-gray-50'}`}>{page}</button>
)
})}
<button onClick={() => setLogPage(p => Math.min(totalPages, p + 1))} disabled={logPage >= totalPages} className="rounded border border-gray-200 px-2 py-1 text-xs hover:bg-gray-50 disabled:opacity-30"></button>
<button onClick={() => setLogPage(totalPages)} disabled={logPage >= totalPages} className="rounded border border-gray-200 px-2 py-1 text-xs hover:bg-gray-50 disabled:opacity-30"></button>
</div>
</div>
)}
</>
)}
<button onClick={props.onRefresh} className="rounded-md border border-gray-200 p-1.5 text-gray-500 hover:bg-gray-50" title="刷新">
<RefreshCw className="h-4 w-4" />
</button>
</div>
<div className="space-y-4">
<label className="flex items-center gap-2 text-sm text-gray-700">
<input type="checkbox" checked={props.sslEnabled} onChange={(e) => props.onEnabledChange(e.target.checked)} className="h-4 w-4 rounded border-gray-300" />
HTTPS / WSS
</label>
<div className="grid gap-2 sm:grid-cols-3">
{modeOptions.map((option) => (
<button
key={option.value}
onClick={() => props.onModeChange(option.value)}
className={`rounded-md border px-3 py-2 text-sm ${props.sslMode === option.value ? 'border-black bg-black text-white' : 'border-gray-200 text-gray-700 hover:bg-gray-50'}`}
>
{option.label}
</button>
))}
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<label className="mb-1 block text-xs text-gray-500">IP / </label>
<input
type="text"
value={props.sslTarget}
onChange={(e) => props.onTargetChange(e.target.value)}
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black"
placeholder={props.ssl?.detected_host || '服务器公网 IP 或域名'}
/>
</div>
{props.sslMode === 'letsencrypt' && (
<div>
<label className="mb-1 block text-xs text-gray-500"></label>
<input type="email" value={props.sslEmail} onChange={(e) => props.onEmailChange(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="admin@example.com" />
</div>
)}
</div>
{props.sslMode === 'letsencrypt' && (
<div className="rounded-md border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800">
IP Certbot 5.4+ 80 Lets Encrypt 访IP certbot
</div>
)}
{props.sslMode === 'self_signed' && (
<div className="rounded-md border border-gray-100 bg-gray-50 p-3 text-xs text-gray-600">
VNC
</div>
)}
{props.sslMode === 'uploaded' && (
<div className="grid gap-3 lg:grid-cols-2">
<div>
<label className="mb-1 block text-xs text-gray-500"> PEM / fullchain.pem</label>
<textarea value={props.certPEM} onChange={(e) => props.onCertChange(e.target.value)} rows={7} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 font-mono text-xs text-black" placeholder="-----BEGIN CERTIFICATE-----" />
</div>
<div>
<label className="mb-1 block text-xs text-gray-500"> PEM / privkey.pem</label>
<textarea value={props.keyPEM} onChange={(e) => props.onKeyChange(e.target.value)} rows={7} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 font-mono text-xs text-black" placeholder="-----BEGIN PRIVATE KEY-----" />
</div>
</div>
)}
{selectedSSL?.certificate ? (
<div className="rounded-md border border-gray-100 bg-gray-50 p-3 text-xs text-gray-600">
<div className="flex items-center gap-2 text-gray-800">
<Lock className="h-3.5 w-3.5" />
{selectedSSL.certificate.valid ? '有效' : '已过期或未生效'}
</div>
<div className="mt-1 font-mono">{selectedSSL.certificate.not_after}</div>
<div className="mt-1 truncate font-mono" title={selectedSSL.cert_path}>{selectedSSL.cert_path || '-'}</div>
{selectedSSL.last_error && <div className="mt-1 text-red-600">{selectedSSL.last_error}</div>}
</div>
) : (
<div className="rounded-md border border-gray-100 bg-gray-50 p-3 text-xs text-gray-600">
{props.sslMode === 'uploaded' ? '上传来源还没有保存证书,请粘贴证书和私钥后保存。' : '当前来源还没有保存证书,保存 SSL 设置时会自动生成或申请。'}
</div>
)}
<label className="flex items-center gap-2 text-xs text-gray-500">
<input type="checkbox" checked={props.applyNow} onChange={(e) => props.onApplyNowChange(e.target.checked)} className="h-4 w-4 rounded border-gray-300" />
</label>
<button onClick={props.onSave} disabled={props.savingSSL} className="inline-flex w-full items-center justify-center gap-2 rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50">
<Upload className="h-4 w-4" />
{props.savingSSL ? '保存中...' : '保存 SSL 设置'}
</button>
</div>
</div>
)
}
interface LoginLogCardProps {
logs: LoginLog[]
logPage: number
pageSize: number
totalPages: number
setLogPage: Dispatch<SetStateAction<number>>
}
function LoginLogCard({ logs, logPage, pageSize, totalPages, setLogPage }: LoginLogCardProps) {
return (
<div className="rounded-lg border border-gray-200 bg-white p-5">
<h2 className="mb-4 flex items-center gap-2 text-sm font-semibold text-black">
<LogIn className="h-4 w-4" />
</h2>
{logs.length === 0 ? (
<p className="text-sm text-gray-400"></p>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-gray-100 text-gray-400">
<th className="w-40 py-2 text-left font-medium"><span className="inline-flex items-center gap-1"><Clock className="h-3 w-3" /></span></th>
<th className="py-2 text-left font-medium"></th>
<th className="py-2 text-left font-medium"><span className="inline-flex items-center gap-1"><Globe className="h-3 w-3" />IP</span></th>
<th className="py-2 text-left font-medium"><span className="inline-flex items-center gap-1"><Monitor className="h-3 w-3" /></span></th>
<th className="py-2 text-left font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{logs.slice((logPage - 1) * pageSize, logPage * pageSize).map((log, index) => (
<tr key={`${log.time}-${index}`}>
<td className="whitespace-nowrap py-1.5 font-mono text-gray-500">{log.time}</td>
<td className="py-1.5 text-gray-700">{log.username}</td>
<td className="py-1.5 font-mono text-gray-500">{log.ip}</td>
<td className="max-w-[180px] truncate py-1.5 text-gray-500" title={log.user_agent}>{formatUA(log.user_agent)}</td>
<td className="py-1.5">
<span className={`rounded px-1.5 py-0.5 text-xs ${log.success ? 'bg-gray-100 text-gray-700' : 'bg-red-50 text-red-600'}`}>
{log.success ? '成功' : '失败'}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
{logs.length > pageSize && (
<div className="mt-3 flex items-center justify-between border-t border-gray-100 pt-3">
<span className="text-xs text-gray-400"> {logs.length} {logPage}/{totalPages} </span>
<div className="flex items-center gap-1">
<button onClick={() => setLogPage(1)} disabled={logPage === 1} className="rounded border border-gray-200 px-2 py-1 text-xs hover:bg-gray-50 disabled:opacity-30"></button>
<button onClick={() => setLogPage(p => Math.max(1, p - 1))} disabled={logPage === 1} className="rounded border border-gray-200 px-2 py-1 text-xs hover:bg-gray-50 disabled:opacity-30"></button>
<button onClick={() => setLogPage(p => Math.min(totalPages, p + 1))} disabled={logPage >= totalPages} className="rounded border border-gray-200 px-2 py-1 text-xs hover:bg-gray-50 disabled:opacity-30"></button>
<button onClick={() => setLogPage(totalPages)} disabled={logPage >= totalPages} className="rounded border border-gray-200 px-2 py-1 text-xs hover:bg-gray-50 disabled:opacity-30"></button>
</div>
</div>
)}
</>
)}
</div>
)
}
+41
View File
@@ -317,6 +317,47 @@ export interface AuditLog {
export const getLoginLogs = () =>
api.get<APIResponse<LoginLog[]>>('/login-logs')
export interface SSLCertificateInfo {
subject: string
issuer: string
dns_names: string[]
ip_names: string[]
not_before: string
not_after: string
valid: boolean
}
export interface SSLSettings {
enabled: boolean
mode: 'disabled' | 'letsencrypt' | 'self_signed' | 'uploaded'
target: string
email?: string
cert_path?: string
key_path?: string
last_issued_at?: string
last_error?: string
detected_host?: string
certificate?: SSLCertificateInfo
mode_certificates?: Record<string, SSLSettings>
needs_restart?: boolean
}
export interface UpdateSSLSettingsRequest {
enabled: boolean
mode: 'disabled' | 'letsencrypt' | 'self_signed' | 'uploaded'
target?: string
email?: string
cert_pem?: string
key_pem?: string
apply_now?: boolean
}
export const getSSLSettings = () =>
api.get<APIResponse<SSLSettings>>('/ssl')
export const updateSSLSettings = (data: UpdateSSLSettingsRequest) =>
api.put<APIResponse<SSLSettings>>('/ssl', data)
// Containers
export const getContainers = () =>
api.get<APIResponse<Container[]>>('/containers')