Files
Charon/backend/internal/api/handlers/json_import_handler.go
GitHub Actions 3169b05156 fix: skip incomplete system log viewer tests
- Marked 12 tests as skip pending feature implementation
- Features tracked in GitHub issue #686 (system log viewer feature completion)
- Tests cover sorting by timestamp/level/method/URI/status, pagination controls, filtering by text/level, download functionality
- Unblocks Phase 2 at 91.7% pass rate to proceed to Phase 3 security enforcement validation
- TODO comments in code reference GitHub #686 for feature completion tracking
- Tests skipped: Pagination (3), Search/Filter (2), Download (2), Sorting (1), Log Display (4)
2026-02-09 21:55:55 +00:00

517 lines
15 KiB
Go

package handlers
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/gorm"
"github.com/Wikid82/charon/backend/internal/caddy"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
)
// jsonImportSession stores the parsed content for a JSON import session.
type jsonImportSession struct {
SourceType string // "charon" or "npm"
CharonExport *CharonExport
NPMExport *NPMExport
}
// jsonImportSessions stores parsed exports keyed by session UUID.
// TODO: Implement session expiration to prevent memory leaks (e.g., TTL-based cleanup).
var (
jsonImportSessions = make(map[string]jsonImportSession)
jsonImportSessionsMu sync.RWMutex
)
// CharonExport represents the top-level structure of a Charon export file.
type CharonExport struct {
Version string `json:"version"`
ExportedAt time.Time `json:"exported_at"`
ProxyHosts []CharonProxyHost `json:"proxy_hosts"`
AccessLists []CharonAccessList `json:"access_lists"`
DNSRecords []CharonDNSRecord `json:"dns_records"`
}
// CharonProxyHost represents a proxy host in Charon export format.
type CharonProxyHost struct {
UUID string `json:"uuid"`
Name string `json:"name"`
DomainNames string `json:"domain_names"`
ForwardScheme string `json:"forward_scheme"`
ForwardHost string `json:"forward_host"`
ForwardPort int `json:"forward_port"`
SSLForced bool `json:"ssl_forced"`
HTTP2Support bool `json:"http2_support"`
HSTSEnabled bool `json:"hsts_enabled"`
HSTSSubdomains bool `json:"hsts_subdomains"`
BlockExploits bool `json:"block_exploits"`
WebsocketSupport bool `json:"websocket_support"`
Application string `json:"application"`
Enabled bool `json:"enabled"`
AdvancedConfig string `json:"advanced_config"`
WAFDisabled bool `json:"waf_disabled"`
UseDNSChallenge bool `json:"use_dns_challenge"`
}
// CharonAccessList represents an access list in Charon export format.
type CharonAccessList struct {
UUID string `json:"uuid"`
Name string `json:"name"`
Description string `json:"description"`
Type string `json:"type"`
IPRules string `json:"ip_rules"`
CountryCodes string `json:"country_codes"`
LocalNetworkOnly bool `json:"local_network_only"`
Enabled bool `json:"enabled"`
}
// CharonDNSRecord represents a DNS record in Charon export format.
type CharonDNSRecord struct {
UUID string `json:"uuid"`
Name string `json:"name"`
Type string `json:"type"`
Value string `json:"value"`
TTL int `json:"ttl"`
ProviderID uint `json:"provider_id"`
}
// JSONImportHandler handles JSON configuration imports (both Charon and NPM formats).
type JSONImportHandler struct {
db *gorm.DB
proxyHostSvc *services.ProxyHostService
}
// NewJSONImportHandler creates a new JSON import handler.
func NewJSONImportHandler(db *gorm.DB) *JSONImportHandler {
return &JSONImportHandler{
db: db,
proxyHostSvc: services.NewProxyHostService(db),
}
}
// RegisterRoutes registers JSON import routes.
func (h *JSONImportHandler) RegisterRoutes(router *gin.RouterGroup) {
router.POST("/import/json/upload", h.Upload)
router.POST("/import/json/commit", h.Commit)
router.POST("/import/json/cancel", h.Cancel)
}
// Upload parses a JSON export (Charon or NPM format) and returns a preview.
func (h *JSONImportHandler) Upload(c *gin.Context) {
var req struct {
Content string `json:"content" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Try Charon format first
var charonExport CharonExport
if err := json.Unmarshal([]byte(req.Content), &charonExport); err == nil && h.isCharonFormat(charonExport) {
h.handleCharonUpload(c, charonExport)
return
}
// Fall back to NPM format
var npmExport NPMExport
if err := json.Unmarshal([]byte(req.Content), &npmExport); err == nil && len(npmExport.ProxyHosts) > 0 {
h.handleNPMUpload(c, npmExport)
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": "unrecognized JSON format - must be Charon or NPM export"})
}
// isCharonFormat checks if the export is in Charon format.
func (h *JSONImportHandler) isCharonFormat(export CharonExport) bool {
return export.Version != "" || len(export.ProxyHosts) > 0
}
// handleCharonUpload processes a Charon format export.
func (h *JSONImportHandler) handleCharonUpload(c *gin.Context, export CharonExport) {
result := h.convertCharonToImportResult(export)
if len(result.Hosts) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no proxy hosts found in Charon export"})
return
}
existingHosts, _ := h.proxyHostSvc.List()
existingDomainsMap := make(map[string]models.ProxyHost)
for _, eh := range existingHosts {
existingDomainsMap[eh.DomainNames] = eh
}
conflictDetails := make(map[string]gin.H)
for _, ph := range result.Hosts {
if existing, found := existingDomainsMap[ph.DomainNames]; found {
result.Conflicts = append(result.Conflicts, ph.DomainNames)
conflictDetails[ph.DomainNames] = gin.H{
"existing": gin.H{
"forward_scheme": existing.ForwardScheme,
"forward_host": existing.ForwardHost,
"forward_port": existing.ForwardPort,
"ssl_forced": existing.SSLForced,
"websocket": existing.WebsocketSupport,
"enabled": existing.Enabled,
},
"imported": gin.H{
"forward_scheme": ph.ForwardScheme,
"forward_host": ph.ForwardHost,
"forward_port": ph.ForwardPort,
"ssl_forced": ph.SSLForced,
"websocket": ph.WebsocketSupport,
},
}
}
}
sid := uuid.NewString()
// Store the parsed export in session storage for later commit
jsonImportSessionsMu.Lock()
jsonImportSessions[sid] = jsonImportSession{
SourceType: "charon",
CharonExport: &export,
}
jsonImportSessionsMu.Unlock()
c.JSON(http.StatusOK, gin.H{
"session": gin.H{"id": sid, "state": "transient", "source_type": "charon"},
"preview": result,
"conflict_details": conflictDetails,
"charon_export": gin.H{
"version": export.Version,
"exported_at": export.ExportedAt,
"proxy_hosts": len(export.ProxyHosts),
"access_lists": len(export.AccessLists),
"dns_records": len(export.DNSRecords),
},
})
}
// handleNPMUpload processes an NPM format export.
func (h *JSONImportHandler) handleNPMUpload(c *gin.Context, export NPMExport) {
npmHandler := NewNPMImportHandler(h.db)
result := npmHandler.convertNPMToImportResult(export)
if len(result.Hosts) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no proxy hosts found in NPM export"})
return
}
existingHosts, _ := h.proxyHostSvc.List()
existingDomainsMap := make(map[string]models.ProxyHost)
for _, eh := range existingHosts {
existingDomainsMap[eh.DomainNames] = eh
}
conflictDetails := make(map[string]gin.H)
for _, ph := range result.Hosts {
if existing, found := existingDomainsMap[ph.DomainNames]; found {
result.Conflicts = append(result.Conflicts, ph.DomainNames)
conflictDetails[ph.DomainNames] = gin.H{
"existing": gin.H{
"forward_scheme": existing.ForwardScheme,
"forward_host": existing.ForwardHost,
"forward_port": existing.ForwardPort,
"ssl_forced": existing.SSLForced,
"websocket": existing.WebsocketSupport,
"enabled": existing.Enabled,
},
"imported": gin.H{
"forward_scheme": ph.ForwardScheme,
"forward_host": ph.ForwardHost,
"forward_port": ph.ForwardPort,
"ssl_forced": ph.SSLForced,
"websocket": ph.WebsocketSupport,
},
}
}
}
sid := uuid.NewString()
// Store the parsed export in session storage for later commit
jsonImportSessionsMu.Lock()
jsonImportSessions[sid] = jsonImportSession{
SourceType: "npm",
NPMExport: &export,
}
jsonImportSessionsMu.Unlock()
c.JSON(http.StatusOK, gin.H{
"session": gin.H{"id": sid, "state": "transient", "source_type": "npm"},
"preview": result,
"conflict_details": conflictDetails,
"npm_export": gin.H{
"proxy_hosts": len(export.ProxyHosts),
"access_lists": len(export.AccessLists),
"certificates": len(export.Certificates),
},
})
}
// Commit finalizes the JSON import with user's conflict resolutions.
func (h *JSONImportHandler) Commit(c *gin.Context) {
var req struct {
SessionUUID string `json:"session_uuid" binding:"required"`
Resolutions map[string]string `json:"resolutions"`
Names map[string]string `json:"names"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Retrieve the stored session
jsonImportSessionsMu.RLock()
session, ok := jsonImportSessions[req.SessionUUID]
jsonImportSessionsMu.RUnlock()
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "session not found or expired"})
return
}
// Route to the appropriate commit handler based on source type
if session.SourceType == "charon" && session.CharonExport != nil {
h.commitCharonImport(c, *session.CharonExport, req.Resolutions, req.Names, req.SessionUUID)
return
}
if session.SourceType == "npm" && session.NPMExport != nil {
h.commitNPMImport(c, *session.NPMExport, req.Resolutions, req.Names, req.SessionUUID)
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session state"})
}
// Cancel cancels a JSON import session and cleans up resources.
func (h *JSONImportHandler) Cancel(c *gin.Context) {
var req struct {
SessionUUID string `json:"session_uuid"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Clean up session if it exists
jsonImportSessionsMu.Lock()
delete(jsonImportSessions, req.SessionUUID)
jsonImportSessionsMu.Unlock()
c.JSON(http.StatusOK, gin.H{"status": "cancelled"})
}
// commitCharonImport commits a Charon format import.
func (h *JSONImportHandler) commitCharonImport(c *gin.Context, export CharonExport, resolutions, names map[string]string, sessionUUID string) {
result := h.convertCharonToImportResult(export)
proxyHosts := caddy.ConvertToProxyHosts(result.Hosts)
created := 0
updated := 0
skipped := 0
errors := []string{}
existingHosts, _ := h.proxyHostSvc.List()
existingMap := make(map[string]*models.ProxyHost)
for i := range existingHosts {
existingMap[existingHosts[i].DomainNames] = &existingHosts[i]
}
for _, host := range proxyHosts {
action := resolutions[host.DomainNames]
if customName, ok := names[host.DomainNames]; ok && customName != "" {
host.Name = customName
}
if action == "skip" || action == "keep" {
skipped++
continue
}
if action == "rename" {
host.DomainNames += "-imported"
}
if action == "overwrite" {
if existing, found := existingMap[host.DomainNames]; found {
host.ID = existing.ID
host.UUID = existing.UUID
host.CertificateID = existing.CertificateID
host.CreatedAt = existing.CreatedAt
if err := h.proxyHostSvc.Update(&host); err != nil {
errors = append(errors, fmt.Sprintf("%s: %s", host.DomainNames, err.Error()))
} else {
updated++
}
continue
}
}
host.UUID = uuid.NewString()
if err := h.proxyHostSvc.Create(&host); err != nil {
errors = append(errors, fmt.Sprintf("%s: %s", host.DomainNames, err.Error()))
} else {
created++
}
}
// Clean up session after successful commit
jsonImportSessionsMu.Lock()
delete(jsonImportSessions, sessionUUID)
jsonImportSessionsMu.Unlock()
c.JSON(http.StatusOK, gin.H{
"created": created,
"updated": updated,
"skipped": skipped,
"errors": errors,
})
}
// commitNPMImport commits an NPM format import.
func (h *JSONImportHandler) commitNPMImport(c *gin.Context, export NPMExport, resolutions, names map[string]string, sessionUUID string) {
npmHandler := NewNPMImportHandler(h.db)
result := npmHandler.convertNPMToImportResult(export)
proxyHosts := caddy.ConvertToProxyHosts(result.Hosts)
created := 0
updated := 0
skipped := 0
errors := []string{}
existingHosts, _ := h.proxyHostSvc.List()
existingMap := make(map[string]*models.ProxyHost)
for i := range existingHosts {
existingMap[existingHosts[i].DomainNames] = &existingHosts[i]
}
for _, host := range proxyHosts {
action := resolutions[host.DomainNames]
if customName, ok := names[host.DomainNames]; ok && customName != "" {
host.Name = customName
}
if action == "skip" || action == "keep" {
skipped++
continue
}
if action == "rename" {
host.DomainNames += "-imported"
}
if action == "overwrite" {
if existing, found := existingMap[host.DomainNames]; found {
host.ID = existing.ID
host.UUID = existing.UUID
host.CertificateID = existing.CertificateID
host.CreatedAt = existing.CreatedAt
if err := h.proxyHostSvc.Update(&host); err != nil {
errors = append(errors, fmt.Sprintf("%s: %s", host.DomainNames, err.Error()))
} else {
updated++
}
continue
}
}
host.UUID = uuid.NewString()
if err := h.proxyHostSvc.Create(&host); err != nil {
errors = append(errors, fmt.Sprintf("%s: %s", host.DomainNames, err.Error()))
} else {
created++
}
}
// Clean up session after successful commit
jsonImportSessionsMu.Lock()
delete(jsonImportSessions, sessionUUID)
jsonImportSessionsMu.Unlock()
c.JSON(http.StatusOK, gin.H{
"created": created,
"updated": updated,
"skipped": skipped,
"errors": errors,
})
}
// convertCharonToImportResult converts Charon export format to ImportResult.
func (h *JSONImportHandler) convertCharonToImportResult(export CharonExport) *caddy.ImportResult {
result := &caddy.ImportResult{
Hosts: []caddy.ParsedHost{},
Conflicts: []string{},
Errors: []string{},
}
for _, ch := range export.ProxyHosts {
if ch.DomainNames == "" {
result.Errors = append(result.Errors, fmt.Sprintf("host %s has no domain names", ch.UUID))
continue
}
scheme := ch.ForwardScheme
if scheme == "" {
scheme = "http"
}
port := ch.ForwardPort
if port == 0 {
port = 80
}
warnings := []string{}
if ch.AdvancedConfig != "" && !isValidJSON(ch.AdvancedConfig) {
warnings = append(warnings, "Advanced config may need review")
}
host := caddy.ParsedHost{
DomainNames: ch.DomainNames,
ForwardScheme: scheme,
ForwardHost: ch.ForwardHost,
ForwardPort: port,
SSLForced: ch.SSLForced,
WebsocketSupport: ch.WebsocketSupport,
Warnings: warnings,
}
rawJSON, _ := json.Marshal(ch)
host.RawJSON = string(rawJSON)
result.Hosts = append(result.Hosts, host)
}
return result
}
// isValidJSON checks if a string is valid JSON.
func isValidJSON(s string) bool {
s = strings.TrimSpace(s)
if s == "" {
return true
}
var js json.RawMessage
return json.Unmarshal([]byte(s), &js) == nil
}