UX improvements.
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -329,24 +330,87 @@ func (api *API) StatusHandler(w http.ResponseWriter, r *http.Request, startTime
|
||||
})
|
||||
}
|
||||
|
||||
// getDaemonStatus checks whether each managed daemon is active.
|
||||
func getDaemonStatus() map[string]map[string]bool {
|
||||
daemons := map[string]string{
|
||||
"dnsmasq": "dnsmasq.service",
|
||||
"haproxy": "haproxy.service",
|
||||
"wireguard": "wg-quick@wg0.service",
|
||||
"crowdsec": "crowdsec.service",
|
||||
// getDaemonStatus checks whether each managed daemon is active and gets its version.
|
||||
func getDaemonStatus() map[string]map[string]any {
|
||||
type daemonInfo struct {
|
||||
unit string
|
||||
verCmd []string // command to get version
|
||||
verParse func(string) string // extract version from output
|
||||
}
|
||||
|
||||
result := make(map[string]map[string]bool, len(daemons)+1)
|
||||
for name, unit := range daemons {
|
||||
err := exec.Command("systemctl", "is-active", "--quiet", unit).Run()
|
||||
result[name] = map[string]bool{"active": err == nil}
|
||||
firstWord := func(s string) string {
|
||||
if i := strings.IndexByte(s, ' '); i > 0 {
|
||||
return s[:i]
|
||||
}
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
daemons := map[string]daemonInfo{
|
||||
"dnsmasq": {
|
||||
unit: "dnsmasq.service",
|
||||
verCmd: []string{"dnsmasq", "--version"},
|
||||
verParse: func(out string) string {
|
||||
// "Dnsmasq version 2.90 ..."
|
||||
if i := strings.Index(out, "version "); i >= 0 {
|
||||
return firstWord(out[i+8:])
|
||||
}
|
||||
return ""
|
||||
},
|
||||
},
|
||||
"haproxy": {
|
||||
unit: "haproxy.service",
|
||||
verCmd: []string{"haproxy", "-v"},
|
||||
verParse: func(out string) string {
|
||||
// "HAProxy version 2.8.5-1 ..."
|
||||
if i := strings.Index(out, "version "); i >= 0 {
|
||||
return firstWord(out[i+8:])
|
||||
}
|
||||
return ""
|
||||
},
|
||||
},
|
||||
"wireguard": {
|
||||
unit: "wg-quick@wg0.service",
|
||||
verCmd: []string{"wg", "--version"},
|
||||
verParse: func(out string) string {
|
||||
// "wireguard-tools v1.0.20210914 - ..."
|
||||
if i := strings.Index(out, " v"); i >= 0 {
|
||||
return firstWord(out[i+1:])
|
||||
}
|
||||
return ""
|
||||
},
|
||||
},
|
||||
"crowdsec": {
|
||||
unit: "crowdsec.service",
|
||||
verCmd: []string{"cscli", "version", "--raw"},
|
||||
verParse: func(out string) string {
|
||||
return firstWord(out)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := make(map[string]map[string]any, len(daemons)+1)
|
||||
for name, info := range daemons {
|
||||
err := exec.Command("systemctl", "is-active", "--quiet", info.unit).Run()
|
||||
entry := map[string]any{"active": err == nil}
|
||||
if out, verErr := exec.Command(info.verCmd[0], info.verCmd[1:]...).Output(); verErr == nil {
|
||||
if v := info.verParse(string(out)); v != "" {
|
||||
entry["version"] = v
|
||||
}
|
||||
}
|
||||
result[name] = entry
|
||||
}
|
||||
|
||||
// nftables has no persistent service — check if the wild-cloud table exists
|
||||
err := exec.Command("sudo", "nft", "list", "table", "inet", "wild-cloud").Run()
|
||||
result["nftables"] = map[string]bool{"active": err == nil}
|
||||
nftErr := exec.Command("sudo", "nft", "list", "table", "inet", "wild-cloud").Run()
|
||||
nftEntry := map[string]any{"active": nftErr == nil}
|
||||
if out, verErr := exec.Command("nft", "--version").Output(); verErr == nil {
|
||||
// "nftables v1.0.9 (Old Doc Yak #3)"
|
||||
s := string(out)
|
||||
if i := strings.Index(s, " v"); i >= 0 {
|
||||
nftEntry["version"] = firstWord(s[i+1:])
|
||||
}
|
||||
}
|
||||
result["nftables"] = nftEntry
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ package v1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type cloudflareZone struct {
|
||||
@@ -10,12 +12,19 @@ type cloudflareZone struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type cloudflarePermissions struct {
|
||||
Zone bool `json:"zone"` // can list zones
|
||||
DNS bool `json:"dns"` // can read/edit DNS records
|
||||
}
|
||||
|
||||
type cloudflareVerifyResponse struct {
|
||||
TokenConfigured bool `json:"tokenConfigured"`
|
||||
TokenValid bool `json:"tokenValid"`
|
||||
TokenStatus string `json:"tokenStatus"`
|
||||
Zones []cloudflareZone `json:"zones"`
|
||||
Error string `json:"error,omitempty"`
|
||||
TokenConfigured bool `json:"tokenConfigured"`
|
||||
TokenValid bool `json:"tokenValid"`
|
||||
TokenStatus string `json:"tokenStatus"`
|
||||
Zones []cloudflareZone `json:"zones"`
|
||||
RequiredZones []string `json:"requiredZones"`
|
||||
Permissions cloudflarePermissions `json:"permissions"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// CloudflareVerify checks whether the stored Cloudflare API token is valid
|
||||
@@ -47,6 +56,15 @@ func (api *API) CloudflareVerify(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
resp.Zones = zones
|
||||
resp.Permissions.Zone = true
|
||||
|
||||
// Test DNS access on the first available zone
|
||||
if len(zones) > 0 {
|
||||
resp.Permissions.DNS = testCloudflareDNSAccess(token, zones[0].ID)
|
||||
}
|
||||
|
||||
// Derive required zones from registered domains
|
||||
resp.RequiredZones = api.getRequiredZones()
|
||||
|
||||
respondJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
@@ -78,6 +96,36 @@ func (api *API) CloudflareSetZoneID(w http.ResponseWriter, r *http.Request) {
|
||||
respondMessage(w, http.StatusOK, "Zone ID saved")
|
||||
}
|
||||
|
||||
// getRequiredZones extracts unique root zones from all registered domains.
|
||||
// e.g., "app.cloud.payne.io" → "payne.io"
|
||||
func (api *API) getRequiredZones() []string {
|
||||
doms, err := api.domains.List()
|
||||
if err != nil || len(doms) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
seen := make(map[string]bool)
|
||||
var zones []string
|
||||
for _, dom := range doms {
|
||||
zone := rootZone(dom.DomainName)
|
||||
if zone != "" && !seen[zone] {
|
||||
seen[zone] = true
|
||||
zones = append(zones, zone)
|
||||
}
|
||||
}
|
||||
return zones
|
||||
}
|
||||
|
||||
// rootZone extracts the registrable zone from a FQDN.
|
||||
// "app.cloud.payne.io" → "payne.io", "foo.example.com" → "example.com"
|
||||
func rootZone(domain string) string {
|
||||
parts := strings.Split(domain, ".")
|
||||
if len(parts) < 2 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(parts[len(parts)-2:], ".")
|
||||
}
|
||||
|
||||
func verifyCloudflareToken(apiToken string) (string, error) {
|
||||
req, err := http.NewRequest(http.MethodGet,
|
||||
"https://api.cloudflare.com/client/v4/user/tokens/verify", nil)
|
||||
@@ -103,6 +151,24 @@ func verifyCloudflareToken(apiToken string) (string, error) {
|
||||
return result.Result.Status, nil
|
||||
}
|
||||
|
||||
// testCloudflareDNSAccess checks if the token can list DNS records for a zone.
|
||||
func testCloudflareDNSAccess(apiToken, zoneID string) bool {
|
||||
req, err := http.NewRequest(http.MethodGet,
|
||||
fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records?per_page=1", zoneID), nil)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+apiToken)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
return resp.StatusCode == http.StatusOK
|
||||
}
|
||||
|
||||
func listCloudflareZones(apiToken string) ([]cloudflareZone, error) {
|
||||
req, err := http.NewRequest(http.MethodGet,
|
||||
"https://api.cloudflare.com/client/v4/zones", nil)
|
||||
|
||||
Reference in New Issue
Block a user