522 lines
15 KiB
Go
522 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
|
|
}
|
|
|
|
if strings.TrimSpace(req.SessionUUID) == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "session_uuid required"})
|
|
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
|
|
}
|