feat: add external notification templates management
- Introduced NotificationTemplate model for reusable external notification templates. - Implemented CRUD operations for external templates in NotificationService. - Added routes for managing external templates in the API. - Created frontend API methods for external templates. - Enhanced Notifications page to manage external templates with a form and list view. - Updated layout and login pages to improve UI consistency. - Added integration tests for proxy host management with improved error handling.
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type NotificationTemplateHandler struct {
|
||||
service *services.NotificationService
|
||||
}
|
||||
|
||||
func NewNotificationTemplateHandler(s *services.NotificationService) *NotificationTemplateHandler {
|
||||
return &NotificationTemplateHandler{service: s}
|
||||
}
|
||||
|
||||
func (h *NotificationTemplateHandler) List(c *gin.Context) {
|
||||
list, err := h.service.ListTemplates()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list templates"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, list)
|
||||
}
|
||||
|
||||
func (h *NotificationTemplateHandler) Create(c *gin.Context) {
|
||||
var t models.NotificationTemplate
|
||||
if err := c.ShouldBindJSON(&t); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.service.CreateTemplate(&t); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create template"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, t)
|
||||
}
|
||||
|
||||
func (h *NotificationTemplateHandler) Update(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var t models.NotificationTemplate
|
||||
if err := c.ShouldBindJSON(&t); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
t.ID = id
|
||||
if err := h.service.UpdateTemplate(&t); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update template"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, t)
|
||||
}
|
||||
|
||||
func (h *NotificationTemplateHandler) Delete(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if err := h.service.DeleteTemplate(id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete template"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
|
||||
}
|
||||
|
||||
// Preview allows rendering an arbitrary template (provided in request) or a stored template by id.
|
||||
func (h *NotificationTemplateHandler) Preview(c *gin.Context) {
|
||||
var raw map[string]interface{}
|
||||
if err := c.ShouldBindJSON(&raw); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var tmplStr string
|
||||
if id, ok := raw["template_id"].(string); ok && id != "" {
|
||||
t, err := h.service.GetTemplate(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "template not found"})
|
||||
return
|
||||
}
|
||||
tmplStr = t.Config
|
||||
} else if s, ok := raw["template"].(string); ok {
|
||||
tmplStr = s
|
||||
}
|
||||
|
||||
data := map[string]interface{}{}
|
||||
if d, ok := raw["data"].(map[string]interface{}); ok {
|
||||
data = d
|
||||
}
|
||||
|
||||
// Build a fake provider to leverage existing RenderTemplate logic
|
||||
provider := models.NotificationProvider{Template: "custom", Config: tmplStr}
|
||||
rendered, parsed, err := h.service.RenderTemplate(provider, data)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "rendered": rendered})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"rendered": rendered, "parsed": parsed})
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"strings"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func setupDB(t *testing.T) *gorm.DB {
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
db.AutoMigrate(&models.NotificationTemplate{})
|
||||
return db
|
||||
}
|
||||
|
||||
func TestNotificationTemplateCRUD(t *testing.T) {
|
||||
db := setupDB(t)
|
||||
svc := services.NewNotificationService(db)
|
||||
h := NewNotificationTemplateHandler(svc)
|
||||
|
||||
// Create
|
||||
payload := `{"name":"Simple","config":"{\"title\": \"{{.Title}}\"}","template":"custom"}`
|
||||
req := httptest.NewRequest("POST", "/", nil)
|
||||
req.Body = io.NopCloser(strings.NewReader(payload))
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = req
|
||||
h.Create(c)
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
|
||||
// List
|
||||
req2 := httptest.NewRequest("GET", "/", nil)
|
||||
w2 := httptest.NewRecorder()
|
||||
c2, _ := gin.CreateTestContext(w2)
|
||||
c2.Request = req2
|
||||
h.List(c2)
|
||||
require.Equal(t, http.StatusOK, w2.Code)
|
||||
var list []models.NotificationTemplate
|
||||
require.NoError(t, json.Unmarshal(w2.Body.Bytes(), &list))
|
||||
require.Len(t, list, 1)
|
||||
}
|
||||
@@ -32,6 +32,7 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error {
|
||||
&models.ImportSession{},
|
||||
&models.Notification{},
|
||||
&models.NotificationProvider{},
|
||||
&models.NotificationTemplate{},
|
||||
&models.UptimeMonitor{},
|
||||
&models.UptimeHeartbeat{},
|
||||
&models.UptimeHost{},
|
||||
@@ -163,6 +164,14 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error {
|
||||
protected.POST("/notifications/providers/preview", notificationProviderHandler.Preview)
|
||||
protected.GET("/notifications/templates", notificationProviderHandler.Templates)
|
||||
|
||||
// External notification templates (saved templates for providers)
|
||||
notificationTemplateHandler := handlers.NewNotificationTemplateHandler(notificationService)
|
||||
protected.GET("/notifications/external-templates", notificationTemplateHandler.List)
|
||||
protected.POST("/notifications/external-templates", notificationTemplateHandler.Create)
|
||||
protected.PUT("/notifications/external-templates/:id", notificationTemplateHandler.Update)
|
||||
protected.DELETE("/notifications/external-templates/:id", notificationTemplateHandler.Delete)
|
||||
protected.POST("/notifications/external-templates/preview", notificationTemplateHandler.Preview)
|
||||
|
||||
// Start background checker (every 1 minute)
|
||||
go func() {
|
||||
// Wait a bit for server to start
|
||||
|
||||
30
backend/internal/models/notification_template.go
Normal file
30
backend/internal/models/notification_template.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// NotificationTemplate represents a reusable external notification template
|
||||
// that can be applied when sending webhooks or other external notifications.
|
||||
type NotificationTemplate struct {
|
||||
ID string `gorm:"primaryKey" json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
// Config holds the JSON/template body for external webhook payloads
|
||||
Config string `json:"config"`
|
||||
// Template is a hint: minimal|detailed|custom (optional)
|
||||
Template string `json:"template" gorm:"default:minimal"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (t *NotificationTemplate) BeforeCreate(tx *gorm.DB) (err error) {
|
||||
if t.ID == "" {
|
||||
t.ID = uuid.New().String()
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -226,8 +226,17 @@ func (s *NotificationService) sendCustomWebhook(p models.NotificationProvider, d
|
||||
port = "80"
|
||||
}
|
||||
}
|
||||
targetURL := fmt.Sprintf("%s://%s%s", u.Scheme, net.JoinHostPort(selectedIP.String(), port), u.RequestURI())
|
||||
req, err := http.NewRequest("POST", targetURL, &body)
|
||||
// Construct a safe URL using the resolved IP:port for the Host component,
|
||||
// while preserving the original path and query from the user-provided URL.
|
||||
// This makes the destination hostname unambiguously an IP that we resolved
|
||||
// and prevents accidental requests to private/internal addresses.
|
||||
safeURL := &neturl.URL{
|
||||
Scheme: u.Scheme,
|
||||
Host: net.JoinHostPort(selectedIP.String(), port),
|
||||
Path: u.Path,
|
||||
RawQuery: u.RawQuery,
|
||||
}
|
||||
req, err := http.NewRequest("POST", safeURL.String(), &body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create webhook request: %w", err)
|
||||
}
|
||||
@@ -328,6 +337,35 @@ func (s *NotificationService) TestProvider(provider models.NotificationProvider)
|
||||
return shoutrrr.Send(url, "Test notification from Charon")
|
||||
}
|
||||
|
||||
// Templates (external notification templates) management
|
||||
func (s *NotificationService) ListTemplates() ([]models.NotificationTemplate, error) {
|
||||
var list []models.NotificationTemplate
|
||||
if err := s.DB.Order("created_at desc").Find(&list).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (s *NotificationService) GetTemplate(id string) (*models.NotificationTemplate, error) {
|
||||
var t models.NotificationTemplate
|
||||
if err := s.DB.First(&t, "id = ?", id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
func (s *NotificationService) CreateTemplate(t *models.NotificationTemplate) error {
|
||||
return s.DB.Create(t).Error
|
||||
}
|
||||
|
||||
func (s *NotificationService) UpdateTemplate(t *models.NotificationTemplate) error {
|
||||
return s.DB.Save(t).Error
|
||||
}
|
||||
|
||||
func (s *NotificationService) DeleteTemplate(id string) error {
|
||||
return s.DB.Delete(&models.NotificationTemplate{}, "id = ?", id).Error
|
||||
}
|
||||
|
||||
// RenderTemplate renders a provider template with provided data and returns
|
||||
// the rendered JSON string and the parsed object for previewing/validation.
|
||||
func (s *NotificationService) RenderTemplate(p models.NotificationProvider, data map[string]interface{}) (string, interface{}, error) {
|
||||
|
||||
58
data/caddy/config-1764440347.json
Normal file
58
data/caddy/config-1764440347.json
Normal file
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"apps": {
|
||||
"http": {
|
||||
"servers": {
|
||||
"charon_server": {
|
||||
"listen": [
|
||||
":80",
|
||||
":443"
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"handler": "rewrite",
|
||||
"uri": "/unknown.html"
|
||||
},
|
||||
{
|
||||
"handler": "file_server",
|
||||
"root": "/app/frontend/dist"
|
||||
}
|
||||
],
|
||||
"terminal": true
|
||||
}
|
||||
],
|
||||
"automatic_https": {},
|
||||
"logs": {
|
||||
"default_logger_name": "access_log"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"logging": {
|
||||
"logs": {
|
||||
"access": {
|
||||
"writer": {
|
||||
"output": "file",
|
||||
"filename": "/app/data/logs/access.log",
|
||||
"roll": true,
|
||||
"roll_size_mb": 10,
|
||||
"roll_keep": 5,
|
||||
"roll_keep_days": 7
|
||||
},
|
||||
"encoder": {
|
||||
"format": "json"
|
||||
},
|
||||
"level": "INFO",
|
||||
"include": [
|
||||
"http.log.access.access_log"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
"module": "file_system",
|
||||
"root": "/app/data/caddy/data"
|
||||
}
|
||||
}
|
||||
58
data/caddy/config-1764440652.json
Normal file
58
data/caddy/config-1764440652.json
Normal file
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"apps": {
|
||||
"http": {
|
||||
"servers": {
|
||||
"charon_server": {
|
||||
"listen": [
|
||||
":80",
|
||||
":443"
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"handler": "rewrite",
|
||||
"uri": "/unknown.html"
|
||||
},
|
||||
{
|
||||
"handler": "file_server",
|
||||
"root": "/app/frontend/dist"
|
||||
}
|
||||
],
|
||||
"terminal": true
|
||||
}
|
||||
],
|
||||
"automatic_https": {},
|
||||
"logs": {
|
||||
"default_logger_name": "access_log"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"logging": {
|
||||
"logs": {
|
||||
"access": {
|
||||
"writer": {
|
||||
"output": "file",
|
||||
"filename": "/app/data/logs/access.log",
|
||||
"roll": true,
|
||||
"roll_size_mb": 10,
|
||||
"roll_keep": 5,
|
||||
"roll_keep_days": 7
|
||||
},
|
||||
"encoder": {
|
||||
"format": "json"
|
||||
},
|
||||
"level": "INFO",
|
||||
"include": [
|
||||
"http.log.access.access_log"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
"module": "file_system",
|
||||
"root": "/app/data/caddy/data"
|
||||
}
|
||||
}
|
||||
75
data/caddy/config-1764440734.json
Normal file
75
data/caddy/config-1764440734.json
Normal file
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"apps": {
|
||||
"http": {
|
||||
"servers": {
|
||||
"charon_server": {
|
||||
"listen": [
|
||||
":80",
|
||||
":443"
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"handler": "rewrite",
|
||||
"uri": "/unknown.html"
|
||||
},
|
||||
{
|
||||
"handler": "file_server",
|
||||
"root": "/app/frontend/dist"
|
||||
}
|
||||
],
|
||||
"terminal": true
|
||||
}
|
||||
],
|
||||
"automatic_https": {},
|
||||
"logs": {
|
||||
"default_logger_name": "access_log"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tls": {
|
||||
"automation": {
|
||||
"policies": [
|
||||
{
|
||||
"issuers": [
|
||||
{
|
||||
"email": "admin@example.com",
|
||||
"module": "acme"
|
||||
},
|
||||
{
|
||||
"module": "zerossl"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"logging": {
|
||||
"logs": {
|
||||
"access": {
|
||||
"writer": {
|
||||
"output": "file",
|
||||
"filename": "/app/data/logs/access.log",
|
||||
"roll": true,
|
||||
"roll_size_mb": 10,
|
||||
"roll_keep": 5,
|
||||
"roll_keep_days": 7
|
||||
},
|
||||
"encoder": {
|
||||
"format": "json"
|
||||
},
|
||||
"level": "INFO",
|
||||
"include": [
|
||||
"http.log.access.access_log"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
"module": "file_system",
|
||||
"root": "/app/data/caddy/data"
|
||||
}
|
||||
}
|
||||
99
data/caddy/config-1764440747.json
Normal file
99
data/caddy/config-1764440747.json
Normal file
@@ -0,0 +1,99 @@
|
||||
{
|
||||
"apps": {
|
||||
"http": {
|
||||
"servers": {
|
||||
"charon_server": {
|
||||
"listen": [
|
||||
":80",
|
||||
":443"
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"match": [
|
||||
{
|
||||
"host": [
|
||||
"test2.localhost"
|
||||
]
|
||||
}
|
||||
],
|
||||
"handle": [
|
||||
{
|
||||
"handler": "vars"
|
||||
},
|
||||
{
|
||||
"flush_interval": -1,
|
||||
"handler": "reverse_proxy",
|
||||
"upstreams": [
|
||||
{
|
||||
"dial": "host.docker.internal:8081"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"terminal": true
|
||||
},
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"handler": "rewrite",
|
||||
"uri": "/unknown.html"
|
||||
},
|
||||
{
|
||||
"handler": "file_server",
|
||||
"root": "/app/frontend/dist"
|
||||
}
|
||||
],
|
||||
"terminal": true
|
||||
}
|
||||
],
|
||||
"automatic_https": {},
|
||||
"logs": {
|
||||
"default_logger_name": "access_log"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tls": {
|
||||
"automation": {
|
||||
"policies": [
|
||||
{
|
||||
"issuers": [
|
||||
{
|
||||
"email": "admin@example.com",
|
||||
"module": "acme"
|
||||
},
|
||||
{
|
||||
"module": "zerossl"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"logging": {
|
||||
"logs": {
|
||||
"access": {
|
||||
"writer": {
|
||||
"output": "file",
|
||||
"filename": "/app/data/logs/access.log",
|
||||
"roll": true,
|
||||
"roll_size_mb": 10,
|
||||
"roll_keep": 5,
|
||||
"roll_keep_days": 7
|
||||
},
|
||||
"encoder": {
|
||||
"format": "json"
|
||||
},
|
||||
"level": "INFO",
|
||||
"include": [
|
||||
"http.log.access.access_log"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
"module": "file_system",
|
||||
"root": "/app/data/caddy/data"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBxDCCAWqgAwIBAgIRAKxvOpSX7dY/DFcxlxeVYlwwCgYIKoZIzj0EAwIwMzEx
|
||||
MC8GA1UEAxMoQ2FkZHkgTG9jYWwgQXV0aG9yaXR5IC0gRUNDIEludGVybWVkaWF0
|
||||
ZTAeFw0yNTExMjkxODI1NDdaFw0yNTExMzAwNjI1NDdaMAAwWTATBgcqhkjOPQIB
|
||||
BggqhkjOPQMBBwNCAARW60CpqeJ5U4xDgS0qtdxoxImMBtfgBQdL84thZUu2aUbn
|
||||
/PwNsnplo1zK6T3XUQPs6oMp4vT3Ay0HhkZJI8u3o4GRMIGOMA4GA1UdDwEB/wQE
|
||||
AwIHgDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwHQYDVR0OBBYEFC+t
|
||||
WqIT91x5K/dJmk4048hU+lFMMB8GA1UdIwQYMBaAFMhhDbgnCp960HTlyVla/ULK
|
||||
skuUMB0GA1UdEQEB/wQTMBGCD3Rlc3QyLmxvY2FsaG9zdDAKBggqhkjOPQQDAgNI
|
||||
ADBFAiAJycukC7hroy2QaM+ORchMwba7A83f5qSjdnDmM/h8AQIhAMx0AbU4nJlF
|
||||
j2iAKlVsZaPze+F3OfBbm0Jg7emfFmx4
|
||||
-----END CERTIFICATE-----
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBxzCCAW2gAwIBAgIQLEy0I3NtCyk+vKrWiqWa9TAKBggqhkjOPQQDAjAwMS4w
|
||||
LAYDVQQDEyVDYWRkeSBMb2NhbCBBdXRob3JpdHkgLSAyMDI1IEVDQyBSb290MB4X
|
||||
DTI1MTEyOTE4MjU0N1oXDTI1MTIwNjE4MjU0N1owMzExMC8GA1UEAxMoQ2FkZHkg
|
||||
TG9jYWwgQXV0aG9yaXR5IC0gRUNDIEludGVybWVkaWF0ZTBZMBMGByqGSM49AgEG
|
||||
CCqGSM49AwEHA0IABBzP8BZUlO8uEk7c09Sl3I68CS+AC60w+l+DIKuaqhi+sCJM
|
||||
ksM3MFZ6SfGs8rURi6MZqqkRfJqsF6ma/ko/oiyjZjBkMA4GA1UdDwEB/wQEAwIB
|
||||
BjASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBTIYQ24JwqfetB05clZWv1C
|
||||
yrJLlDAfBgNVHSMEGDAWgBREcndLnTskIjkt5DalMgkrk+/+iDAKBggqhkjOPQQD
|
||||
AgNIADBFAiAZ1KKvFsJGdbCSbTpEl5CQQrPf7PQzYN7w9AFpcGl3iQIhAKMy7uy8
|
||||
Hr0w5vrl/1R9FcrvNZKsDwquCBVr/BKAAIsk
|
||||
-----END CERTIFICATE-----
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"sans": [
|
||||
"test2.localhost"
|
||||
],
|
||||
"issuer_data": null
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
-----BEGIN EC PRIVATE KEY-----
|
||||
MHcCAQEEIHuTybGDaH2llLl0Ye/IRlcL7UEluaswZWqFHo7A4WZyoAoGCCqGSM49
|
||||
AwEHoUQDQgAEVutAqanieVOMQ4EtKrXcaMSJjAbX4AUHS/OLYWVLtmlG5/z8DbJ6
|
||||
ZaNcyuk911ED7OqDKeL09wMtB4ZGSSPLtw==
|
||||
-----END EC PRIVATE KEY-----
|
||||
1
data/caddy/data/last_clean.json
Normal file
1
data/caddy/data/last_clean.json
Normal file
@@ -0,0 +1 @@
|
||||
{"tls":{"timestamp":"2025-11-29T18:19:07.702634586Z","instance_id":"2acc9ef3-fc3e-40f5-9462-d6682722eb94"}}
|
||||
12
data/caddy/data/pki/authorities/local/intermediate.crt
Normal file
12
data/caddy/data/pki/authorities/local/intermediate.crt
Normal file
@@ -0,0 +1,12 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBxzCCAW2gAwIBAgIQLEy0I3NtCyk+vKrWiqWa9TAKBggqhkjOPQQDAjAwMS4w
|
||||
LAYDVQQDEyVDYWRkeSBMb2NhbCBBdXRob3JpdHkgLSAyMDI1IEVDQyBSb290MB4X
|
||||
DTI1MTEyOTE4MjU0N1oXDTI1MTIwNjE4MjU0N1owMzExMC8GA1UEAxMoQ2FkZHkg
|
||||
TG9jYWwgQXV0aG9yaXR5IC0gRUNDIEludGVybWVkaWF0ZTBZMBMGByqGSM49AgEG
|
||||
CCqGSM49AwEHA0IABBzP8BZUlO8uEk7c09Sl3I68CS+AC60w+l+DIKuaqhi+sCJM
|
||||
ksM3MFZ6SfGs8rURi6MZqqkRfJqsF6ma/ko/oiyjZjBkMA4GA1UdDwEB/wQEAwIB
|
||||
BjASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBTIYQ24JwqfetB05clZWv1C
|
||||
yrJLlDAfBgNVHSMEGDAWgBREcndLnTskIjkt5DalMgkrk+/+iDAKBggqhkjOPQQD
|
||||
AgNIADBFAiAZ1KKvFsJGdbCSbTpEl5CQQrPf7PQzYN7w9AFpcGl3iQIhAKMy7uy8
|
||||
Hr0w5vrl/1R9FcrvNZKsDwquCBVr/BKAAIsk
|
||||
-----END CERTIFICATE-----
|
||||
5
data/caddy/data/pki/authorities/local/intermediate.key
Normal file
5
data/caddy/data/pki/authorities/local/intermediate.key
Normal file
@@ -0,0 +1,5 @@
|
||||
-----BEGIN EC PRIVATE KEY-----
|
||||
MHcCAQEEIK62nlQXS+XmU6lLY1dkxanQW+5C+hDMRkAyMeLVPDrioAoGCCqGSM49
|
||||
AwEHoUQDQgAEHM/wFlSU7y4STtzT1KXcjrwJL4ALrTD6X4Mgq5qqGL6wIkySwzcw
|
||||
VnpJ8azytRGLoxmqqRF8mqwXqZr+Sj+iLA==
|
||||
-----END EC PRIVATE KEY-----
|
||||
11
data/caddy/data/pki/authorities/local/root.crt
Normal file
11
data/caddy/data/pki/authorities/local/root.crt
Normal file
@@ -0,0 +1,11 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBojCCAUmgAwIBAgIQFfTjqoMpNZnTSWKmX53qCzAKBggqhkjOPQQDAjAwMS4w
|
||||
LAYDVQQDEyVDYWRkeSBMb2NhbCBBdXRob3JpdHkgLSAyMDI1IEVDQyBSb290MB4X
|
||||
DTI1MTEyOTE4MjU0N1oXDTM1MTAwODE4MjU0N1owMDEuMCwGA1UEAxMlQ2FkZHkg
|
||||
TG9jYWwgQXV0aG9yaXR5IC0gMjAyNSBFQ0MgUm9vdDBZMBMGByqGSM49AgEGCCqG
|
||||
SM49AwEHA0IABPSnVwHAJdl5JJN8JT2K0VxGmtXMx1qMeQIq3bG891mR/Fa889/k
|
||||
PS8lb/txO1kDbkS46ZJZn1+iWRYGroHM9iejRTBDMA4GA1UdDwEB/wQEAwIBBjAS
|
||||
BgNVHRMBAf8ECDAGAQH/AgEBMB0GA1UdDgQWBBREcndLnTskIjkt5DalMgkrk+/+
|
||||
iDAKBggqhkjOPQQDAgNHADBEAiBcfxd1wNE1WakMLWMYU2kGCUTyB/S9MD0vlYtL
|
||||
AmTaUQIgJQ4Og2/PSGhG0UYGpICBI/dhxVkm7HQGKDiTaUNDHcE=
|
||||
-----END CERTIFICATE-----
|
||||
5
data/caddy/data/pki/authorities/local/root.key
Normal file
5
data/caddy/data/pki/authorities/local/root.key
Normal file
@@ -0,0 +1,5 @@
|
||||
-----BEGIN EC PRIVATE KEY-----
|
||||
MHcCAQEEIE4Z8xnl0mswc0hJile7xtFbVWhqcvgYS8ofcOY9rhJhoAoGCCqGSM49
|
||||
AwEHoUQDQgAE9KdXAcAl2Xkkk3wlPYrRXEaa1czHWox5Airdsbz3WZH8Vrzz3+Q9
|
||||
LyVv+3E7WQNuRLjpklmfX6JZFgaugcz2Jw==
|
||||
-----END EC PRIVATE KEY-----
|
||||
@@ -50,3 +50,41 @@ export const previewProvider = async (provider: Partial<NotificationProvider>, d
|
||||
const response = await client.post('/notifications/providers/preview', payload);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// External (saved) templates API
|
||||
export interface ExternalTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
config?: string;
|
||||
template?: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export const getExternalTemplates = async () => {
|
||||
const response = await client.get<ExternalTemplate[]>('/notifications/external-templates');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const createExternalTemplate = async (data: Partial<ExternalTemplate>) => {
|
||||
const response = await client.post<ExternalTemplate>('/notifications/external-templates', data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const updateExternalTemplate = async (id: string, data: Partial<ExternalTemplate>) => {
|
||||
const response = await client.put<ExternalTemplate>(`/notifications/external-templates/${id}`, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const deleteExternalTemplate = async (id: string) => {
|
||||
await client.delete(`/notifications/external-templates/${id}`);
|
||||
};
|
||||
|
||||
export const previewExternalTemplate = async (templateId?: string, template?: string, data?: Record<string, any>) => {
|
||||
const payload: any = {};
|
||||
if (templateId) payload.template_id = templateId;
|
||||
if (template) payload.template = template;
|
||||
if (data) payload.data = data;
|
||||
const response = await client.post('/notifications/external-templates/preview', payload);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@@ -94,7 +94,9 @@ export default function Layout({ children }: LayoutProps) {
|
||||
`}>
|
||||
<div className={`h-20 flex items-center justify-center border-b border-gray-200 dark:border-gray-800`}>
|
||||
{isCollapsed ? (
|
||||
<img src="/logo.png" alt="Charon" className="h-12 w-10" />
|
||||
<img src="/logo.png" alt="Charon" style={{ height: '150px', width: 'auto' }}/>
|
||||
|
||||
|
||||
) : (
|
||||
<img src="/banner.png" alt="Charon" className="h-16 w-auto" />
|
||||
)}
|
||||
|
||||
@@ -60,7 +60,9 @@ export default function Login() {
|
||||
<div className="min-h-screen bg-dark-bg flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md space-y-4">
|
||||
<div className="flex items-center justify-center">
|
||||
<img src="/logo.png" alt="Charon" className="h-12 w-auto" />
|
||||
<img src="/logo.png" alt="Charon" style={{ height: '150px', width: 'auto' }}/>
|
||||
|
||||
|
||||
</div>
|
||||
<Card className="w-full" title="Login">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { getProviders, createProvider, updateProvider, deleteProvider, testProvider, getTemplates, previewProvider, NotificationProvider } from '../api/notifications';
|
||||
import { getProviders, createProvider, updateProvider, deleteProvider, testProvider, getTemplates, previewProvider, NotificationProvider, getExternalTemplates, previewExternalTemplate, ExternalTemplate, createExternalTemplate, updateExternalTemplate, deleteExternalTemplate } from '../api/notifications';
|
||||
import { Card } from '../components/ui/Card';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { Bell, Plus, Trash2, Edit2, Send, Check, X, Loader2 } from 'lucide-react';
|
||||
@@ -51,15 +51,22 @@ const ProviderForm: React.FC<{
|
||||
setPreviewContent(null);
|
||||
setPreviewError(null);
|
||||
try {
|
||||
const res = await previewProvider(formData as Partial<NotificationProvider>);
|
||||
if (res.parsed) setPreviewContent(JSON.stringify(res.parsed, null, 2)); else setPreviewContent(res.rendered);
|
||||
// If using an external saved template (id), call previewExternalTemplate with template_id
|
||||
if (formData.template && typeof formData.template === 'string' && formData.template.length === 36) {
|
||||
const res = await previewExternalTemplate(formData.template, undefined, undefined);
|
||||
if (res.parsed) setPreviewContent(JSON.stringify(res.parsed, null, 2)); else setPreviewContent(res.rendered);
|
||||
} else {
|
||||
const res = await previewProvider(formData as Partial<NotificationProvider>);
|
||||
if (res.parsed) setPreviewContent(JSON.stringify(res.parsed, null, 2)); else setPreviewContent(res.rendered);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setPreviewError(err?.response?.data?.error || err?.message || 'Failed to generate preview');
|
||||
}
|
||||
};
|
||||
|
||||
const type = watch('type');
|
||||
const { data: templatesList } = useQuery({ queryKey: ['notificationTemplates'], queryFn: getTemplates });
|
||||
const { data: builtins } = useQuery({ queryKey: ['notificationTemplates'], queryFn: getTemplates });
|
||||
const { data: externalTemplates } = useQuery({ queryKey: ['externalTemplates'], queryFn: getExternalTemplates });
|
||||
const template = watch('template');
|
||||
|
||||
const setTemplate = (templateStr: string, templateName?: string) => {
|
||||
@@ -125,7 +132,12 @@ const ProviderForm: React.FC<{
|
||||
<div className="mt-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Template</label>
|
||||
<select {...register('template')} className="mt-1 block w-full rounded-md border-gray-300">
|
||||
{templatesList?.map((t: any) => (
|
||||
{/* Built-in template options */}
|
||||
{builtins?.map((t: any) => (
|
||||
<option key={t.id} value={t.id}>{t.name}</option>
|
||||
))}
|
||||
{/* External saved templates (id values are UUIDs) */}
|
||||
{externalTemplates?.map((t: any) => (
|
||||
<option key={t.id} value={t.id}>{t.name}</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -213,16 +225,82 @@ const ProviderForm: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
const TemplateForm: React.FC<{
|
||||
initialData?: Partial<ExternalTemplate>;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: Partial<ExternalTemplate>) => void;
|
||||
}> = ({ initialData, onClose, onSubmit }) => {
|
||||
const { register, handleSubmit, watch } = useForm({
|
||||
defaultValues: initialData || { template: 'custom', config: '' }
|
||||
});
|
||||
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
const [previewErr, setPreviewErr] = useState<string | null>(null);
|
||||
|
||||
const handlePreview = async () => {
|
||||
setPreview(null);
|
||||
setPreviewErr(null);
|
||||
const form = watch();
|
||||
try {
|
||||
const res = await previewExternalTemplate(undefined, form.config, { Title: 'Preview Title', Message: 'Preview Message', Time: new Date().toISOString(), EventType: 'preview' });
|
||||
if (res.parsed) setPreview(JSON.stringify(res.parsed, null, 2)); else setPreview(res.rendered);
|
||||
} catch (err: any) {
|
||||
setPreviewErr(err?.response?.data?.error || err?.message || 'Preview failed');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Name</label>
|
||||
<input {...register('name', { required: true })} className="mt-1 block w-full rounded-md" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Description</label>
|
||||
<input {...register('description')} className="mt-1 block w-full rounded-md" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Template Type</label>
|
||||
<select {...register('template')} className="mt-1 block w-full rounded-md">
|
||||
<option value="minimal">Minimal</option>
|
||||
<option value="detailed">Detailed</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Config (JSON/template)</label>
|
||||
<textarea {...register('config')} rows={6} className="mt-1 block w-full font-mono text-xs rounded-md" />
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" onClick={onClose}>Cancel</Button>
|
||||
<Button type="button" variant="secondary" onClick={handlePreview}>Preview</Button>
|
||||
<Button type="submit">Save</Button>
|
||||
</div>
|
||||
{previewErr && <div className="text-sm text-red-600">{previewErr}</div>}
|
||||
{preview && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Preview</label>
|
||||
<pre className="mt-1 p-2 bg-gray-50 dark:bg-gray-800 rounded text-xs overflow-auto whitespace-pre-wrap">{preview}</pre>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
const Notifications: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [managingTemplates, setManagingTemplates] = useState(false);
|
||||
const [editingTemplateId, setEditingTemplateId] = useState<string | null>(null);
|
||||
|
||||
const { data: providers, isLoading } = useQuery({
|
||||
queryKey: ['notificationProviders'],
|
||||
queryFn: getProviders,
|
||||
});
|
||||
|
||||
const { data: externalTemplates } = useQuery({ queryKey: ['externalTemplates'], queryFn: getExternalTemplates });
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: createProvider,
|
||||
onSuccess: () => {
|
||||
@@ -246,6 +324,21 @@ const Notifications: React.FC = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const createTemplateMutation = useMutation({
|
||||
mutationFn: (data: Partial<ExternalTemplate>) => createExternalTemplate(data),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['externalTemplates'] }),
|
||||
});
|
||||
|
||||
const updateTemplateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Partial<ExternalTemplate> }) => updateExternalTemplate(id, data),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['externalTemplates'] }),
|
||||
});
|
||||
|
||||
const deleteTemplateMutation = useMutation({
|
||||
mutationFn: (id: string) => deleteExternalTemplate(id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['externalTemplates'] }),
|
||||
});
|
||||
|
||||
const testMutation = useMutation({
|
||||
mutationFn: testProvider,
|
||||
onSuccess: () => alert('Test notification sent!'),
|
||||
@@ -267,6 +360,72 @@ const Notifications: React.FC = () => {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* External Templates Management */}
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">External Templates</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={() => setManagingTemplates(!managingTemplates)} variant="secondary" size="sm">
|
||||
{managingTemplates ? 'Hide' : 'Manage Templates'}
|
||||
</Button>
|
||||
<Button onClick={() => { setEditingTemplateId(null); setManagingTemplates(true); }}>
|
||||
<Plus className="w-4 h-4 mr-2" />New Template
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{managingTemplates && (
|
||||
<div className="space-y-4">
|
||||
{/* Template Form area */}
|
||||
{editingTemplateId !== null && (
|
||||
<Card className="p-4">
|
||||
<TemplateForm
|
||||
initialData={externalTemplates?.find((t: any) => t.id === editingTemplateId) as Partial<ExternalTemplate>}
|
||||
onClose={() => setEditingTemplateId(null)}
|
||||
onSubmit={(data) => {
|
||||
if (editingTemplateId) updateTemplateMutation.mutate({ id: editingTemplateId, data });
|
||||
else createTemplateMutation.mutate(data as Partial<ExternalTemplate>);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Create new when editingTemplateId is null and Manage Templates open -> show form */}
|
||||
{editingTemplateId === null && (
|
||||
<Card className="p-4">
|
||||
<h3 className="font-medium mb-2">Create Template</h3>
|
||||
<TemplateForm
|
||||
onClose={() => setManagingTemplates(false)}
|
||||
onSubmit={(data) => createTemplateMutation.mutate(data as Partial<ExternalTemplate>)}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* List of templates */}
|
||||
<div className="grid gap-3">
|
||||
{externalTemplates?.map((t: ExternalTemplate) => (
|
||||
<Card key={t.id} className="p-4 flex justify-between items-start">
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white">{t.name}</h4>
|
||||
<p className="text-sm text-gray-500 mt-1">{t.description}</p>
|
||||
<pre className="mt-2 text-xs font-mono bg-gray-50 dark:bg-gray-800 p-2 rounded max-h-44 overflow-auto">{t.config}</pre>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 ml-4">
|
||||
<Button size="sm" variant="secondary" onClick={() => setEditingTemplateId(t.id)}>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button size="sm" variant="danger" onClick={() => { if (confirm('Delete template?')) deleteTemplateMutation.mutate(t.id); }}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
{externalTemplates?.length === 0 && (
|
||||
<div className="text-sm text-gray-500">No external templates. Use the form above to create one.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAdding && (
|
||||
<Card className="p-6 mb-6 border-blue-500 border-2">
|
||||
<h3 className="text-lg font-medium mb-4">Add New Provider</h3>
|
||||
|
||||
@@ -92,7 +92,9 @@ const Setup: React.FC = () => {
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8 bg-white dark:bg-gray-800 p-8 rounded-lg shadow-md">
|
||||
<div className="flex flex-col items-center">
|
||||
<img src="/logo.png" alt="Charon" className="h-12 w-auto mb-4" />
|
||||
<img src="/logo.png" alt="Charon" style={{ height: '150px', width: 'auto' }}/>
|
||||
|
||||
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white">
|
||||
Welcome to Charon
|
||||
</h2>
|
||||
|
||||
@@ -54,9 +54,26 @@ EXISTING_ID=$(curl -s -H "Authorization: Bearer $TOKEN" $API_URL/proxy-hosts | j
|
||||
if [ -n "$EXISTING_ID" ]; then
|
||||
echo "Found existing proxy host (ID: $EXISTING_ID), deleting..."
|
||||
curl -s -X DELETE $API_URL/proxy-hosts/$EXISTING_ID -H "Authorization: Bearer $TOKEN"
|
||||
# Wait until the host is removed and Caddy has reloaded
|
||||
for i in $(seq 1 10); do
|
||||
sleep 1
|
||||
STILL_EXISTS=$(curl -s -H "Authorization: Bearer $TOKEN" $API_URL/proxy-hosts | jq -r --arg domain "test.localhost" '.[] | select(.domain_names == $domain) | .uuid' | head -n1)
|
||||
if [ -z "$STILL_EXISTS" ]; then
|
||||
break
|
||||
fi
|
||||
echo "Waiting for API to delete existing proxy host..."
|
||||
done
|
||||
fi
|
||||
# Start a lightweight test upstream server to ensure proxy has a target (local-only)
|
||||
python3 -c "import http.server, socketserver
|
||||
# Start a lightweight test upstream server to ensure proxy has a target (local-only). If a
|
||||
# whoami container is already running on the Docker network, prefer using that.
|
||||
USE_HOST_WHOAMI=false
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
if docker ps --format '{{.Names}}' | grep -q '^whoami$'; then
|
||||
USE_HOST_WHOAMI=true
|
||||
fi
|
||||
fi
|
||||
if [ "$USE_HOST_WHOAMI" = "false" ]; then
|
||||
python3 -c "import http.server, socketserver
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
self.send_response(200)
|
||||
@@ -64,20 +81,60 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
self.wfile.write(b'Hostname: local-test')
|
||||
def log_message(self, format, *args):
|
||||
pass
|
||||
httpd=socketserver.TCPServer((\"0.0.0.0\", 8081), Handler)
|
||||
httpd=socketserver.TCPServer(('0.0.0.0', 8081), Handler)
|
||||
import threading
|
||||
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
||||
" &
|
||||
else
|
||||
echo "Using existing whoami container for upstream tests"
|
||||
fi
|
||||
|
||||
# We use 'whoami' as the forward host because they are on the same docker network
|
||||
RESPONSE=$(curl -s -X POST $API_URL/proxy-hosts \
|
||||
# Prefer "whoami" when running inside CI/docker (it resolves on the docker network).
|
||||
# For local runs, default to 127.0.0.1 since we start the test upstream on the host —
|
||||
# but if charon runs inside Docker and the upstream is bound to the host, we must
|
||||
# use host.docker.internal so Caddy inside the container can reach the host service.
|
||||
FORWARD_HOST="127.0.0.1"
|
||||
FORWARD_PORT="8081"
|
||||
if [ "$USE_HOST_WHOAMI" = "true" ]; then
|
||||
FORWARD_HOST="whoami"
|
||||
FORWARD_PORT="80"
|
||||
fi
|
||||
if [ -n "$CI" ] || [ -n "$GITHUB_ACTIONS" ]; then
|
||||
FORWARD_HOST="whoami"
|
||||
# whoami image listens on port 80 inside its container
|
||||
FORWARD_PORT="80"
|
||||
fi
|
||||
|
||||
# If we're running charon in Docker locally and we didn't choose whoami, prefer
|
||||
# host.docker.internal so that the containerized Caddy can reach a host-bound upstream.
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
if docker ps --format '{{.Names}}' | grep -q '^charon-debug$' || docker ps --format '{{.Image}}' | grep -q 'charon:local'; then
|
||||
if [ "$FORWARD_HOST" = "127.0.0.1" ]; then
|
||||
FORWARD_HOST="host.docker.internal"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
echo "Using forward host: $FORWARD_HOST:$FORWARD_PORT"
|
||||
|
||||
# Adjust the Caddy/Caddy proxy test port for local runs to avoid conflicts with
|
||||
# host services on port 80.
|
||||
CADDY_PORT="80"
|
||||
if [ -z "$CI" ] && [ -z "$GITHUB_ACTIONS" ]; then
|
||||
# Use a non-privileged port locally when binding to host: 8082
|
||||
CADDY_PORT="8082"
|
||||
fi
|
||||
echo "Using Caddy host port: $CADDY_PORT"
|
||||
# Retry creation up to 5 times if the apply config call fails due to Caddy reloads
|
||||
RESPONSE=""
|
||||
for attempt in 1 2 3 4 5; do
|
||||
RESPONSE=$(curl -s -X POST $API_URL/proxy-hosts \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"domain_names": "test.localhost",
|
||||
"forward_scheme": "http",
|
||||
"forward_host": "127.0.0.1",
|
||||
"forward_port": 8081,
|
||||
"forward_host": "'"$FORWARD_HOST"'",
|
||||
"forward_port": '"$FORWARD_PORT"',
|
||||
"access_list_id": null,
|
||||
"certificate_id": null,
|
||||
"ssl_forced": false,
|
||||
@@ -89,6 +146,21 @@ RESPONSE=$(curl -s -X POST $API_URL/proxy-hosts \
|
||||
"hsts_subdomains": false,
|
||||
"locations": []
|
||||
}')
|
||||
# If Response contains a failure message indicating caddy apply failed, retry
|
||||
if echo "$RESPONSE" | grep -q "Failed to apply configuration"; then
|
||||
echo "Warning: failed to apply config on attempt $attempt, retrying..."
|
||||
# Wait for Caddy admin API on host to respond to /config to reduce collisions
|
||||
for i in $(seq 1 10); do
|
||||
if curl -s -o /dev/null -w "%{http_code}" http://localhost:${CADDY_ADMIN_PORT:-20194}/config/ >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
sleep $attempt
|
||||
continue
|
||||
fi
|
||||
break
|
||||
done
|
||||
|
||||
ID=$(echo $RESPONSE | jq -r .uuid)
|
||||
if [ -z "$ID" ] || [ "$ID" = "null" ]; then
|
||||
@@ -100,8 +172,18 @@ echo "✅ Proxy Host created (ID: $ID)"
|
||||
echo "Testing Proxy..."
|
||||
# We use Host header to route to the correct proxy host
|
||||
# We hit localhost:80 (Caddy) which should route to whoami
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -H "Host: test.localhost" http://localhost:80)
|
||||
CONTENT=$(curl -s -H "Host: test.localhost" http://localhost:80)
|
||||
HTTP_CODE=0
|
||||
CONTENT=""
|
||||
# Retry probing Caddy for the new route for up to 10 seconds
|
||||
for i in $(seq 1 10); do
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -H "Host: test.localhost" http://localhost:${CADDY_PORT} || true)
|
||||
CONTENT=$(curl -s -H "Host: test.localhost" http://localhost:${CADDY_PORT} || true)
|
||||
if [ "$HTTP_CODE" = "200" ] && echo "$CONTENT" | grep -q "Hostname:"; then
|
||||
break
|
||||
fi
|
||||
echo "Waiting for Caddy to pick up new route ($i/10)..."
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [ "$HTTP_CODE" = "200" ] && echo "$CONTENT" | grep -q "Hostname:"; then
|
||||
echo "✅ Proxy test passed! Content received from whoami."
|
||||
|
||||
Reference in New Issue
Block a user