Show routes, TLS certs, and port-forwarding on services page
- Add Routes model to services UI (paths, headers, IP whitelisting per route) - Show TLS cert info per service with inline provision/renew actions - Remove TLS Certificates section from dashboard (now on services page) - Make gateway router port list dynamic from config + VPN state - Add TODO for header validation in HAProxy config generation
This commit is contained in:
22
CLAUDE.md
22
CLAUDE.md
@@ -37,6 +37,7 @@ make check # Lint + test
|
|||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
- `WILD_CENTRAL_DATA_DIR` — Data directory (default: `/var/lib/wild-central`)
|
- `WILD_CENTRAL_DATA_DIR` — Data directory (default: `/var/lib/wild-central`)
|
||||||
|
- `WILD_CENTRAL_PORT` — API listen port (default: `5055`)
|
||||||
- `WILD_CENTRAL_NATS_PORT` — NATS listen port (default: `4222`)
|
- `WILD_CENTRAL_NATS_PORT` — NATS listen port (default: `4222`)
|
||||||
- `WILD_CENTRAL_DNSMASQ_CONFIG_PATH` — dnsmasq config path
|
- `WILD_CENTRAL_DNSMASQ_CONFIG_PATH` — dnsmasq config path
|
||||||
- `WILD_CENTRAL_HAPROXY_CONFIG_PATH` — HAProxy config path
|
- `WILD_CENTRAL_HAPROXY_CONFIG_PATH` — HAProxy config path
|
||||||
@@ -46,6 +47,15 @@ make check # Lint + test
|
|||||||
- `WILD_CENTRAL_STATIC_DIR` — Web UI static files directory
|
- `WILD_CENTRAL_STATIC_DIR` — Web UI static files directory
|
||||||
- `WILD_CENTRAL_VITE_URL` — Vite dev server URL for frontend proxying
|
- `WILD_CENTRAL_VITE_URL` — Vite dev server URL for frontend proxying
|
||||||
|
|
||||||
|
### Data Directory
|
||||||
|
|
||||||
|
Runtime state is persisted in `{WILD_CENTRAL_DATA_DIR}/`:
|
||||||
|
- `state.yaml` — operator, domain, firewall, DDNS, DHCP settings (managed via API)
|
||||||
|
- `secrets.yaml` — API tokens and credentials
|
||||||
|
- `services/` — per-domain service registration files
|
||||||
|
- `nats/` — embedded NATS JetStream data
|
||||||
|
- `instances/` — Wild Cloud instance configs
|
||||||
|
|
||||||
## Service Registration API
|
## Service Registration API
|
||||||
|
|
||||||
The key abstraction. Services register with Central to get DNS, proxy, TLS, and public exposure.
|
The key abstraction. Services register with Central to get DNS, proxy, TLS, and public exposure.
|
||||||
@@ -62,15 +72,14 @@ DELETE /api/v1/services/{name} Deregister service
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"name": "my-api",
|
|
||||||
"source": "wild-works",
|
|
||||||
"domain": "my-api.payne.io",
|
"domain": "my-api.payne.io",
|
||||||
|
"source": "wild-works",
|
||||||
"backend": {
|
"backend": {
|
||||||
"address": "192.168.8.60:9001",
|
"address": "192.168.8.60:9001",
|
||||||
"type": "http",
|
"type": "http",
|
||||||
"health": "/health"
|
"health": "/health"
|
||||||
},
|
},
|
||||||
"reach": "internal",
|
"public": false,
|
||||||
"tls": "terminate"
|
"tls": "terminate"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -81,8 +90,7 @@ DELETE /api/v1/services/{name} Deregister service
|
|||||||
- `http` — L7, Central terminates TLS with wildcard cert (Wild Works services)
|
- `http` — L7, Central terminates TLS with wildcard cert (Wild Works services)
|
||||||
- `static` — L7, static file serving (Wild Works frontends)
|
- `static` — L7, static file serving (Wild Works frontends)
|
||||||
|
|
||||||
### Reach levels
|
### Public
|
||||||
|
|
||||||
- `off` — localhost only
|
- `false` (default) — LAN-visible only (DNS + proxy + TLS)
|
||||||
- `internal` — LAN-visible (DNS + proxy + TLS)
|
- `true` — internet-visible (+ DDNS or tunnel exposure)
|
||||||
- `public` — internet-visible (+ tunnel or direct exposure)
|
|
||||||
|
|||||||
13
TODO.md
Normal file
13
TODO.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# TODO
|
||||||
|
|
||||||
|
## Header validation in service registration
|
||||||
|
|
||||||
|
A bad header value from a client (e.g., `Access-Control-Allow-Headers: Content-Type, Authorization, ...` with commas/spaces) can produce invalid HAProxy config syntax. When HAProxy config validation fails, the reconciler skips the write — but the bad service persists, so the next reconcile cycle fails identically. This blocks ALL config updates for ALL services until the bad registration is fixed manually.
|
||||||
|
|
||||||
|
### Needed
|
||||||
|
|
||||||
|
1. **Input validation at registration time** — reject or sanitize header values that would break HAProxy config. HAProxy header values with spaces/commas need quoting; values with special characters (newlines, NULs) should be rejected outright.
|
||||||
|
|
||||||
|
2. **Graceful degradation in reconciliation** — if generating config for one service produces an invalid HAProxy config, skip that service and log a warning rather than failing the entire reconciliation. Approach: generate config, validate, if invalid → bisect to find the offending service → exclude it and regenerate.
|
||||||
|
|
||||||
|
3. **Current workaround** — header values are Go-`%q`-quoted in the HAProxy config output. This handles spaces/commas but may produce backslash escapes that HAProxy doesn't understand for edge cases.
|
||||||
@@ -68,7 +68,7 @@ func NewAPI(dataDir, version string, allowedOrigins []string) (*API, error) {
|
|||||||
version: version,
|
version: version,
|
||||||
allowedOrigins: allowedOrigins,
|
allowedOrigins: allowedOrigins,
|
||||||
config: configMgr,
|
config: configMgr,
|
||||||
secrets: secrets.NewManager(),
|
secrets: secrets.NewManager(filepath.Join(dataDir, "secrets.yaml")),
|
||||||
dnsmasq: dnsmasq.NewConfigGenerator(dnsmasqConfigPath),
|
dnsmasq: dnsmasq.NewConfigGenerator(dnsmasqConfigPath),
|
||||||
haproxy: haproxy.NewManager(haproxyConfigPath),
|
haproxy: haproxy.NewManager(haproxyConfigPath),
|
||||||
nftables: nftables.NewManager(nftablesRulesPath),
|
nftables: nftables.NewManager(nftablesRulesPath),
|
||||||
@@ -327,26 +327,12 @@ func (api *API) UpdateGlobalConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// GetGlobalSecrets returns the global secrets (redacted by default)
|
// GetGlobalSecrets returns the global secrets (redacted by default)
|
||||||
func (api *API) GetGlobalSecrets(w http.ResponseWriter, r *http.Request) {
|
func (api *API) GetGlobalSecrets(w http.ResponseWriter, r *http.Request) {
|
||||||
secretsPath := filepath.Join(api.dataDir, "secrets.yaml")
|
secretsMap, err := api.secrets.GetAll()
|
||||||
data, err := os.ReadFile(secretsPath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
|
||||||
respondJSON(w, http.StatusOK, map[string]any{})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondError(w, http.StatusInternalServerError, "Failed to read secrets")
|
respondError(w, http.StatusInternalServerError, "Failed to read secrets")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var secretsMap map[string]any
|
|
||||||
if err := yaml.Unmarshal(data, &secretsMap); err != nil {
|
|
||||||
respondError(w, http.StatusInternalServerError, "Failed to parse secrets")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if secretsMap == nil {
|
|
||||||
secretsMap = map[string]any{}
|
|
||||||
}
|
|
||||||
|
|
||||||
showRaw := r.URL.Query().Get("raw") == "true"
|
showRaw := r.URL.Query().Get("raw") == "true"
|
||||||
if !showRaw {
|
if !showRaw {
|
||||||
redactSecrets(secretsMap)
|
redactSecrets(secretsMap)
|
||||||
@@ -357,8 +343,6 @@ func (api *API) GetGlobalSecrets(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// UpdateGlobalSecrets updates the global secrets
|
// UpdateGlobalSecrets updates the global secrets
|
||||||
func (api *API) UpdateGlobalSecrets(w http.ResponseWriter, r *http.Request) {
|
func (api *API) UpdateGlobalSecrets(w http.ResponseWriter, r *http.Request) {
|
||||||
secretsPath := filepath.Join(api.dataDir, "secrets.yaml")
|
|
||||||
|
|
||||||
body, err := io.ReadAll(r.Body)
|
body, err := io.ReadAll(r.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusBadRequest, "Failed to read request body")
|
respondError(w, http.StatusBadRequest, "Failed to read request body")
|
||||||
@@ -371,36 +355,7 @@ func (api *API) UpdateGlobalSecrets(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
existingContent, err := storage.ReadFile(secretsPath)
|
if err := api.secrets.MergeUpdate(updates); err != nil {
|
||||||
if err != nil && !os.IsNotExist(err) {
|
|
||||||
respondError(w, http.StatusInternalServerError, "Failed to read existing secrets")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var existing map[string]any
|
|
||||||
if len(existingContent) > 0 {
|
|
||||||
if err := yaml.Unmarshal(existingContent, &existing); err != nil {
|
|
||||||
respondError(w, http.StatusBadRequest, "Failed to parse existing secrets")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
existing = make(map[string]any)
|
|
||||||
}
|
|
||||||
|
|
||||||
for key, value := range updates {
|
|
||||||
existing[key] = value
|
|
||||||
}
|
|
||||||
|
|
||||||
yamlContent, err := yaml.Marshal(existing)
|
|
||||||
if err != nil {
|
|
||||||
respondError(w, http.StatusInternalServerError, "Failed to marshal YAML")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
lockPath := secretsPath + ".lock"
|
|
||||||
if err := storage.WithLock(lockPath, func() error {
|
|
||||||
return storage.WriteFile(secretsPath, yamlContent, 0600)
|
|
||||||
}); err != nil {
|
|
||||||
respondError(w, http.StatusInternalServerError, "Failed to write secrets")
|
respondError(w, http.StatusInternalServerError, "Failed to write secrets")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -488,45 +443,19 @@ func redactSecrets(m map[string]any) {
|
|||||||
|
|
||||||
// getCloudflareToken reads the Cloudflare API token from global secrets
|
// getCloudflareToken reads the Cloudflare API token from global secrets
|
||||||
func (api *API) getCloudflareToken() string {
|
func (api *API) getCloudflareToken() string {
|
||||||
secretsPath := filepath.Join(api.dataDir, "secrets.yaml")
|
token, err := api.secrets.GetSecret("cloudflare.apiToken")
|
||||||
data, err := os.ReadFile(secretsPath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
var secretsMap map[string]any
|
|
||||||
if err := yaml.Unmarshal(data, &secretsMap); err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
cloudflare, ok := secretsMap["cloudflare"].(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
token, _ := cloudflare["apiToken"].(string)
|
|
||||||
return token
|
return token
|
||||||
}
|
}
|
||||||
|
|
||||||
// getCloudflareZoneID reads the Cloudflare Zone ID from global secrets
|
// getCloudflareZoneID reads the Cloudflare Zone ID from global secrets
|
||||||
func (api *API) getCloudflareZoneID() string {
|
func (api *API) getCloudflareZoneID() string {
|
||||||
secretsPath := filepath.Join(api.dataDir, "secrets.yaml")
|
zoneID, err := api.secrets.GetSecret("cloudflare.zoneId")
|
||||||
data, err := os.ReadFile(secretsPath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
var secretsMap map[string]any
|
|
||||||
if err := yaml.Unmarshal(data, &secretsMap); err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
cloudflare, ok := secretsMap["cloudflare"].(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
zoneID, _ := cloudflare["zoneId"].(string)
|
|
||||||
return zoneID
|
return zoneID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package v1
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type cloudflareZone struct {
|
type cloudflareZone struct {
|
||||||
@@ -71,8 +70,7 @@ func (api *API) CloudflareSetZoneID(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
secretsPath := filepath.Join(api.dataDir, "secrets.yaml")
|
if err := api.secrets.SetSecret("cloudflare.zoneId", req.ZoneID); err != nil {
|
||||||
if err := api.secrets.SetSecret(secretsPath, "cloudflare.zoneId", req.ZoneID); err != nil {
|
|
||||||
respondError(w, http.StatusInternalServerError, "Failed to save zone ID")
|
respondError(w, http.StatusInternalServerError, "Failed to save zone ID")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,82 +1,17 @@
|
|||||||
package secrets
|
package secrets
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/big"
|
"math/big"
|
||||||
"os/exec"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/wild-cloud/wild-central/internal/storage"
|
"github.com/wild-cloud/wild-central/internal/storage"
|
||||||
|
"github.com/wild-cloud/wild-central/internal/tools"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
// YQ provides a wrapper around the yq command-line tool
|
|
||||||
type YQ struct {
|
|
||||||
yqPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewYQ creates a new YQ wrapper
|
|
||||||
func NewYQ() *YQ {
|
|
||||||
path, err := exec.LookPath("yq")
|
|
||||||
if err != nil {
|
|
||||||
path = "yq"
|
|
||||||
}
|
|
||||||
return &YQ{yqPath: path}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get retrieves a value from a YAML file using a yq expression
|
|
||||||
func (y *YQ) Get(filePath, expression string) (string, error) {
|
|
||||||
cmd := exec.Command(y.yqPath, expression, filePath)
|
|
||||||
var stdout, stderr bytes.Buffer
|
|
||||||
cmd.Stdout = &stdout
|
|
||||||
cmd.Stderr = &stderr
|
|
||||||
if err := cmd.Run(); err != nil {
|
|
||||||
return "", fmt.Errorf("yq get failed: %w, stderr: %s", err, stderr.String())
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(stdout.String()), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set sets a value in a YAML file using a yq expression
|
|
||||||
func (y *YQ) Set(filePath, expression, value string) error {
|
|
||||||
if !strings.HasPrefix(expression, ".") {
|
|
||||||
expression = "." + expression
|
|
||||||
}
|
|
||||||
quotedValue := fmt.Sprintf(`"%s"`, strings.ReplaceAll(value, `"`, `\"`))
|
|
||||||
setExpr := fmt.Sprintf("%s = %s", expression, quotedValue)
|
|
||||||
cmd := exec.Command(y.yqPath, "-i", setExpr, filePath)
|
|
||||||
var stderr bytes.Buffer
|
|
||||||
cmd.Stderr = &stderr
|
|
||||||
if err := cmd.Run(); err != nil {
|
|
||||||
return fmt.Errorf("yq set failed: %w, stderr: %s", err, stderr.String())
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete removes a key from a YAML file
|
|
||||||
func (y *YQ) Delete(filePath, expression string) error {
|
|
||||||
delExpr := fmt.Sprintf("del(%s)", expression)
|
|
||||||
cmd := exec.Command(y.yqPath, "-i", delExpr, filePath)
|
|
||||||
var stderr bytes.Buffer
|
|
||||||
cmd.Stderr = &stderr
|
|
||||||
if err := cmd.Run(); err != nil {
|
|
||||||
return fmt.Errorf("yq delete failed: %w, stderr: %s", err, stderr.String())
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate checks if a YAML file is valid
|
|
||||||
func (y *YQ) Validate(filePath string) error {
|
|
||||||
cmd := exec.Command(y.yqPath, "eval", ".", filePath)
|
|
||||||
var stderr bytes.Buffer
|
|
||||||
cmd.Stderr = &stderr
|
|
||||||
if err := cmd.Run(); err != nil {
|
|
||||||
return fmt.Errorf("yaml validation failed: %w, stderr: %s", err, stderr.String())
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// DefaultSecretLength is 32 characters
|
// DefaultSecretLength is 32 characters
|
||||||
DefaultSecretLength = 32
|
DefaultSecretLength = 32
|
||||||
@@ -84,19 +19,21 @@ const (
|
|||||||
alphanumeric = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
alphanumeric = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Manager handles secret generation and storage
|
// Manager handles secret storage backed by a single YAML file.
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
yq *YQ
|
secretsPath string
|
||||||
|
yq *tools.YQ
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewManager creates a new secrets manager
|
// NewManager creates a new secrets manager bound to the given file path.
|
||||||
func NewManager() *Manager {
|
func NewManager(secretsPath string) *Manager {
|
||||||
return &Manager{
|
return &Manager{
|
||||||
yq: NewYQ(),
|
secretsPath: secretsPath,
|
||||||
|
yq: tools.NewYQ(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateSecret generates a cryptographically secure random alphanumeric string
|
// GenerateSecret generates a cryptographically secure random alphanumeric string.
|
||||||
func GenerateSecret(length int) (string, error) {
|
func GenerateSecret(length int) (string, error) {
|
||||||
if length <= 0 {
|
if length <= 0 {
|
||||||
length = DefaultSecretLength
|
length = DefaultSecretLength
|
||||||
@@ -114,54 +51,17 @@ func GenerateSecret(length int) (string, error) {
|
|||||||
return string(result), nil
|
return string(result), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnsureSecretsFile ensures a secrets file exists with proper structure and permissions
|
// GetSecret retrieves a secret value by dot-notation key.
|
||||||
func (m *Manager) EnsureSecretsFile(dataDir, instanceName string) error {
|
func (m *Manager) GetSecret(key string) (string, error) {
|
||||||
secretsPath := filepath.Join(dataDir, "instances", instanceName, "config", "secrets.yaml")
|
if !storage.FileExists(m.secretsPath) {
|
||||||
|
return "", fmt.Errorf("secrets file not found: %s", m.secretsPath)
|
||||||
// Check if secrets file already exists
|
|
||||||
if storage.FileExists(secretsPath) {
|
|
||||||
// Ensure proper permissions
|
|
||||||
if err := storage.EnsureFilePermissions(secretsPath, 0600); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create minimal secrets structure
|
value, err := m.yq.Get(m.secretsPath, fmt.Sprintf(".%s", key))
|
||||||
initialSecrets := `# Wild Cloud Instance Secrets
|
|
||||||
# WARNING: This file contains sensitive data. Keep secure!
|
|
||||||
cluster:
|
|
||||||
talosSecrets: ""
|
|
||||||
kubeconfig: ""
|
|
||||||
cloudflare:
|
|
||||||
token: ""
|
|
||||||
`
|
|
||||||
|
|
||||||
// Ensure config directory exists
|
|
||||||
if err := storage.EnsureDir(filepath.Join(dataDir, "instances", instanceName, "config"), 0755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write secrets file with restrictive permissions (0600)
|
|
||||||
if err := storage.WriteFile(secretsPath, []byte(initialSecrets), 0600); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetSecret retrieves a secret value from a secrets file
|
|
||||||
func (m *Manager) GetSecret(secretsPath, key string) (string, error) {
|
|
||||||
if !storage.FileExists(secretsPath) {
|
|
||||||
return "", fmt.Errorf("secrets file not found: %s", secretsPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
value, err := m.yq.Get(secretsPath, fmt.Sprintf(".%s", key))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("getting secret %s: %w", key, err)
|
return "", fmt.Errorf("getting secret %s: %w", key, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// yq returns "null" for non-existent keys
|
|
||||||
if value == "" || value == "null" {
|
if value == "" || value == "null" {
|
||||||
return "", fmt.Errorf("secret not found: %s", key)
|
return "", fmt.Errorf("secret not found: %s", key)
|
||||||
}
|
}
|
||||||
@@ -169,69 +69,75 @@ func (m *Manager) GetSecret(secretsPath, key string) (string, error) {
|
|||||||
return value, nil
|
return value, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetSecret sets a secret value in a secrets file
|
// SetSecret sets a secret value by dot-notation key.
|
||||||
func (m *Manager) SetSecret(secretsPath, key, value string) error {
|
func (m *Manager) SetSecret(key, value string) error {
|
||||||
if !storage.FileExists(secretsPath) {
|
if !storage.FileExists(m.secretsPath) {
|
||||||
return fmt.Errorf("secrets file not found: %s", secretsPath)
|
return fmt.Errorf("secrets file not found: %s", m.secretsPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Acquire lock before modifying
|
lockPath := m.secretsPath + ".lock"
|
||||||
lockPath := secretsPath + ".lock"
|
|
||||||
return storage.WithLock(lockPath, func() error {
|
return storage.WithLock(lockPath, func() error {
|
||||||
// Don't wrap value in quotes - yq handles YAML quoting automatically
|
if err := m.yq.Set(m.secretsPath, fmt.Sprintf(".%s", key), value); err != nil {
|
||||||
if err := m.yq.Set(secretsPath, fmt.Sprintf(".%s", key), value); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// Ensure permissions remain secure after modification
|
return storage.EnsureFilePermissions(m.secretsPath, 0600)
|
||||||
return storage.EnsureFilePermissions(secretsPath, 0600)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnsureSecret generates and sets a secret only if it doesn't exist (idempotent)
|
// DeleteSecret removes a secret by dot-notation key.
|
||||||
func (m *Manager) EnsureSecret(secretsPath, key string, length int) (string, error) {
|
func (m *Manager) DeleteSecret(key string) error {
|
||||||
if !storage.FileExists(secretsPath) {
|
if !storage.FileExists(m.secretsPath) {
|
||||||
return "", fmt.Errorf("secrets file not found: %s", secretsPath)
|
return fmt.Errorf("secrets file not found: %s", m.secretsPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if secret already exists
|
lockPath := m.secretsPath + ".lock"
|
||||||
existingSecret, err := m.GetSecret(secretsPath, key)
|
return storage.WithLock(lockPath, func() error {
|
||||||
if err == nil && existingSecret != "" && existingSecret != "null" {
|
if err := m.yq.Delete(m.secretsPath, fmt.Sprintf(".%s", key)); err != nil {
|
||||||
// Secret already exists, return it
|
return err
|
||||||
return existingSecret, nil
|
}
|
||||||
|
return storage.EnsureFilePermissions(m.secretsPath, 0600)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate new secret
|
// GetAll reads and returns the full secrets map.
|
||||||
secret, err := GenerateSecret(length)
|
func (m *Manager) GetAll() (map[string]any, error) {
|
||||||
|
data, err := os.ReadFile(m.secretsPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return map[string]any{}, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("reading secrets: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set the secret
|
var secretsMap map[string]any
|
||||||
if err := m.SetSecret(secretsPath, key, secret); err != nil {
|
if err := yaml.Unmarshal(data, &secretsMap); err != nil {
|
||||||
return "", err
|
return nil, fmt.Errorf("parsing secrets: %w", err)
|
||||||
|
}
|
||||||
|
if secretsMap == nil {
|
||||||
|
secretsMap = map[string]any{}
|
||||||
}
|
}
|
||||||
|
|
||||||
return secret, nil
|
return secretsMap, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateAndStoreSecret is a convenience function that generates a secret and stores it
|
// MergeUpdate merges the provided key-value pairs into the existing secrets file.
|
||||||
func (m *Manager) GenerateAndStoreSecret(secretsPath, key string) (string, error) {
|
func (m *Manager) MergeUpdate(updates map[string]any) error {
|
||||||
return m.EnsureSecret(secretsPath, key, DefaultSecretLength)
|
lockPath := m.secretsPath + ".lock"
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteSecret removes a secret from a secrets file
|
|
||||||
func (m *Manager) DeleteSecret(secretsPath, key string) error {
|
|
||||||
if !storage.FileExists(secretsPath) {
|
|
||||||
return fmt.Errorf("secrets file not found: %s", secretsPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Acquire lock before modifying
|
|
||||||
lockPath := secretsPath + ".lock"
|
|
||||||
return storage.WithLock(lockPath, func() error {
|
return storage.WithLock(lockPath, func() error {
|
||||||
if err := m.yq.Delete(secretsPath, fmt.Sprintf(".%s", key)); err != nil {
|
existing, err := m.GetAll()
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// Ensure permissions remain secure after modification
|
|
||||||
return storage.EnsureFilePermissions(secretsPath, 0600)
|
for key, value := range updates {
|
||||||
|
existing[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
yamlContent, err := yaml.Marshal(existing)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshaling secrets: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return storage.WriteFile(m.secretsPath, yamlContent, 0600)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -7,13 +7,13 @@ import { Alert, AlertDescription } from './ui/alert';
|
|||||||
import { Badge } from './ui/badge';
|
import { Badge } from './ui/badge';
|
||||||
import {
|
import {
|
||||||
LayoutDashboard, Cloud, CheckCircle, XCircle, AlertCircle, Loader2,
|
LayoutDashboard, Cloud, CheckCircle, XCircle, AlertCircle, Loader2,
|
||||||
RefreshCw, BookOpen, Globe, Shield, Router, Server, RotateCw, Key, Plus, X,
|
RefreshCw, BookOpen, Globe, Router, Server, RotateCw, Key, Plus, X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useCloudflare } from '../hooks/useCloudflare';
|
import { useCloudflare } from '../hooks/useCloudflare';
|
||||||
import { useDdns } from '../hooks/useDdns';
|
import { useDdns } from '../hooks/useDdns';
|
||||||
import { useCentralStatus } from '../hooks/useCentralStatus';
|
import { useCentralStatus } from '../hooks/useCentralStatus';
|
||||||
import { useCert } from '../hooks/useCert';
|
|
||||||
import { useConfig } from '../hooks';
|
import { useConfig } from '../hooks';
|
||||||
|
import { useVpn } from '../hooks/useVpn';
|
||||||
import { secretsApi } from '../services/api';
|
import { secretsApi } from '../services/api';
|
||||||
import { usePageHelp } from '../hooks/usePageHelp';
|
import { usePageHelp } from '../hooks/usePageHelp';
|
||||||
import type { DdnsRecordStatus } from '../services/api/networking';
|
import type { DdnsRecordStatus } from '../services/api/networking';
|
||||||
@@ -22,8 +22,8 @@ export function DashboardComponent() {
|
|||||||
const { data: centralStatus, isLoading: statusLoading, restart: restartDaemon, isRestarting } = useCentralStatus();
|
const { data: centralStatus, isLoading: statusLoading, restart: restartDaemon, isRestarting } = useCentralStatus();
|
||||||
const { verification, isLoading: cfLoading } = useCloudflare();
|
const { verification, isLoading: cfLoading } = useCloudflare();
|
||||||
const { status: ddnsStatus, isLoadingStatus: ddnsLoading, trigger: triggerDdns, isTriggering } = useDdns();
|
const { status: ddnsStatus, isLoadingStatus: ddnsLoading, trigger: triggerDdns, isTriggering } = useDdns();
|
||||||
const { status: certStatus } = useCert();
|
|
||||||
const { config: globalConfig, updateConfig } = useConfig();
|
const { config: globalConfig, updateConfig } = useConfig();
|
||||||
|
const { config: vpnConfig } = useVpn();
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { data: globalSecrets } = useQuery({
|
const { data: globalSecrets } = useQuery({
|
||||||
@@ -52,10 +52,8 @@ export function DashboardComponent() {
|
|||||||
// Warning conditions
|
// Warning conditions
|
||||||
const cfTokenMissing = !cfLoading && !verification?.tokenConfigured;
|
const cfTokenMissing = !cfLoading && !verification?.tokenConfigured;
|
||||||
const cfTokenInvalid = !cfLoading && verification?.tokenConfigured && !verification?.tokenValid;
|
const cfTokenInvalid = !cfLoading && verification?.tokenConfigured && !verification?.tokenValid;
|
||||||
const certExpiring = certStatus?.certs?.some(c => c.cert.exists && (c.cert.daysLeft ?? 999) < 14);
|
|
||||||
const certMissing = certStatus?.certs?.some(c => !c.cert.exists);
|
|
||||||
const ddnsFailed = ddnsStatus?.enabled && ddnsStatus?.lastError;
|
const ddnsFailed = ddnsStatus?.enabled && ddnsStatus?.lastError;
|
||||||
const hasWarnings = cfTokenMissing || cfTokenInvalid || certExpiring || certMissing || ddnsFailed;
|
const hasWarnings = cfTokenMissing || cfTokenInvalid || ddnsFailed;
|
||||||
|
|
||||||
usePageHelp({
|
usePageHelp({
|
||||||
title: 'What is the Dashboard?',
|
title: 'What is the Dashboard?',
|
||||||
@@ -103,18 +101,6 @@ export function DashboardComponent() {
|
|||||||
<AlertDescription>Cloudflare API token is invalid. Check your token in Cloudflare settings.</AlertDescription>
|
<AlertDescription>Cloudflare API token is invalid. Check your token in Cloudflare settings.</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
{certMissing && (
|
|
||||||
<Alert variant="warning">
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
<AlertDescription>One or more TLS certificates are missing. Services may not be reachable over HTTPS.</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
{certExpiring && !certMissing && (
|
|
||||||
<Alert variant="warning">
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
<AlertDescription>A TLS certificate expires within 14 days. Consider renewing soon.</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
{ddnsFailed && (
|
{ddnsFailed && (
|
||||||
<Alert variant="error">
|
<Alert variant="error">
|
||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" />
|
||||||
@@ -426,62 +412,39 @@ export function DashboardComponent() {
|
|||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 4. TLS Certificates */}
|
{/* 4. Gateway Router */}
|
||||||
{certStatus?.certs && certStatus.certs.length > 0 && (
|
|
||||||
<Card className="p-4 border-l-4 border-l-blue-500">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Shield className="h-5 w-5 text-blue-500" />
|
|
||||||
<div className="font-medium">TLS Certificates</div>
|
|
||||||
</div>
|
|
||||||
{(() => {
|
|
||||||
const allExist = certStatus.certs!.every(c => c.cert.exists);
|
|
||||||
const someExist = certStatus.certs!.some(c => c.cert.exists);
|
|
||||||
if (allExist) {
|
|
||||||
return <Badge variant="success" className="gap-1"><CheckCircle className="h-3 w-3" />All valid</Badge>;
|
|
||||||
} else if (someExist) {
|
|
||||||
return <Badge variant="warning" className="gap-1"><AlertCircle className="h-3 w-3" />Partial</Badge>;
|
|
||||||
} else {
|
|
||||||
return <Badge variant="destructive" className="gap-1"><XCircle className="h-3 w-3" />Missing</Badge>;
|
|
||||||
}
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2 ml-7">
|
|
||||||
{certStatus.certs!.map((entry) => (
|
|
||||||
<div key={entry.domain} className="flex items-center justify-between text-sm">
|
|
||||||
<span className="font-mono">{entry.domain}</span>
|
|
||||||
{entry.cert.exists ? (
|
|
||||||
<span className="text-xs text-green-600 dark:text-green-400">
|
|
||||||
Valid ({entry.cert.daysLeft}d)
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-red-500">Missing</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 5. Gateway Router */}
|
|
||||||
<Card className="p-4 border-l-4 border-l-amber-500 bg-gradient-to-r from-amber-50/50 to-orange-50/50 dark:from-amber-950/10 dark:to-orange-950/10">
|
<Card className="p-4 border-l-4 border-l-amber-500 bg-gradient-to-r from-amber-50/50 to-orange-50/50 dark:from-amber-950/10 dark:to-orange-950/10">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<Router className="h-5 w-5 text-amber-500" />
|
<Router className="h-5 w-5 text-amber-500" />
|
||||||
<div className="font-medium">Gateway Router</div>
|
<div className="font-medium">Gateway Router</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2 ml-7 text-sm text-muted-foreground">
|
<div className="space-y-2 ml-7 text-sm text-muted-foreground">
|
||||||
<p>Ensure your router is configured for Wild Central to work correctly:</p>
|
<p>Your LAN router should be configured to work with Wild Central:</p>
|
||||||
<ul className="list-disc list-inside space-y-1 text-xs">
|
<ul className="list-disc list-inside space-y-1 text-xs">
|
||||||
<li>
|
<li>
|
||||||
Forward DNS to Wild Central
|
Set the router's DNS server to Wild Central
|
||||||
{globalConfig?.cloud?.dnsmasq?.ip && (
|
{globalConfig?.cloud?.dnsmasq?.ip && (
|
||||||
<span className="font-mono bg-muted px-1.5 py-0.5 rounded ml-1">
|
<span className="font-mono bg-muted px-1.5 py-0.5 rounded ml-1">
|
||||||
{globalConfig.cloud.dnsmasq.ip}
|
{globalConfig.cloud.dnsmasq.ip}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
<li>Forward ports <span className="font-mono">443</span>, <span className="font-mono">80</span>, <span className="font-mono">51820</span> to Wild Central</li>
|
<li>
|
||||||
<li>Set Wild Central as the primary DNS server for your LAN</li>
|
Port-forward to Wild Central:{' '}
|
||||||
|
{[
|
||||||
|
{ port: 443, proto: 'TCP', label: 'HTTPS' },
|
||||||
|
{ port: 80, proto: 'TCP', label: 'HTTP' },
|
||||||
|
...(vpnConfig?.enabled ? [{ port: vpnConfig.listenPort || 51820, proto: 'UDP', label: 'VPN' }] : []),
|
||||||
|
...((globalConfig?.cloud?.nftables?.extraPorts ?? []) as { port: number; protocol?: string; label?: string }[])
|
||||||
|
.map(ep => ({ port: ep.port, proto: (ep.protocol || 'tcp').toUpperCase(), label: ep.label || '' })),
|
||||||
|
].map((p, i) => (
|
||||||
|
<span key={i}>
|
||||||
|
{i > 0 && ', '}
|
||||||
|
<span className="font-mono">{p.port}</span>/{p.proto}
|
||||||
|
{p.label && <span className="text-muted-foreground/60"> ({p.label})</span>}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -9,13 +9,14 @@ import { Switch } from './ui/switch';
|
|||||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from './ui/collapsible';
|
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from './ui/collapsible';
|
||||||
import {
|
import {
|
||||||
Globe, Loader2, AlertCircle, CheckCircle, Play, RotateCw,
|
Globe, Loader2, AlertCircle, CheckCircle, Play, RotateCw,
|
||||||
Plus, Trash2, X, ChevronDown, ChevronUp,
|
Plus, Trash2, X, ChevronDown, ChevronUp, Route, Shield, FileText,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useHaproxy } from '../hooks/useHaproxy';
|
import { useHaproxy } from '../hooks/useHaproxy';
|
||||||
import { useServices } from '../hooks/useServices';
|
import { useServices } from '../hooks/useServices';
|
||||||
import { useCert } from '../hooks/useCert';
|
import { useCert } from '../hooks/useCert';
|
||||||
import { usePageHelp } from '../hooks/usePageHelp';
|
import { usePageHelp } from '../hooks/usePageHelp';
|
||||||
import type { RegisteredService } from '../services/api/networking';
|
import type { RegisteredService } from '../services/api/networking';
|
||||||
|
import type { CertEntry } from '../services/api/cert';
|
||||||
import { servicesApi, haproxyApi } from '../services/api/networking';
|
import { servicesApi, haproxyApi } from '../services/api/networking';
|
||||||
|
|
||||||
function StatusDot({ status, label }: { status: 'ok' | 'error' | 'na'; label: string }) {
|
function StatusDot({ status, label }: { status: 'ok' | 'error' | 'na'; label: string }) {
|
||||||
@@ -30,8 +31,43 @@ function StatusDot({ status, label }: { status: 'ok' | 'error' | 'na'; label: st
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getTlsStatus(service: RegisteredService, certDomains: Set<string>): 'ok' | 'error' | 'na' {
|
function getTlsStatus(service: RegisteredService, certDomains: Set<string>): 'ok' | 'error' | 'na' {
|
||||||
if (service.tls === 'passthrough' || service.backend.type === 'tcp-passthrough') return 'na';
|
const backendType = service.routes?.length ? service.routes[0].backend.type : service.backend.type;
|
||||||
return certDomains.has(service.domain) ? 'ok' : 'error';
|
if (service.tls === 'passthrough' || backendType === 'tcp-passthrough') return 'na';
|
||||||
|
// Check exact match or wildcard coverage
|
||||||
|
if (certDomains.has(service.domain)) return 'ok';
|
||||||
|
const dotIdx = service.domain.indexOf('.');
|
||||||
|
if (dotIdx > 0) {
|
||||||
|
const parent = service.domain.slice(dotIdx + 1);
|
||||||
|
if (certDomains.has(parent)) return 'ok';
|
||||||
|
}
|
||||||
|
return 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
function findCert(domain: string, certs: CertEntry[]): CertEntry | undefined {
|
||||||
|
// Exact match first
|
||||||
|
const exact = certs.find(c => c.cert.exists && c.domain === domain);
|
||||||
|
if (exact) return exact;
|
||||||
|
// Wildcard: check parent domain
|
||||||
|
const dotIdx = domain.indexOf('.');
|
||||||
|
if (dotIdx > 0) {
|
||||||
|
const parent = domain.slice(dotIdx + 1);
|
||||||
|
return certs.find(c => c.cert.exists && c.domain === parent);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasRoutes(service: RegisteredService): boolean {
|
||||||
|
return (service.routes?.length ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBackendAddress(service: RegisteredService): string {
|
||||||
|
if (hasRoutes(service)) return service.routes![0].backend.address;
|
||||||
|
return service.backend.address;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBackendType(service: RegisteredService): string {
|
||||||
|
if (hasRoutes(service)) return service.routes![0].backend.type;
|
||||||
|
return service.backend.type;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ServicesComponent() {
|
export function ServicesComponent() {
|
||||||
@@ -43,7 +79,17 @@ export function ServicesComponent() {
|
|||||||
restart: restartHaproxy, isRestarting: isHaproxyRestarting,
|
restart: restartHaproxy, isRestarting: isHaproxyRestarting,
|
||||||
} = useHaproxy();
|
} = useHaproxy();
|
||||||
const { services, isLoading: isServicesLoading } = useServices();
|
const { services, isLoading: isServicesLoading } = useServices();
|
||||||
const { status: certStatus } = useCert();
|
const { status: certStatus, provision: provisionCert, isProvisioning, renew: renewCert, isRenewing } = useCert();
|
||||||
|
|
||||||
|
const provisionMutation = useMutation({
|
||||||
|
mutationFn: (domain: string) => provisionCert(domain),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['cert'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const renewMutation = useMutation({
|
||||||
|
mutationFn: () => renewCert(),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['cert'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
const [expandedDomains, setExpandedDomains] = useState<Set<string>>(new Set());
|
const [expandedDomains, setExpandedDomains] = useState<Set<string>>(new Set());
|
||||||
@@ -197,7 +243,8 @@ export function ServicesComponent() {
|
|||||||
const isExpanded = expandedDomains.has(service.domain);
|
const isExpanded = expandedDomains.has(service.domain);
|
||||||
const tlsStatus = getTlsStatus(service, certDomains);
|
const tlsStatus = getTlsStatus(service, certDomains);
|
||||||
const ddnsStatus2 = service.public ? 'ok' as const : 'na' as const;
|
const ddnsStatus2 = service.public ? 'ok' as const : 'na' as const;
|
||||||
const isPassthrough = service.tls === 'passthrough' || service.backend.type === 'tcp-passthrough';
|
const isPassthrough = service.tls === 'passthrough' || getBackendType(service) === 'tcp-passthrough';
|
||||||
|
const routeCount = service.routes?.length ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={service.domain}>
|
<Card key={service.domain}>
|
||||||
@@ -208,6 +255,9 @@ export function ServicesComponent() {
|
|||||||
<span className="font-mono text-sm font-medium truncate">
|
<span className="font-mono text-sm font-medium truncate">
|
||||||
{service.subdomains ? `*.${service.domain}` : service.domain}
|
{service.subdomains ? `*.${service.domain}` : service.domain}
|
||||||
</span>
|
</span>
|
||||||
|
{routeCount > 1 && (
|
||||||
|
<span className="text-xs text-muted-foreground bg-muted px-1.5 py-0.5 rounded">{routeCount} routes</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 shrink-0">
|
<div className="flex items-center gap-3 shrink-0">
|
||||||
<StatusDot status="ok" label="DNS" />
|
<StatusDot status="ok" label="DNS" />
|
||||||
@@ -223,6 +273,7 @@ export function ServicesComponent() {
|
|||||||
<CardContent className="pt-0 pb-4 px-4 space-y-4">
|
<CardContent className="pt-0 pb-4 px-4 space-y-4">
|
||||||
{/* Controls */}
|
{/* Controls */}
|
||||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-3 py-2">
|
<div className="flex flex-wrap items-center gap-x-6 gap-y-3 py-2">
|
||||||
|
{!hasRoutes(service) && (
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs text-muted-foreground">Backend</Label>
|
<Label className="text-xs text-muted-foreground">Backend</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -239,6 +290,7 @@ export function ServicesComponent() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Switch
|
<Switch
|
||||||
@@ -261,19 +313,142 @@ export function ServicesComponent() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Switch
|
<Switch
|
||||||
checked={!isPassthrough}
|
checked={!isPassthrough}
|
||||||
onCheckedChange={v => updateMutation.mutate({
|
onCheckedChange={v => {
|
||||||
|
if (hasRoutes(service)) {
|
||||||
|
updateMutation.mutate({
|
||||||
|
domain: service.domain,
|
||||||
|
updates: { tls: v ? 'terminate' : 'passthrough' },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
updateMutation.mutate({
|
||||||
domain: service.domain,
|
domain: service.domain,
|
||||||
updates: {
|
updates: {
|
||||||
tls: v ? 'terminate' : 'passthrough',
|
tls: v ? 'terminate' : 'passthrough',
|
||||||
backend: { ...service.backend, type: v ? 'http' : 'tcp-passthrough' },
|
backend: { ...service.backend, type: v ? 'http' : 'tcp-passthrough' },
|
||||||
},
|
},
|
||||||
})}
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
disabled={updateMutation.isPending}
|
disabled={updateMutation.isPending}
|
||||||
/>
|
/>
|
||||||
<Label className="text-sm">{isPassthrough ? 'TLS Passthrough' : 'Central TLS'}</Label>
|
<Label className="text-sm">{isPassthrough ? 'TLS Passthrough' : 'Central TLS'}</Label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Routes detail */}
|
||||||
|
{hasRoutes(service) && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
|
<Route className="h-3 w-3" />Routes
|
||||||
|
</Label>
|
||||||
|
{service.routes!.map((route, i) => (
|
||||||
|
<div key={i} className="bg-muted/50 rounded-md p-3 space-y-2 text-sm">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="font-mono text-xs">
|
||||||
|
{route.paths?.length ? route.paths.join(', ') : '/*'}
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
defaultValue={route.backend.address}
|
||||||
|
className="h-7 font-mono text-xs w-44"
|
||||||
|
onBlur={e => {
|
||||||
|
const val = e.target.value.trim();
|
||||||
|
if (val && val !== route.backend.address) {
|
||||||
|
const updatedRoutes = [...service.routes!];
|
||||||
|
updatedRoutes[i] = { ...route, backend: { ...route.backend, address: val } };
|
||||||
|
servicesApi.register({ ...service, backend: undefined as any, routes: updatedRoutes } as any)
|
||||||
|
.then(() => queryClient.invalidateQueries({ queryKey: ['services'] }));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{route.headers && Object.keys(route.headers.response ?? {}).length > 0 && (
|
||||||
|
<div className="text-xs space-y-0.5">
|
||||||
|
<span className="text-muted-foreground flex items-center gap-1">
|
||||||
|
<FileText className="h-3 w-3" />Response headers
|
||||||
|
</span>
|
||||||
|
{Object.entries(route.headers.response!).map(([k, v]) => (
|
||||||
|
<div key={k} className="font-mono pl-4 text-muted-foreground">
|
||||||
|
{k}: <span className="text-foreground">{v}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{route.headers && Object.keys(route.headers.request ?? {}).length > 0 && (
|
||||||
|
<div className="text-xs space-y-0.5">
|
||||||
|
<span className="text-muted-foreground flex items-center gap-1">
|
||||||
|
<FileText className="h-3 w-3" />Request headers
|
||||||
|
</span>
|
||||||
|
{Object.entries(route.headers.request!).map(([k, v]) => (
|
||||||
|
<div key={k} className="font-mono pl-4 text-muted-foreground">
|
||||||
|
{k}: <span className="text-foreground">{v}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{route.ipAllow && route.ipAllow.length > 0 && (
|
||||||
|
<div className="text-xs">
|
||||||
|
<span className="text-muted-foreground flex items-center gap-1">
|
||||||
|
<Shield className="h-3 w-3" />IP allow: <span className="font-mono text-foreground">{route.ipAllow.join(', ')}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* TLS cert info */}
|
||||||
|
{!isPassthrough && (() => {
|
||||||
|
const cert = findCert(service.domain, certStatus?.certs ?? []);
|
||||||
|
if (!cert) {
|
||||||
|
const provisionDomain = service.subdomains ? `*.${service.domain}` : service.domain;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-red-500">
|
||||||
|
<AlertCircle className="h-3.5 w-3.5" />
|
||||||
|
No TLS certificate
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" className="h-7 text-xs gap-1"
|
||||||
|
disabled={provisionMutation.isPending}
|
||||||
|
onClick={() => provisionMutation.mutate(provisionDomain)}>
|
||||||
|
{provisionMutation.isPending
|
||||||
|
? <Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
: <Plus className="h-3 w-3" />}
|
||||||
|
Provision {provisionDomain}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const isWildcard = cert.domain !== service.domain;
|
||||||
|
const daysLeft = cert.cert.daysLeft ?? 0;
|
||||||
|
const expiry = cert.cert.expiry ? new Date(cert.cert.expiry).toLocaleDateString() : 'unknown';
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<CheckCircle className="h-3.5 w-3.5 text-green-500" />
|
||||||
|
{isWildcard ? `*.${cert.domain}` : cert.domain}
|
||||||
|
</span>
|
||||||
|
<span>Expires {expiry} ({daysLeft}d)</span>
|
||||||
|
{cert.cert.issuerCN && <span className="truncate max-w-48">{cert.cert.issuerCN}</span>}
|
||||||
|
</div>
|
||||||
|
{daysLeft < 30 && (
|
||||||
|
<Button variant="outline" size="sm" className="h-7 text-xs gap-1"
|
||||||
|
disabled={renewMutation.isPending}
|
||||||
|
onClick={() => renewMutation.mutate()}>
|
||||||
|
{renewMutation.isPending
|
||||||
|
? <Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
: <RotateCw className="h-3 w-3" />}
|
||||||
|
Renew
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Source + deregister */}
|
{/* Source + deregister */}
|
||||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
<span>Source: {service.source}</span>
|
<span>Source: {service.source}</span>
|
||||||
|
|||||||
@@ -2,6 +2,22 @@ import { apiClient } from './client';
|
|||||||
|
|
||||||
// Services
|
// Services
|
||||||
|
|
||||||
|
export interface HeaderConfig {
|
||||||
|
request?: Record<string, string>;
|
||||||
|
response?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Route {
|
||||||
|
paths?: string[];
|
||||||
|
backend: {
|
||||||
|
address: string;
|
||||||
|
type: string;
|
||||||
|
health?: string;
|
||||||
|
};
|
||||||
|
headers?: HeaderConfig;
|
||||||
|
ipAllow?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface RegisteredService {
|
export interface RegisteredService {
|
||||||
domain: string;
|
domain: string;
|
||||||
source: string;
|
source: string;
|
||||||
@@ -13,6 +29,7 @@ export interface RegisteredService {
|
|||||||
};
|
};
|
||||||
public: boolean;
|
public: boolean;
|
||||||
tls?: string;
|
tls?: string;
|
||||||
|
routes?: Route[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const servicesApi = {
|
export const servicesApi = {
|
||||||
|
|||||||
Reference in New Issue
Block a user