Skip to content

Commit 4648415

Browse files
committed
fix(caddy): resolve startup errors by querying root config
Instead of querying `/apps/http/servers` directly, which causes Caddy to log 'invalid traversal path' or 400 Bad Request errors when the paths don't exist in a fresh configuration, the API client now queries the root config (`/`) and safely handles the empty state. This ensures a cleaner startup log output and avoids unnecessary patch requests. Includes a minor fix for the UI tour targeting the filter buttons.
1 parent f045c41 commit 4648415

File tree

8 files changed

+232
-57
lines changed

8 files changed

+232
-57
lines changed

webui/cmd/webui/web/static/js/spa.bundle.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

webui/cmd/webui/web/templates/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ <h1 class="h4 mb-1">Relays & Proxies</h1>
5757
<use href="/static/vendor/bootstrap-icons/bootstrap-icons.svg#bi-question-circle"></use>
5858
</svg>
5959
</button>
60-
<div class="btn-group" role="group" aria-label="Type filter">
60+
<div class="btn-group" role="group" aria-label="Type filter" id="filter-relay-buttons">
6161
<input type="checkbox" class="btn-check" id="filter-relay" autocomplete="off" checked />
6262
<label class="btn btn-outline-primary btn-sm" for="filter-relay" data-bs-toggle="tooltip"
6363
data-bs-placement="bottom" title="TCP relays served by socat">

webui/frontend/src/index.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1083,7 +1083,7 @@
10831083
fallbackText: "The Autostart toggle appears on each proxy/relay card. It lets services start automatically on boot.",
10841084
},
10851085
{
1086-
target: "#filter-relay",
1086+
target: "#filter-relay-buttons",
10871087
title: "Filter Items",
10881088
description: "Use these toggles to show or hide TCP relays and HTTPS proxies in the list below.",
10891089
},

webui/internal/caddy/api_client.go

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,9 @@ const (
3434

3535
// APIClient provides methods to interact with Caddy's admin API
3636
type APIClient struct {
37-
BaseURL string
38-
HTTPClient *http.Client
39-
MaxLogBodySize int
37+
BaseURL string
38+
HTTPClient *http.Client
39+
MaxLogBodySize int
4040
}
4141

4242
// NewAPIClient creates a new Caddy API client
@@ -113,7 +113,13 @@ func (c *APIClient) doRequestWithHeaders(method, path string, body interface{})
113113
}
114114

115115
if resp.StatusCode >= 400 {
116-
logger.Error("caddy", "Caddy API error %d for %s %s: %s", resp.StatusCode, method, url, string(respBody))
116+
if method == http.MethodGet && (resp.StatusCode == http.StatusNotFound ||
117+
(resp.StatusCode == http.StatusBadRequest && strings.Contains(string(respBody), "invalid traversal path"))) {
118+
// Expected during checks when paths don't exist yet
119+
logger.Debug("caddy", "Path not found (expected during checks): %d for %s %s: %s", resp.StatusCode, method, url, string(respBody))
120+
} else {
121+
logger.Error("caddy", "Caddy API error %d for %s %s: %s", resp.StatusCode, method, url, string(respBody))
122+
}
117123
return nil, nil, &HTTPError{StatusCode: resp.StatusCode, Body: string(respBody)}
118124
}
119125

webui/internal/caddy/manager.go

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -121,21 +121,25 @@ func (m *Manager) InitializeAutostart() error {
121121
skipped := 0
122122
for _, proxy := range proxies {
123123
if proxy.Autostart {
124+
// Always sync state to Caddy API to ensure the route exists after a container restart
125+
proxy.Enabled = true
126+
if err := m.UpdateProxy(proxy); err != nil {
127+
log.Printf("Warning: failed to autostart proxy %s (ID: %s): %v", proxy.Hostname, proxy.ID, err)
128+
skipped++
129+
continue
130+
}
131+
started++
132+
log.Printf("Autostarted proxy %s (ID: %s)", proxy.Hostname, proxy.ID)
133+
} else {
134+
// Check if we need to sync disabled state (if it was previously thought to be enabled)
124135
if proxy.Enabled {
125-
// Already enabled, count it
126-
started++
127-
log.Printf("Proxy %s (ID: %s) has autostart enabled and is already active", proxy.Hostname, proxy.ID)
128-
} else {
129-
// Enable it now
130-
proxy.Enabled = true
136+
proxy.Enabled = false
131137
if err := m.UpdateProxy(proxy); err != nil {
132-
log.Printf("Warning: failed to autostart proxy %s (ID: %s): %v", proxy.Hostname, proxy.ID, err)
133-
continue
138+
log.Printf("Warning: failed to sync disabled state for proxy %s (ID: %s): %v", proxy.Hostname, proxy.ID, err)
139+
} else {
140+
log.Printf("Disabled non-autostart proxy %s (ID: %s)", proxy.Hostname, proxy.ID)
134141
}
135-
started++
136-
log.Printf("Autostarted proxy %s (ID: %s)", proxy.Hostname, proxy.ID)
137142
}
138-
} else {
139143
skipped++
140144
}
141145
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package caddy
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"testing"
7+
8+
"github.com/sudocarlos/tailrelay/internal/config"
9+
)
10+
11+
func TestManager_InitializeAutostart(t *testing.T) {
12+
var requests []string
13+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
14+
reqStr := r.Method + " " + r.URL.Path
15+
requests = append(requests, reqStr)
16+
// Return 404 for listServers and ensureHTTPServersPath to mimic empty Caddy
17+
if r.Method == http.MethodGet && (r.URL.Path == "/config/apps/http/servers" || r.URL.Path == "/config" || r.URL.Path == "/config/") {
18+
w.WriteHeader(http.StatusNotFound)
19+
w.Write([]byte(`{"error":"loading config path \"/\": path not found"}`))
20+
return
21+
}
22+
23+
// For everything else (PUT, PATCH, DELETE), return OK
24+
w.WriteHeader(http.StatusOK)
25+
w.Write([]byte(`{}`)) // provide a valid json response body
26+
}))
27+
defer srv.Close()
28+
29+
dir := t.TempDir()
30+
serverMapPath := dir + "/servers.json"
31+
32+
manager := NewManager(srv.URL, serverMapPath)
33+
34+
// Add proxy 1: Autostart true, initially Enabled false
35+
_, err := manager.AddProxy(config.CaddyProxy{
36+
ID: "proxy-1",
37+
Hostname: "test1.com",
38+
Port: 8081,
39+
Target: "localhost:9091",
40+
Enabled: false,
41+
Autostart: true,
42+
})
43+
if err != nil {
44+
t.Fatalf("Failed to add proxy-1: %v", err)
45+
}
46+
47+
// Add proxy 2: Autostart false, initially Enabled true
48+
_, err = manager.AddProxy(config.CaddyProxy{
49+
ID: "proxy-2",
50+
Hostname: "test2.com",
51+
Port: 8082,
52+
Target: "localhost:9092",
53+
Enabled: true,
54+
Autostart: false,
55+
})
56+
if err != nil {
57+
t.Fatalf("Failed to add proxy-2: %v", err)
58+
}
59+
60+
// Add proxy 3: Autostart true, initially Enabled true
61+
_, err = manager.AddProxy(config.CaddyProxy{
62+
ID: "proxy-3",
63+
Hostname: "test3.com",
64+
Port: 8083,
65+
Target: "localhost:9093",
66+
Enabled: true,
67+
Autostart: true,
68+
})
69+
if err != nil {
70+
t.Fatalf("Failed to add proxy-3: %v", err)
71+
}
72+
73+
// Clear request log from the initial AddProxy calls
74+
requests = []string{}
75+
76+
// Run InitializeAutostart
77+
if err := manager.InitializeAutostart(); err != nil {
78+
t.Fatalf("InitializeAutostart failed: %v", err)
79+
}
80+
81+
// Verify resulting metadata state
82+
p1, _ := manager.GetProxy("proxy-1")
83+
if !p1.Enabled {
84+
t.Errorf("Proxy 1 should be enabled because Autostart is true")
85+
}
86+
87+
p2, _ := manager.GetProxy("proxy-2")
88+
if p2.Enabled {
89+
t.Errorf("Proxy 2 should be disabled because Autostart is false, overriding its previous state")
90+
}
91+
92+
p3, _ := manager.GetProxy("proxy-3")
93+
if !p3.Enabled {
94+
t.Errorf("Proxy 3 should be enabled because Autostart is true")
95+
}
96+
97+
// Verify that the mock server received the expected requests
98+
t.Logf("Recorded requests: %v", requests)
99+
if len(requests) == 0 {
100+
t.Errorf("Expected Caddy API requests during InitializeAutostart, but got none")
101+
}
102+
103+
// proxy-1 and proxy-3 should result in PUT or PATCH (create or update route) requests
104+
updateReqs := 0
105+
for _, req := range requests {
106+
if (len(req) > 3) && (req[:3] == "PUT" || req[:5] == "PATCH") && len(req) > 30 && req[len(req)-4:] != "fig/" {
107+
// Count requests like "PUT /config/apps/http/servers/srv..." or "PATCH /config/apps/http/servers/srv..."
108+
// But exclude "PATCH /config/"
109+
updateReqs++
110+
}
111+
}
112+
113+
if updateReqs < 2 {
114+
t.Errorf("Expected at least 2 PUT/PATCH requests for autostart proxies, got %d", updateReqs)
115+
}
116+
}

webui/internal/caddy/proxy_manager.go

Lines changed: 85 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -612,27 +612,52 @@ func extractIDFromLocation(location string) (string, error) {
612612
}
613613

614614
func (pm *ProxyManager) ensureHTTPServersPath() error {
615-
_, err := pm.client.GetConfig("/apps/http/servers")
616-
if err == nil {
617-
// Path already exists, nothing to do
618-
return nil
615+
// Instead of querying /apps/http/servers which triggers a 400 error in Caddy's logs
616+
// if the path doesn't exist, we query the root config and check locally.
617+
data, err := pm.client.GetConfig("/")
618+
if err != nil {
619+
var httpErr *HTTPError
620+
if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound {
621+
// Root config doesn't exist, it's empty
622+
data = []byte(`{}`)
623+
} else {
624+
return fmt.Errorf("failed to get root config: %w", err)
625+
}
619626
}
620627

621-
// Check if this is the "invalid traversal path" or 404 error
622-
var httpErr *HTTPError
623-
if errors.As(err, &httpErr) {
624-
if httpErr.StatusCode == http.StatusNotFound ||
625-
(httpErr.StatusCode == http.StatusBadRequest && strings.Contains(httpErr.Body, "invalid traversal path")) {
626-
// We can safely PATCH the root config to ensure the path exists without destroying data
627-
initPayload := map[string]interface{}{
628-
"apps": map[string]interface{}{
629-
"http": map[string]interface{}{},
630-
},
631-
}
632-
return pm.client.PatchConfig("/", initPayload)
628+
dataStr := strings.TrimSpace(string(data))
629+
if len(dataStr) == 0 || dataStr == "null" {
630+
data = []byte(`{}`)
631+
}
632+
633+
var root map[string]interface{}
634+
if err := json.Unmarshal(data, &root); err != nil {
635+
return fmt.Errorf("failed to parse root config: %w", err)
636+
}
637+
638+
apps, ok := root["apps"].(map[string]interface{})
639+
if !ok {
640+
apps = make(map[string]interface{})
641+
}
642+
643+
httpApp, ok := apps["http"].(map[string]interface{})
644+
if !ok {
645+
// Path doesn't exist, safely create it
646+
initPayload := map[string]interface{}{
647+
"apps": map[string]interface{}{
648+
"http": map[string]interface{}{},
649+
},
633650
}
651+
return pm.client.PatchConfig("/", initPayload)
634652
}
635-
return err
653+
654+
if _, ok := httpApp["servers"]; !ok {
655+
// servers key doesn't exist yet but http does, nothing to patch since
656+
// adding a server will automatically create the servers map.
657+
return nil
658+
}
659+
660+
return nil
636661
}
637662

638663
// InitializeServer ensures the HTTP server exists in Caddy config
@@ -664,28 +689,54 @@ func (pm *ProxyManager) InitializeServer(listenAddrs []string) error {
664689
}
665690

666691
func (pm *ProxyManager) listServers() (map[string]*HTTPServer, error) {
667-
data, err := pm.client.GetConfig("/apps/http/servers")
692+
// Query root config instead of /apps/http/servers to avoid 400 errors
693+
// when the path doesn't exist yet
694+
data, err := pm.client.GetConfig("/")
668695
if err != nil {
669-
// When Caddy config is empty ({}), the servers path doesn't exist yet and
670-
// Caddy returns a 404 (or 400 Bad Request with "invalid traversal path").
671-
// Treat this as an empty server list so callers can proceed normally.
672696
var httpErr *HTTPError
673-
if errors.As(err, &httpErr) {
674-
if httpErr.StatusCode == http.StatusNotFound ||
675-
(httpErr.StatusCode == http.StatusBadRequest && strings.Contains(httpErr.Body, "invalid traversal path")) {
676-
return map[string]*HTTPServer{}, nil
677-
}
697+
if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound {
698+
// Root config doesn't exist, treat as empty server list
699+
return map[string]*HTTPServer{}, nil
678700
}
679-
return nil, err
701+
return nil, fmt.Errorf("failed to get root config: %w", err)
702+
}
703+
704+
dataStr := strings.TrimSpace(string(data))
705+
if len(dataStr) == 0 || dataStr == "null" {
706+
return map[string]*HTTPServer{}, nil
707+
}
708+
709+
var root map[string]interface{}
710+
if err := json.Unmarshal(data, &root); err != nil {
711+
return nil, fmt.Errorf("failed to parse root config: %w", err)
712+
}
713+
714+
apps, ok := root["apps"].(map[string]interface{})
715+
if !ok {
716+
return map[string]*HTTPServer{}, nil
717+
}
718+
719+
httpApp, ok := apps["http"].(map[string]interface{})
720+
if !ok {
721+
return map[string]*HTTPServer{}, nil
722+
}
723+
724+
serversRaw, ok := httpApp["servers"]
725+
if !ok || serversRaw == nil {
726+
return map[string]*HTTPServer{}, nil
727+
}
728+
729+
// Marshaling just the servers part to unmarshal into our struct map
730+
serversData, err := json.Marshal(serversRaw)
731+
if err != nil {
732+
return nil, fmt.Errorf("failed to marshal servers data: %w", err)
680733
}
681734

682735
var servers map[string]*HTTPServer
683-
if err := json.Unmarshal(data, &servers); err != nil {
736+
if err := json.Unmarshal(serversData, &servers); err != nil {
684737
return nil, fmt.Errorf("unmarshal servers: %w", err)
685738
}
686739

687-
// json.Unmarshal of a JSON null yields nil; normalize to a non-nil empty map
688-
// so callers can iterate safely.
689740
if servers == nil {
690741
servers = map[string]*HTTPServer{}
691742
}
@@ -720,15 +771,13 @@ func (pm *ProxyManager) getServerNameForProxy(proxy config.CaddyProxy) (string,
720771

721772
// serverExistsInCaddy checks if a server name actually exists in Caddy's configuration
722773
func (pm *ProxyManager) serverExistsInCaddy(serverName string) bool {
723-
path := fmt.Sprintf("/apps/http/servers/%s", serverName)
724-
data, err := pm.client.GetConfig(path)
774+
// Use listServers to avoid 400 errors when checking paths
775+
servers, err := pm.listServers()
725776
if err != nil {
726777
return false
727778
}
728-
// Caddy returns "null" for non-existent paths
729-
dataStr := strings.TrimSpace(string(data))
730-
exists := len(dataStr) > 0 && dataStr != "null"
731-
logger.Debug("caddy", "Checking if server %s exists in Caddy: %v (data: %s)", serverName, exists, dataStr)
779+
_, exists := servers[serverName]
780+
logger.Debug("caddy", "Checking if server %s exists in Caddy: %v", serverName, exists)
732781
return exists
733782
}
734783

webui/internal/caddy/proxy_manager_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,11 @@ func newTestProxyManager(t *testing.T, apiURL string) *ProxyManager {
1515
}
1616

1717
// TestListServers_EmptyCaddyConfig verifies that listServers returns an empty map
18-
// (not an error) when Caddy's config is {} and the /apps/http/servers path returns 404.
18+
// (not an error) when Caddy's config is {} and the root path returns 404.
1919
func TestListServers_EmptyCaddyConfig(t *testing.T) {
2020
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2121
w.WriteHeader(http.StatusNotFound)
22-
w.Write([]byte(`{"error":"loading config path \"/apps/http/servers\": path not found"}`))
22+
w.Write([]byte(`{"error":"loading config path \"/\": path not found"}`))
2323
}))
2424
defer srv.Close()
2525

@@ -76,7 +76,7 @@ func TestListServers_EmptyObjectResponse(t *testing.T) {
7676
func TestAllocateServerName_EmptyCaddyConfig(t *testing.T) {
7777
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
7878
w.WriteHeader(http.StatusNotFound)
79-
w.Write([]byte(`{"error":"path not found"}`))
79+
w.Write([]byte(`{"error":"loading config path \"/\": path not found"}`))
8080
}))
8181
defer srv.Close()
8282

0 commit comments

Comments
 (0)