Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a13468942 | |||
| 8722fd2658 | |||
| 4bab53f20f | |||
| 0e1f97ee03 | |||
| 34125823df | |||
| 4927383faa | |||
| fd26f8de88 | |||
| 1924a56b13 | |||
| fce60b8ad3 | |||
| 0a29338e1f | |||
| 68ca2d2716 | |||
| 07c37023ab | |||
| 71c53e1071 | |||
| 65d97f3da1 | |||
| 42948ba37c |
@@ -0,0 +1,106 @@
|
|||||||
|
# xboard-node fork 运维手册
|
||||||
|
|
||||||
|
## 仓库说明
|
||||||
|
|
||||||
|
JP01 Gitea 自维护 fork,**不是上游镜像**,独立分支。
|
||||||
|
|
||||||
|
- 本仓库:`http://103.112.1.141:3000/will/xboard-node`(dev 分支)
|
||||||
|
- 上游(仅供 cherry-pick 参考):`https://github.com/cedar2025/Xboard-Node`
|
||||||
|
- 工作目录:`/tmp/xboard-node`(JP01)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 已应用补丁
|
||||||
|
|
||||||
|
| 补丁 | 文件 | 说明 | 是否可移除 |
|
||||||
|
|------|------|------|-----------|
|
||||||
|
| #32 | `internal/service/service.go` | ban 用户前调 `CloseUserConnections()`,立即断连 | 待上游合并后移除 |
|
||||||
|
| #55 | `internal/kernel/singbox/conntracker.go` | UDP PacketConn upload/download 计数对调 | 待上游合并后移除 |
|
||||||
|
| 自改 | `cmd/xbctl/main.go` | `downloadBase` 指向 JP01 Gitea releases | 永久保留 |
|
||||||
|
| 自改 | `cmd/xbctl/main.go` | `downloadToken` + `downloadFile` 加 Authorization header,支持 private 仓库下载 | 永久保留 |
|
||||||
|
| 自改 | `install.sh` | binary 下载地址改为 JP01 Gitea releases | 永久保留 |
|
||||||
|
|
||||||
|
### xbctl private 仓库下载(2026-06-24)
|
||||||
|
|
||||||
|
**背景**:Gitea 仓库改为 private 后,`xbctl upgrade` 下载时 HTTP 401。
|
||||||
|
|
||||||
|
**改动位置**:`cmd/xbctl/main.go`
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 新增常量
|
||||||
|
downloadToken = "cf6634b57d2c60c66030dbb29d77c4ed25370e94"
|
||||||
|
|
||||||
|
// downloadFile 改用 NewRequest,加 Authorization header
|
||||||
|
func downloadFile(url, dest string) error {
|
||||||
|
client := &http.Client{Timeout: 120 * time.Second}
|
||||||
|
req, err := http.NewRequest("GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if downloadToken != "" {
|
||||||
|
req.Header.Set("Authorization", "token "+downloadToken)
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**注意**:token 是只读 `read:repository` 权限,泄露只能读仓库,不能写。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 编译命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /tmp/xboard-node
|
||||||
|
VERSION=$(git describe --tags --always --dirty)
|
||||||
|
|
||||||
|
# xboard-node(必须带这些 tags)
|
||||||
|
CGO_ENABLED=0 /usr/local/go/bin/go build \
|
||||||
|
-ldflags "-s -w -X main.version=${VERSION}" \
|
||||||
|
-tags "with_quic with_utls with_wireguard with_acme with_clash_api" \
|
||||||
|
-o xboard-node-linux-amd64 ./cmd/xboard-node/
|
||||||
|
|
||||||
|
# xbctl
|
||||||
|
CGO_ENABLED=0 /usr/local/go/bin/go build \
|
||||||
|
-ldflags "-s -w" \
|
||||||
|
-o xbctl-linux-amd64 ./cmd/xbctl/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 发布流程
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TOKEN=cf6634b57d2c60c66030dbb29d77c4ed25370e94
|
||||||
|
RELEASE_ID=14 # v1.13-patched
|
||||||
|
|
||||||
|
# 删旧 asset
|
||||||
|
OLD_ID=$(curl -s "http://localhost:3000/api/v1/repos/will/xboard-node/releases/$RELEASE_ID/assets" \
|
||||||
|
-H "Authorization: token $TOKEN" | python3 -c "
|
||||||
|
import sys,json
|
||||||
|
for a in json.load(sys.stdin):
|
||||||
|
if a['name'] == 'xboard-node-linux-amd64': print(a['id'])")
|
||||||
|
curl -s -X DELETE "http://localhost:3000/api/v1/repos/will/xboard-node/releases/$RELEASE_ID/assets/$OLD_ID" \
|
||||||
|
-H "Authorization: token $TOKEN"
|
||||||
|
|
||||||
|
# 上传新 asset
|
||||||
|
curl -s -X POST "http://localhost:3000/api/v1/repos/will/xboard-node/releases/$RELEASE_ID/assets?name=xboard-node-linux-amd64" \
|
||||||
|
-H "Authorization: token $TOKEN" -H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary @xboard-node-linux-amd64
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 节点部署
|
||||||
|
|
||||||
|
| 节点 | IP | MCP |
|
||||||
|
|------|----|-----|
|
||||||
|
| dmit-tyo | 154.31.113.157 | mcp__dmit-tyo__exec |
|
||||||
|
| us-unlock | 103.152.113.30 | mcp__us-unlock__exec |
|
||||||
|
| vmiss-us01 | 38.47.112.162 | mcp__vmiss-us04__exec |
|
||||||
|
|
||||||
|
节点上执行升级:
|
||||||
|
```bash
|
||||||
|
xbctl upgrade # 自动带 token,直接用
|
||||||
|
```
|
||||||
@@ -42,13 +42,6 @@ curl -fsSL https://raw.githubusercontent.com/cedar2025/xboard-node/dev/install.s
|
|||||||
curl -fsSL https://raw.githubusercontent.com/cedar2025/xboard-node/dev/install.sh | \
|
curl -fsSL https://raw.githubusercontent.com/cedar2025/xboard-node/dev/install.sh | \
|
||||||
sudo bash -s -- --mode machine --panel https://panel.example.com --token TOKEN --machine-id 1
|
sudo bash -s -- --mode machine --panel https://panel.example.com --token TOKEN --machine-id 1
|
||||||
|
|
||||||
# Run again with another --panel to append a binding. No manual instance management needed.
|
|
||||||
# Upgrade
|
|
||||||
curl -fsSL .../install.sh | sudo bash -s -- upgrade
|
|
||||||
# Uninstall
|
|
||||||
curl -fsSL .../install.sh | sudo bash -s -- uninstall --purge --yes
|
|
||||||
```
|
|
||||||
|
|
||||||
## xbctl
|
## xbctl
|
||||||
|
|
||||||
Run `xbctl` after installation for help. Common commands:
|
Run `xbctl` after installation for help. Common commands:
|
||||||
@@ -70,6 +63,7 @@ Legacy single-panel config is fully compatible. Appending bindings auto-migrates
|
|||||||
|
|
||||||
- Custom routes: [docs-custom-routes.md](docs-custom-routes.md)
|
- Custom routes: [docs-custom-routes.md](docs-custom-routes.md)
|
||||||
- Custom outbounds: [docs-custom-outbounds.md](docs-custom-outbounds.md)
|
- Custom outbounds: [docs-custom-outbounds.md](docs-custom-outbounds.md)
|
||||||
|
- DNS providers (ACME DNS-01): [docs-dns-providers.md](docs-dns-providers.md)
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
+491
-49
@@ -5,9 +5,12 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -22,11 +25,12 @@ const (
|
|||||||
defaultConfigPath = "/etc/xboard-node/config.yml"
|
defaultConfigPath = "/etc/xboard-node/config.yml"
|
||||||
defaultMetaPath = "/etc/xboard-node/install-meta.json"
|
defaultMetaPath = "/etc/xboard-node/install-meta.json"
|
||||||
defaultCredentialsPath = "/etc/xboard-node/credentials.env"
|
defaultCredentialsPath = "/etc/xboard-node/credentials.env"
|
||||||
defaultInstallerPath = "/etc/xboard-node/install.sh"
|
|
||||||
defaultBinaryPath = "/usr/local/bin/xboard-node"
|
defaultBinaryPath = "/usr/local/bin/xboard-node"
|
||||||
defaultCLIPath = "/usr/local/bin/xbctl"
|
defaultCLIPath = "/usr/local/bin/xbctl"
|
||||||
defaultHealthURL = "http://127.0.0.1:65530/healthz"
|
|
||||||
serviceName = "xboard-node.service"
|
serviceName = "xboard-node.service"
|
||||||
|
serviceFilePath = "/etc/systemd/system/xboard-node.service"
|
||||||
|
defaultInstallRoot = "/etc/xboard-node"
|
||||||
|
downloadBase = "http://64.204.66.12:3000/will/xboard-node"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -158,7 +162,7 @@ func run(args []string) error {
|
|||||||
return runInstance(args[1:])
|
return runInstance(args[1:])
|
||||||
case "service":
|
case "service":
|
||||||
return runService(args[1:])
|
return runService(args[1:])
|
||||||
case "logs":
|
case "logs", "log":
|
||||||
return runService([]string{"logs"})
|
return runService([]string{"logs"})
|
||||||
case "health":
|
case "health":
|
||||||
return runHealth()
|
return runHealth()
|
||||||
@@ -172,12 +176,12 @@ func run(args []string) error {
|
|||||||
return runBind(append([]string{"remove-node"}, args[1:]...))
|
return runBind(append([]string{"remove-node"}, args[1:]...))
|
||||||
case "unbind-machine":
|
case "unbind-machine":
|
||||||
return runBind(append([]string{"remove-machine"}, args[1:]...))
|
return runBind(append([]string{"remove-machine"}, args[1:]...))
|
||||||
case "start", "stop", "restart":
|
case "start", "stop", "restart", "enable", "disable":
|
||||||
return runService(args)
|
return runService(args)
|
||||||
case "upgrade":
|
case "upgrade":
|
||||||
return runInstaller(append([]string{"upgrade"}, args[1:]...))
|
return runUpgrade(args[1:])
|
||||||
case "uninstall":
|
case "uninstall":
|
||||||
return runInstaller(append([]string{"uninstall"}, args[1:]...))
|
return runUninstall(args[1:])
|
||||||
case "version", "-v", "--version":
|
case "version", "-v", "--version":
|
||||||
fmt.Printf("xbctl %s (built %s)\n", version, buildTime)
|
fmt.Printf("xbctl %s (built %s)\n", version, buildTime)
|
||||||
return nil
|
return nil
|
||||||
@@ -200,22 +204,53 @@ func printUsage() {
|
|||||||
xbctl instance get <id> [--output text|json]
|
xbctl instance get <id> [--output text|json]
|
||||||
xbctl config init --mode node|machine --panel-url URL --token TOKEN [flags]
|
xbctl config init --mode node|machine --panel-url URL --token TOKEN [flags]
|
||||||
xbctl config health-port [--config PATH]
|
xbctl config health-port [--config PATH]
|
||||||
xbctl service status|start|stop|restart|logs
|
xbctl service status|start|stop|restart|enable|disable|logs
|
||||||
xbctl health
|
xbctl health
|
||||||
xbctl bind add-node --panel URL --token TOKEN --node-id ID [--node-type TYPE] [--kernel singbox|xray]
|
xbctl bind add-node --panel-url URL --token TOKEN --node-id ID [--node-type TYPE] [--kernel singbox|xray]
|
||||||
xbctl bind add-machine --panel URL --token TOKEN --machine-id ID [--kernel singbox|xray]
|
xbctl bind add-machine --panel-url URL --token TOKEN --machine-id ID [--kernel singbox|xray]
|
||||||
|
xbctl bind remove <instance-id>
|
||||||
xbctl bind remove-node --panel URL --node-id ID
|
xbctl bind remove-node --panel URL --node-id ID
|
||||||
xbctl bind remove-machine --panel URL --machine-id ID
|
xbctl bind remove-machine --panel URL --machine-id ID
|
||||||
xbctl bind-node ...
|
xbctl upgrade [--version VERSION]
|
||||||
xbctl bind-machine ...
|
xbctl uninstall [--purge] [--yes]
|
||||||
xbctl unbind-node --panel URL --node-id ID
|
xbctl version
|
||||||
xbctl unbind-machine --panel URL --machine-id ID
|
|
||||||
xbctl upgrade [...]
|
shortcuts:
|
||||||
xbctl uninstall [...]`)
|
xbctl start|stop|restart = xbctl service start|stop|restart
|
||||||
|
xbctl log|logs = xbctl service logs
|
||||||
|
xbctl bind-node ... = xbctl bind add-node ...
|
||||||
|
xbctl bind-machine ... = xbctl bind add-machine ...
|
||||||
|
xbctl unbind-node ... = xbctl bind remove-node ...
|
||||||
|
xbctl unbind-machine ... = xbctl bind remove-machine ...`)
|
||||||
}
|
}
|
||||||
|
|
||||||
func runStatus() error {
|
func runStatus() error {
|
||||||
return runInstaller([]string{"status"})
|
fmt.Println("xboard-node status")
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
// Version from install-meta.json
|
||||||
|
ver := "unknown"
|
||||||
|
if meta, err := loadInstallMeta(defaultMetaPath); err == nil {
|
||||||
|
ver = meta.Version
|
||||||
|
}
|
||||||
|
fmt.Printf(" version: %s\n", ver)
|
||||||
|
|
||||||
|
// Service status
|
||||||
|
svc := systemctlState()
|
||||||
|
fmt.Printf(" service: %s\n", svc)
|
||||||
|
|
||||||
|
// Health
|
||||||
|
health := instanceAwareHealth()
|
||||||
|
fmt.Printf(" health: %s\n", health)
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
// Instance list
|
||||||
|
rows, err := collectInstanceRows()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf(" (no instances found: %v)\n", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return printRows(rows, "text")
|
||||||
}
|
}
|
||||||
|
|
||||||
func runList(args []string) error {
|
func runList(args []string) error {
|
||||||
@@ -256,14 +291,14 @@ func runInstance(args []string) error {
|
|||||||
|
|
||||||
func runService(args []string) error {
|
func runService(args []string) error {
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return errors.New("usage: xbctl service <status|start|stop|restart|logs>")
|
return errors.New("usage: xbctl service <status|start|stop|restart|enable|disable|logs>")
|
||||||
}
|
}
|
||||||
sub := args[0]
|
sub := args[0]
|
||||||
rest := args[1:]
|
rest := args[1:]
|
||||||
switch sub {
|
switch sub {
|
||||||
case "status":
|
case "status":
|
||||||
return runCommand("sudo", append([]string{"systemctl", "status", serviceName, "--no-pager"}, rest...)...)
|
return runCommand("sudo", append([]string{"systemctl", "status", serviceName, "--no-pager"}, rest...)...)
|
||||||
case "start", "stop", "restart":
|
case "start", "stop", "restart", "enable", "disable":
|
||||||
return runCommand("sudo", append([]string{"systemctl", sub, serviceName}, rest...)...)
|
return runCommand("sudo", append([]string{"systemctl", sub, serviceName}, rest...)...)
|
||||||
case "logs":
|
case "logs":
|
||||||
if len(rest) == 0 {
|
if len(rest) == 0 {
|
||||||
@@ -276,50 +311,349 @@ func runService(args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func runHealth() error {
|
func runHealth() error {
|
||||||
return runCommand("curl", "-fsS", defaultHealthURL)
|
h := instanceAwareHealth()
|
||||||
|
fmt.Println(h)
|
||||||
|
if h == "down" {
|
||||||
|
return errors.New("health check failed")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func runBind(args []string) error {
|
func runBind(args []string) error {
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return errors.New("usage: xbctl bind <add-node|add-machine|remove-node|remove-machine> ...")
|
return errors.New("usage: xbctl bind <add-node|add-machine|remove-node|remove-machine> ...")
|
||||||
}
|
}
|
||||||
|
if err := ensureRoot("bind"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
sub := args[0]
|
sub := args[0]
|
||||||
rest := args[1:]
|
rest := args[1:]
|
||||||
switch sub {
|
switch sub {
|
||||||
case "add-node":
|
case "add-node":
|
||||||
return runInstaller(append([]string{"--mode", "node"}, rest...))
|
return runBindAdd("node", rest)
|
||||||
case "add-machine":
|
case "add-machine":
|
||||||
return runInstaller(append([]string{"--mode", "machine"}, rest...))
|
return runBindAdd("machine", rest)
|
||||||
case "remove-node":
|
case "remove-node":
|
||||||
panel, nodeID, err := parseRemoveNodeArgs(rest)
|
panel, nodeID, err := parseRemoveNodeArgs(rest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return removeBinding(panel, nodeID, 0)
|
return removeBinding(panel, nodeID, 0, "")
|
||||||
case "remove-machine":
|
case "remove-machine":
|
||||||
panel, machineID, err := parseRemoveMachineArgs(rest)
|
panel, machineID, err := parseRemoveMachineArgs(rest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return removeBinding(panel, 0, machineID)
|
return removeBinding(panel, 0, machineID, "")
|
||||||
|
case "remove":
|
||||||
|
if len(rest) == 0 {
|
||||||
|
return errors.New("usage: xbctl bind remove <instance-id>")
|
||||||
|
}
|
||||||
|
return removeBinding("", 0, 0, rest[0])
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unknown bind command: %s", sub)
|
return fmt.Errorf("unknown bind command: %s", sub)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func runInstaller(args []string) error {
|
func runBindAdd(mode string, args []string) error {
|
||||||
installerPath := defaultInstallerPath
|
// Build configInit args from bind args
|
||||||
usingSystemInstaller := false
|
initArgs := []string{
|
||||||
if _, err := os.Stat(installerPath); err == nil {
|
"--mode", mode,
|
||||||
usingSystemInstaller = true
|
"--config", defaultConfigPath,
|
||||||
|
"--output", defaultConfigPath,
|
||||||
|
"--credentials-in", defaultCredentialsPath,
|
||||||
|
"--credentials-out", defaultCredentialsPath,
|
||||||
|
"--meta", defaultMetaPath,
|
||||||
|
"--install-root", defaultInstallRoot,
|
||||||
|
}
|
||||||
|
// Pass through remaining args (--panel-url, --token, --node-id, --machine-id, --kernel, etc.)
|
||||||
|
initArgs = append(initArgs, args...)
|
||||||
|
|
||||||
|
if err := runConfigInit(initArgs); err != nil {
|
||||||
|
return fmt.Errorf("bind failed: %w", err)
|
||||||
|
}
|
||||||
|
// Restart service to pick up new config
|
||||||
|
fmt.Println("Restarting service...")
|
||||||
|
if err := runCommand("systemctl", "restart", serviceName); err != nil {
|
||||||
|
return fmt.Errorf("service restart failed: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Println("Binding added successfully")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runUpgrade(args []string) error {
|
||||||
|
if err := ensureRoot("upgrade"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
version := "latest"
|
||||||
|
for i := 0; i < len(args); i++ {
|
||||||
|
if args[i] == "--version" && i+1 < len(args) {
|
||||||
|
version = args[i+1]
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
arch := runtime.GOARCH
|
||||||
|
if arch != "amd64" && arch != "arm64" {
|
||||||
|
return fmt.Errorf("unsupported architecture: %s", arch)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Starting upgrade...")
|
||||||
|
|
||||||
|
binaryDir := filepath.Dir(defaultBinaryPath)
|
||||||
|
cliDir := filepath.Dir(defaultCLIPath)
|
||||||
|
newBinary := filepath.Join(binaryDir, ".xboard-node.new")
|
||||||
|
newCLI := filepath.Join(cliDir, ".xbctl.new")
|
||||||
|
|
||||||
|
binaryURL := resolveDownloadURL(fmt.Sprintf("xboard-node-linux-%s", arch), version)
|
||||||
|
cliURL := resolveDownloadURL(fmt.Sprintf("xbctl-linux-%s", arch), version)
|
||||||
|
|
||||||
|
fmt.Printf("Downloading %s...\n", binaryURL)
|
||||||
|
if err := downloadFile(binaryURL, newBinary); err != nil {
|
||||||
|
return fmt.Errorf("download binary: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Downloading %s...\n", cliURL)
|
||||||
|
if err := downloadFile(cliURL, newCLI); err != nil {
|
||||||
|
os.Remove(newBinary)
|
||||||
|
return fmt.Errorf("download xbctl: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Chmod(newBinary, 0o755); err != nil {
|
||||||
|
return cleanupFiles(newBinary, newCLI, fmt.Errorf("chmod binary: %w", err))
|
||||||
|
}
|
||||||
|
if err := os.Chmod(newCLI, 0o755); err != nil {
|
||||||
|
return cleanupFiles(newBinary, newCLI, fmt.Errorf("chmod xbctl: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate downloaded binaries
|
||||||
|
if out, err := exec.Command(newBinary, "-v").CombinedOutput(); err != nil {
|
||||||
|
return cleanupFiles(newBinary, newCLI, fmt.Errorf("binary version check failed: %s", string(out)))
|
||||||
|
}
|
||||||
|
if out, err := exec.Command(newCLI, "version").CombinedOutput(); err != nil {
|
||||||
|
return cleanupFiles(newBinary, newCLI, fmt.Errorf("xbctl version check failed: %s", string(out)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backup existing binaries
|
||||||
|
backupBinary := defaultBinaryPath + ".bak"
|
||||||
|
backupCLI := defaultCLIPath + ".bak"
|
||||||
|
// Backup existing binaries
|
||||||
|
if fileExists(defaultBinaryPath) {
|
||||||
|
if err := copyFile(defaultBinaryPath, backupBinary); err != nil {
|
||||||
|
return cleanupFiles(newBinary, newCLI, fmt.Errorf("backup binary: %w", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if fileExists(defaultCLIPath) {
|
||||||
|
if err := copyFile(defaultCLIPath, backupCLI); err != nil {
|
||||||
|
return cleanupFiles(newBinary, newCLI, fmt.Errorf("backup xbctl: %w", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomic rename
|
||||||
|
if err := os.Rename(newBinary, defaultBinaryPath); err != nil {
|
||||||
|
return cleanupFiles(newBinary, newCLI, fmt.Errorf("replace binary: %w", err))
|
||||||
|
}
|
||||||
|
if err := os.Rename(newCLI, defaultCLIPath); err != nil {
|
||||||
|
if fileExists(backupBinary) {
|
||||||
|
os.Rename(backupBinary, defaultBinaryPath)
|
||||||
|
}
|
||||||
|
os.Remove(newCLI)
|
||||||
|
return fmt.Errorf("replace xbctl: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recreate /usr/bin/xbctl symlink
|
||||||
|
os.Remove("/usr/bin/xbctl")
|
||||||
|
os.Symlink(defaultCLIPath, "/usr/bin/xbctl")
|
||||||
|
|
||||||
|
// Restart service
|
||||||
|
fmt.Println("Restarting service...")
|
||||||
|
runCommand("systemctl", "daemon-reload")
|
||||||
|
if err := runCommand("systemctl", "restart", serviceName); err != nil {
|
||||||
|
fmt.Println("Restart failed, rolling back...")
|
||||||
|
rollbackOK := true
|
||||||
|
if fileExists(backupBinary) {
|
||||||
|
if e := os.Rename(backupBinary, defaultBinaryPath); e != nil {
|
||||||
|
fmt.Printf("Warning: rollback binary failed: %v\n", e)
|
||||||
|
rollbackOK = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if fileExists(backupCLI) {
|
||||||
|
if e := os.Rename(backupCLI, defaultCLIPath); e != nil {
|
||||||
|
fmt.Printf("Warning: rollback xbctl failed: %v\n", e)
|
||||||
|
rollbackOK = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runCommand("systemctl", "daemon-reload")
|
||||||
|
if e := runCommand("systemctl", "restart", serviceName); e != nil {
|
||||||
|
return fmt.Errorf("upgrade and rollback restart both failed: %w", e)
|
||||||
|
}
|
||||||
|
if rollbackOK {
|
||||||
|
return errors.New("upgrade failed: service restart failed, rolled back successfully")
|
||||||
|
}
|
||||||
|
return errors.New("upgrade failed: partial rollback, check binary state manually")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up backups
|
||||||
|
os.Remove(backupBinary)
|
||||||
|
os.Remove(backupCLI)
|
||||||
|
|
||||||
|
// Update install-meta.json
|
||||||
|
newVer := "unknown"
|
||||||
|
if out, err := exec.Command(defaultBinaryPath, "-v").CombinedOutput(); err == nil {
|
||||||
|
newVer = strings.TrimSpace(string(out))
|
||||||
|
}
|
||||||
|
if root, err := loadWritableRootConfig(defaultConfigPath); err == nil {
|
||||||
|
instances, _ := root.NormalizeInstances()
|
||||||
|
writeInstallMetaVersioned(defaultMetaPath, root, newVer, latestInstanceID(instances))
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Upgrade complete (version: %s)\n", newVer)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runUninstall(args []string) error {
|
||||||
|
if err := ensureRoot("uninstall"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
purge := false
|
||||||
|
yes := false
|
||||||
|
for _, a := range args {
|
||||||
|
switch a {
|
||||||
|
case "--purge":
|
||||||
|
purge = true
|
||||||
|
case "--yes", "-y":
|
||||||
|
yes = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !yes {
|
||||||
|
fmt.Print("Proceed with uninstall? [y/N]: ")
|
||||||
|
var answer string
|
||||||
|
fmt.Scanln(&answer)
|
||||||
|
if answer != "y" && answer != "Y" {
|
||||||
|
fmt.Println("Uninstall cancelled")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var warnings []string
|
||||||
|
|
||||||
|
// Stop and disable service
|
||||||
|
if fileExists(serviceFilePath) {
|
||||||
|
if err := runCommand("systemctl", "stop", serviceName); err != nil {
|
||||||
|
warnings = append(warnings, fmt.Sprintf("stop service: %v", err))
|
||||||
|
}
|
||||||
|
if err := runCommand("systemctl", "disable", serviceName); err != nil {
|
||||||
|
warnings = append(warnings, fmt.Sprintf("disable service: %v", err))
|
||||||
|
}
|
||||||
|
if err := os.Remove(serviceFilePath); err != nil {
|
||||||
|
warnings = append(warnings, fmt.Sprintf("remove service file: %v", err))
|
||||||
|
}
|
||||||
|
runCommand("systemctl", "daemon-reload")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove binaries
|
||||||
|
// Remove binaries and symlinks
|
||||||
|
for _, p := range []string{defaultBinaryPath, defaultCLIPath, "/usr/bin/xbctl"} {
|
||||||
|
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
|
||||||
|
warnings = append(warnings, fmt.Sprintf("remove %s: %v", p, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if purge {
|
||||||
|
if err := os.RemoveAll(defaultInstallRoot); err != nil {
|
||||||
|
warnings = append(warnings, fmt.Sprintf("remove %s: %v", defaultInstallRoot, err))
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Removed %s\n", defaultInstallRoot)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
installerPath = "./install.sh"
|
os.Remove(defaultMetaPath)
|
||||||
|
fmt.Printf("Config preserved under %s\n", defaultInstallRoot)
|
||||||
}
|
}
|
||||||
all := append([]string{"bash", installerPath}, args...)
|
|
||||||
if usingSystemInstaller {
|
if len(warnings) > 0 {
|
||||||
all = append(all, "--binary", defaultBinaryPath, "--xbctl-binary", defaultCLIPath)
|
fmt.Println("Uninstall completed with warnings:")
|
||||||
|
for _, w := range warnings {
|
||||||
|
fmt.Printf(" - %s\n", w)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
return runCommand("sudo", all...)
|
|
||||||
|
fmt.Println("Uninstall complete")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureRoot(cmd string) error {
|
||||||
|
if os.Geteuid() != 0 {
|
||||||
|
return fmt.Errorf("%s requires root privileges; run with sudo", cmd)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveDownloadURL(artifact, version string) string {
|
||||||
|
// Gitea release download URL: {base}/releases/download/{tag}/{file}
|
||||||
|
return downloadBase + "/releases/download/" + version + "/" + artifact
|
||||||
|
}
|
||||||
|
|
||||||
|
// readDownloadToken returns the Gitea API token for downloading release assets.
|
||||||
|
// Lookup order: GITEA_TOKEN in credentials.env, then GITEA_TOKEN env var.
|
||||||
|
func readDownloadToken() string {
|
||||||
|
if data, err := os.ReadFile(defaultCredentialsPath); err == nil {
|
||||||
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if k, v, ok := strings.Cut(line, "="); ok && k == "GITEA_TOKEN" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return os.Getenv("GITEA_TOKEN")
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadFile(url, dest string) error {
|
||||||
|
client := &http.Client{Timeout: 120 * time.Second}
|
||||||
|
req, err := http.NewRequest("GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tok := readDownloadToken(); tok != "" {
|
||||||
|
req.Header.Set("Authorization", "token "+tok)
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return fmt.Errorf("HTTP %d from %s", resp.StatusCode, url)
|
||||||
|
}
|
||||||
|
f, err := os.Create(dest)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
_, err = io.Copy(f, resp.Body)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanupFiles(a, b string, err error) error {
|
||||||
|
os.Remove(a)
|
||||||
|
os.Remove(b)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileExists(path string) bool {
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyFile(src, dst string) error {
|
||||||
|
data, err := os.ReadFile(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(dst, data, 0o755)
|
||||||
}
|
}
|
||||||
|
|
||||||
func runCommand(name string, args ...string) error {
|
func runCommand(name string, args ...string) error {
|
||||||
@@ -413,7 +747,7 @@ func parsePositiveInt(raw string, field string) (int, error) {
|
|||||||
return value, nil
|
return value, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeBinding(panelURL string, nodeID int, machineID int) error {
|
func removeBinding(panelURL string, nodeID int, machineID int, instanceID string) error {
|
||||||
root, err := loadWritableRootConfig(defaultConfigPath)
|
root, err := loadWritableRootConfig(defaultConfigPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -426,12 +760,19 @@ func removeBinding(panelURL string, nodeID int, machineID int) error {
|
|||||||
kept := make([]config.Config, 0, len(instances))
|
kept := make([]config.Config, 0, len(instances))
|
||||||
removed := make([]config.Config, 0, 1)
|
removed := make([]config.Config, 0, 1)
|
||||||
for _, inst := range instances {
|
for _, inst := range instances {
|
||||||
matched := strings.TrimSpace(inst.Panel.URL) == strings.TrimSpace(panelURL)
|
var matched bool
|
||||||
if nodeID > 0 {
|
if instanceID != "" {
|
||||||
matched = matched && !inst.IsMachineMode() && inst.Panel.NodeID == nodeID
|
// Match by instance ID
|
||||||
}
|
id, _ := inst.AutoInstanceID()
|
||||||
if machineID > 0 {
|
matched = id == instanceID || inst.InstanceID == instanceID
|
||||||
matched = matched && inst.IsMachineMode() && inst.Machine != nil && inst.Machine.MachineID == machineID
|
} else {
|
||||||
|
matched = strings.TrimSpace(inst.Panel.URL) == strings.TrimSpace(panelURL)
|
||||||
|
if nodeID > 0 {
|
||||||
|
matched = matched && !inst.IsMachineMode() && inst.Panel.NodeID == nodeID
|
||||||
|
}
|
||||||
|
if machineID > 0 {
|
||||||
|
matched = matched && inst.IsMachineMode() && inst.Machine != nil && inst.Machine.MachineID == machineID
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if matched {
|
if matched {
|
||||||
removed = append(removed, inst)
|
removed = append(removed, inst)
|
||||||
@@ -440,13 +781,31 @@ func removeBinding(panelURL string, nodeID int, machineID int) error {
|
|||||||
kept = append(kept, inst)
|
kept = append(kept, inst)
|
||||||
}
|
}
|
||||||
if len(removed) == 0 {
|
if len(removed) == 0 {
|
||||||
|
if instanceID != "" {
|
||||||
|
return fmt.Errorf("binding not found: id=%s", instanceID)
|
||||||
|
}
|
||||||
if nodeID > 0 {
|
if nodeID > 0 {
|
||||||
return fmt.Errorf("binding not found: panel=%s node_id=%d", panelURL, nodeID)
|
return fmt.Errorf("binding not found: panel=%s node_id=%d", panelURL, nodeID)
|
||||||
}
|
}
|
||||||
return fmt.Errorf("binding not found: panel=%s machine_id=%d", panelURL, machineID)
|
return fmt.Errorf("binding not found: panel=%s machine_id=%d", panelURL, machineID)
|
||||||
}
|
}
|
||||||
if len(kept) == 0 {
|
if len(kept) == 0 {
|
||||||
return errors.New("refusing to remove the last binding; use xbctl uninstall if you want to remove everything")
|
// Last binding removed — stop the service to prevent crash-loop
|
||||||
|
root.Instances = nil
|
||||||
|
if err := writeRootConfig(defaultConfigPath, root); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := pruneCredentialKeys(defaultCredentialsPath, removed); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := writeInstallMeta(defaultMetaPath, root); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
runCommand("systemctl", "stop", serviceName)
|
||||||
|
fmt.Printf("removed %d binding(s)\n", len(removed))
|
||||||
|
fmt.Println("All bindings removed. Service stopped.")
|
||||||
|
fmt.Println("Use 'xbctl bind add-node/add-machine' to add a new binding, or 'xbctl uninstall' to fully uninstall.")
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
root.Instances = kept
|
root.Instances = kept
|
||||||
@@ -460,7 +819,7 @@ func removeBinding(panelURL string, nodeID int, machineID int) error {
|
|||||||
if err := writeInstallMeta(defaultMetaPath, root); err != nil {
|
if err := writeInstallMeta(defaultMetaPath, root); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := runCommand("sudo", "systemctl", "restart", serviceName); err != nil {
|
if err := runCommand("systemctl", "restart", serviceName); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Printf("removed %d binding(s)\n", len(removed))
|
fmt.Printf("removed %d binding(s)\n", len(removed))
|
||||||
@@ -490,11 +849,18 @@ func normalizeRootInstances(root *config.RootConfig) []config.Config {
|
|||||||
if len(root.Instances) > 0 {
|
if len(root.Instances) > 0 {
|
||||||
return append([]config.Config(nil), root.Instances...)
|
return append([]config.Config(nil), root.Instances...)
|
||||||
}
|
}
|
||||||
return []config.Config{root.Config}
|
// Only treat legacy single-config mode if the embedded Config is valid
|
||||||
|
if root.Config.Panel.URL != "" {
|
||||||
|
return []config.Config{root.Config}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeRootConfig(path string, root *config.RootConfig) error {
|
func writeRootConfig(path string, root *config.RootConfig) error {
|
||||||
instances := normalizeRootInstances(root)
|
instances := root.Instances
|
||||||
|
if len(instances) == 0 && root.Config.Panel.URL != "" {
|
||||||
|
instances = []config.Config{root.Config}
|
||||||
|
}
|
||||||
out := fileRootConfig{Instances: make([]fileInstance, 0, len(instances))}
|
out := fileRootConfig{Instances: make([]fileInstance, 0, len(instances))}
|
||||||
|
|
||||||
// Preserve top-level shared settings so instances can inherit them.
|
// Preserve top-level shared settings so instances can inherit them.
|
||||||
@@ -723,19 +1089,62 @@ func printRows(rows []instanceRow, output string) error {
|
|||||||
|
|
||||||
func systemctlState() string {
|
func systemctlState() string {
|
||||||
cmd := exec.Command("systemctl", "is-active", serviceName)
|
cmd := exec.Command("systemctl", "is-active", serviceName)
|
||||||
out, err := cmd.Output()
|
out, err := cmd.CombinedOutput()
|
||||||
|
state := strings.TrimSpace(string(out))
|
||||||
|
if state != "" {
|
||||||
|
return state
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "unknown"
|
return "unknown"
|
||||||
}
|
}
|
||||||
return strings.TrimSpace(string(out))
|
return state
|
||||||
}
|
}
|
||||||
|
|
||||||
func healthStatus() string {
|
func healthStatus() string {
|
||||||
cmd := exec.Command("curl", "-fsS", defaultHealthURL)
|
return instanceAwareHealth()
|
||||||
if err := cmd.Run(); err != nil {
|
}
|
||||||
|
|
||||||
|
func instanceAwareHealth() string {
|
||||||
|
port := 0
|
||||||
|
if meta, err := loadInstallMeta(defaultMetaPath); err == nil {
|
||||||
|
for _, inst := range meta.Instances {
|
||||||
|
if inst.HealthPort > 0 {
|
||||||
|
port = inst.HealthPort
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if port == 0 {
|
||||||
|
if root, err := loadWritableRootConfig(defaultConfigPath); err == nil {
|
||||||
|
// Check top-level health_port first (inherited by all instances)
|
||||||
|
if root.HealthPort > 0 {
|
||||||
|
port = root.HealthPort
|
||||||
|
}
|
||||||
|
if port == 0 {
|
||||||
|
instances, _ := root.NormalizeInstances()
|
||||||
|
for _, inst := range instances {
|
||||||
|
if inst.HealthPort > 0 {
|
||||||
|
port = inst.HealthPort
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if port == 0 {
|
||||||
|
return "disabled"
|
||||||
|
}
|
||||||
|
url := fmt.Sprintf("http://127.0.0.1:%d/healthz", port)
|
||||||
|
client := &http.Client{Timeout: 3 * time.Second}
|
||||||
|
resp, err := client.Get(url)
|
||||||
|
if err != nil {
|
||||||
return "down"
|
return "down"
|
||||||
}
|
}
|
||||||
return "ok"
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode == 200 {
|
||||||
|
return "ok"
|
||||||
|
}
|
||||||
|
return "down"
|
||||||
}
|
}
|
||||||
|
|
||||||
func formatTarget(nodeID *int, machineID *int) string {
|
func formatTarget(nodeID *int, machineID *int) string {
|
||||||
@@ -756,6 +1165,39 @@ func intPtr(v int) *int {
|
|||||||
return &vv
|
return &vv
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func latestInstanceID(instances []*config.Config) string {
|
||||||
|
if len(instances) > 0 {
|
||||||
|
id, _ := instances[len(instances)-1].AutoInstanceID()
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func regenerateServiceFile() error {
|
||||||
|
unit := fmt.Sprintf(`[Unit]
|
||||||
|
Description=Xboard Node Backend
|
||||||
|
Documentation=http://64.204.66.12:3000/will/xboard-node
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
WorkingDirectory=%s
|
||||||
|
EnvironmentFile=-%s
|
||||||
|
ExecStart=%s -c %s
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
LimitNOFILE=1048576
|
||||||
|
NoNewPrivileges=true
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
`, defaultInstallRoot, defaultCredentialsPath, defaultBinaryPath, defaultConfigPath)
|
||||||
|
return os.WriteFile(serviceFilePath, []byte(unit), 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
func machineIDPtr(cfg *config.Config) *int {
|
func machineIDPtr(cfg *config.Config) *int {
|
||||||
if cfg.Machine == nil || cfg.Machine.MachineID <= 0 {
|
if cfg.Machine == nil || cfg.Machine.MachineID <= 0 {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# DNS Providers
|
||||||
|
|
||||||
|
Used by `cert_config.cert_mode: "dns"` for ACME DNS-01.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
cert_config:
|
||||||
|
cert_mode: "dns"
|
||||||
|
dns_provider: "cloudflare"
|
||||||
|
dns_env:
|
||||||
|
CLOUDFLARE_DNS_API_TOKEN: "xxx"
|
||||||
|
```
|
||||||
|
|
||||||
|
| provider | aliases | env |
|
||||||
|
|---|---|---|
|
||||||
|
| cloudflare | cf | `CLOUDFLARE_DNS_API_TOKEN` |
|
||||||
|
| alidns | aliyun | `ALICLOUD_ACCESS_KEY_ID`, `ALICLOUD_ACCESS_KEY_SECRET` |
|
||||||
|
| tencentcloud | tencent | `TENCENTCLOUD_SECRET_ID`, `TENCENTCLOUD_SECRET_KEY` |
|
||||||
|
| route53 | aws | AWS credential chain (env / IAM role / profile) |
|
||||||
|
| godaddy | | `GODADDY_API_KEY`, `GODADDY_API_SECRET` |
|
||||||
|
| namecheap | | `NAMECHEAP_API_KEY`, `NAMECHEAP_API_USER` |
|
||||||
|
| namesilo | | `NAMESILO_API_TOKEN` |
|
||||||
|
| digitalocean | do | `DO_AUTH_TOKEN` |
|
||||||
|
| linode | | `LINODE_TOKEN` |
|
||||||
|
| hetzner | | `HETZNER_API_TOKEN` |
|
||||||
|
| gandi | | `GANDI_BEARER_TOKEN` |
|
||||||
|
| porkbun | | `PORKBUN_API_KEY`, `PORKBUN_API_SECRET_KEY` |
|
||||||
|
| netlify | | `NETLIFY_PERSONAL_ACCESS_TOKEN` |
|
||||||
|
| azure | | `AZURE_SUBSCRIPTION_ID`, `AZURE_RESOURCE_GROUP_NAME` |
|
||||||
|
| googleclouddns | gcp, gcloud | `GCE_PROJECT` |
|
||||||
|
| huaweicloud | huawei | `HUAWEICLOUD_ACCESS_KEY_ID`, `HUAWEICLOUD_SECRET_ACCESS_KEY` |
|
||||||
|
| bunny | | `BUNNY_ACCESS_KEY` |
|
||||||
|
| duckdns | | `DUCKDNS_TOKEN` |
|
||||||
|
| ovh | | `OVH_APPLICATION_KEY`, `OVH_APPLICATION_SECRET`, `OVH_CONSUMER_KEY` |
|
||||||
|
| vultr | | `VULTR_API_TOKEN` |
|
||||||
|
| desec | | `DESEC_TOKEN` |
|
||||||
@@ -8,18 +8,48 @@ require (
|
|||||||
github.com/go-viper/mapstructure/v2 v2.5.0
|
github.com/go-viper/mapstructure/v2 v2.5.0
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/libdns/alidns v1.0.6
|
github.com/libdns/alidns v1.0.6
|
||||||
|
github.com/libdns/azure v0.5.0
|
||||||
|
github.com/libdns/bunny v1.6.1
|
||||||
github.com/libdns/cloudflare v0.2.2
|
github.com/libdns/cloudflare v0.2.2
|
||||||
|
github.com/libdns/desec v1.1.1
|
||||||
|
github.com/libdns/digitalocean v0.0.0-20250606071607-dfa7af5c2e31
|
||||||
|
github.com/libdns/duckdns v0.3.0
|
||||||
|
github.com/libdns/gandi v1.1.0
|
||||||
|
github.com/libdns/godaddy v1.1.0
|
||||||
|
github.com/libdns/googleclouddns v1.2.0
|
||||||
|
github.com/libdns/hetzner v1.0.0
|
||||||
|
github.com/libdns/huaweicloud v1.0.1
|
||||||
|
github.com/libdns/linode v0.5.0
|
||||||
|
github.com/libdns/namecheap v1.0.0
|
||||||
|
github.com/libdns/namesilo v1.0.0
|
||||||
|
github.com/libdns/netlify v1.2.0
|
||||||
|
github.com/libdns/ovh v1.1.0
|
||||||
|
github.com/libdns/porkbun v1.1.0
|
||||||
|
github.com/libdns/route53 v1.6.0
|
||||||
|
github.com/libdns/tencentcloud v1.4.3
|
||||||
|
github.com/libdns/vultr/v2 v2.0.4
|
||||||
github.com/sagernet/sing v0.8.2
|
github.com/sagernet/sing v0.8.2
|
||||||
github.com/sagernet/sing-box v1.13.2
|
github.com/sagernet/sing-box v1.13.2
|
||||||
github.com/shirou/gopsutil/v4 v4.26.2
|
github.com/shirou/gopsutil/v4 v4.26.2
|
||||||
github.com/xtls/xray-core v1.260327.0
|
github.com/xtls/xray-core v1.260327.0
|
||||||
|
golang.org/x/net v0.52.0
|
||||||
golang.org/x/term v0.41.0
|
golang.org/x/term v0.41.0
|
||||||
golang.org/x/time v0.12.0
|
golang.org/x/time v0.12.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
cloud.google.com/go/auth v0.16.5 // indirect
|
||||||
|
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||||
|
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||||
filippo.io/edwards25519 v1.1.0 // indirect
|
filippo.io/edwards25519 v1.1.0 // indirect
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 // indirect
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.9.0 // indirect
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 // indirect
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0 // indirect
|
||||||
|
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 // indirect
|
||||||
|
github.com/PuerkitoBio/purell v1.1.1 // indirect
|
||||||
|
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
|
||||||
github.com/ajg/form v1.5.1 // indirect
|
github.com/ajg/form v1.5.1 // indirect
|
||||||
github.com/akutz/memconn v0.1.0 // indirect
|
github.com/akutz/memconn v0.1.0 // indirect
|
||||||
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect
|
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect
|
||||||
@@ -27,7 +57,23 @@ require (
|
|||||||
github.com/anthropics/anthropic-sdk-go v1.26.0 // indirect
|
github.com/anthropics/anthropic-sdk-go v1.26.0 // indirect
|
||||||
github.com/anytls/sing-anytls v0.0.11 // indirect
|
github.com/anytls/sing-anytls v0.0.11 // indirect
|
||||||
github.com/apernet/quic-go v0.59.1-0.20260217092621-db4786c77a22 // indirect
|
github.com/apernet/quic-go v0.59.1-0.20260217092621-db4786c77a22 // indirect
|
||||||
|
github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.39.1 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.31.10 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.18.14 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.8 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.8 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.8 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/route53 v1.58.3 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.29.4 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.0 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.38.5 // indirect
|
||||||
|
github.com/aws/smithy-go v1.23.0 // indirect
|
||||||
github.com/caddyserver/zerossl v0.1.5 // indirect
|
github.com/caddyserver/zerossl v0.1.5 // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/cilium/ebpf v0.21.0 // indirect
|
github.com/cilium/ebpf v0.21.0 // indirect
|
||||||
github.com/cloudflare/circl v1.6.3 // indirect
|
github.com/cloudflare/circl v1.6.3 // indirect
|
||||||
github.com/coder/websocket v1.8.14 // indirect
|
github.com/coder/websocket v1.8.14 // indirect
|
||||||
@@ -36,8 +82,10 @@ require (
|
|||||||
github.com/database64128/netx-go v0.1.1 // indirect
|
github.com/database64128/netx-go v0.1.1 // indirect
|
||||||
github.com/database64128/tfo-go/v2 v2.3.2 // indirect
|
github.com/database64128/tfo-go/v2 v2.3.2 // indirect
|
||||||
github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect
|
github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect
|
||||||
|
github.com/digitalocean/godo v1.148.0 // indirect
|
||||||
github.com/ebitengine/purego v0.10.0 // indirect
|
github.com/ebitengine/purego v0.10.0 // indirect
|
||||||
github.com/enfein/mieru/v3 v3.29.0 // indirect
|
github.com/enfein/mieru/v3 v3.29.0 // indirect
|
||||||
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
github.com/florianl/go-nfqueue/v2 v2.0.2 // indirect
|
github.com/florianl/go-nfqueue/v2 v2.0.2 // indirect
|
||||||
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
|
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
|
||||||
github.com/gaissmai/bart v0.18.0 // indirect
|
github.com/gaissmai/bart v0.18.0 // indirect
|
||||||
@@ -45,38 +93,67 @@ require (
|
|||||||
github.com/go-chi/chi/v5 v5.2.5 // indirect
|
github.com/go-chi/chi/v5 v5.2.5 // indirect
|
||||||
github.com/go-chi/render v1.0.3 // indirect
|
github.com/go-chi/render v1.0.3 // indirect
|
||||||
github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced // indirect
|
github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced // indirect
|
||||||
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||||
|
github.com/go-openapi/analysis v0.19.16 // indirect
|
||||||
|
github.com/go-openapi/errors v0.19.9 // indirect
|
||||||
|
github.com/go-openapi/jsonpointer v0.19.5 // indirect
|
||||||
|
github.com/go-openapi/jsonreference v0.19.5 // indirect
|
||||||
|
github.com/go-openapi/loads v0.20.0 // indirect
|
||||||
|
github.com/go-openapi/runtime v0.19.24 // indirect
|
||||||
|
github.com/go-openapi/spec v0.20.0 // indirect
|
||||||
|
github.com/go-openapi/strfmt v0.19.11 // indirect
|
||||||
|
github.com/go-openapi/swag v0.19.12 // indirect
|
||||||
|
github.com/go-openapi/validate v0.20.0 // indirect
|
||||||
|
github.com/go-resty/resty/v2 v2.16.5 // indirect
|
||||||
|
github.com/go-stack/stack v1.8.0 // indirect
|
||||||
github.com/gobwas/httphead v0.1.0 // indirect
|
github.com/gobwas/httphead v0.1.0 // indirect
|
||||||
github.com/gobwas/pool v0.2.1 // indirect
|
github.com/gobwas/pool v0.2.1 // indirect
|
||||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||||
github.com/gofrs/uuid/v5 v5.4.0 // indirect
|
github.com/gofrs/uuid/v5 v5.4.0 // indirect
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
|
||||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||||
github.com/google/btree v1.1.3 // indirect
|
github.com/google/btree v1.1.3 // indirect
|
||||||
github.com/google/go-cmp v0.7.0 // indirect
|
github.com/google/go-cmp v0.7.0 // indirect
|
||||||
|
github.com/google/go-querystring v1.1.0 // indirect
|
||||||
github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 // indirect
|
github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 // indirect
|
||||||
|
github.com/google/s2a-go v0.1.9 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
|
||||||
|
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
|
||||||
|
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||||
|
github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
|
||||||
github.com/hashicorp/yamux v0.1.2 // indirect
|
github.com/hashicorp/yamux v0.1.2 // indirect
|
||||||
github.com/hdevalence/ed25519consensus v0.2.0 // indirect
|
github.com/hdevalence/ed25519consensus v0.2.0 // indirect
|
||||||
github.com/insomniacslk/dhcp v0.0.0-20260220084031-5adc3eb26f91 // indirect
|
github.com/insomniacslk/dhcp v0.0.0-20260220084031-5adc3eb26f91 // indirect
|
||||||
|
github.com/josharian/intern v1.0.0 // indirect
|
||||||
github.com/jsimonetti/rtnetlink v1.4.0 // indirect
|
github.com/jsimonetti/rtnetlink v1.4.0 // indirect
|
||||||
github.com/juju/ratelimit v1.0.2 // indirect
|
github.com/juju/ratelimit v1.0.2 // indirect
|
||||||
github.com/keybase/go-keychain v0.0.1 // indirect
|
github.com/keybase/go-keychain v0.0.1 // indirect
|
||||||
github.com/klauspost/compress v1.18.0 // indirect
|
github.com/klauspost/compress v1.18.0 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
|
github.com/kylelemons/godebug v1.1.0 // indirect
|
||||||
github.com/libdns/acmedns v0.5.0 // indirect
|
github.com/libdns/acmedns v0.5.0 // indirect
|
||||||
github.com/libdns/libdns v1.1.1 // indirect
|
github.com/libdns/libdns v1.1.1 // indirect
|
||||||
|
github.com/linode/linodego v1.56.0 // indirect
|
||||||
github.com/logrusorgru/aurora v2.0.3+incompatible // indirect
|
github.com/logrusorgru/aurora v2.0.3+incompatible // indirect
|
||||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||||
|
github.com/mailru/easyjson v0.7.7 // indirect
|
||||||
github.com/mdlayher/netlink v1.9.0 // indirect
|
github.com/mdlayher/netlink v1.9.0 // indirect
|
||||||
github.com/mdlayher/socket v0.5.1 // indirect
|
github.com/mdlayher/socket v0.5.1 // indirect
|
||||||
github.com/metacubex/utls v1.8.4 // indirect
|
github.com/metacubex/utls v1.8.4 // indirect
|
||||||
github.com/mholt/acmez/v3 v3.1.6 // indirect
|
github.com/mholt/acmez/v3 v3.1.6 // indirect
|
||||||
github.com/miekg/dns v1.1.72 // indirect
|
github.com/miekg/dns v1.1.72 // indirect
|
||||||
github.com/mitchellh/go-ps v1.0.0 // indirect
|
github.com/mitchellh/go-ps v1.0.0 // indirect
|
||||||
|
github.com/mitchellh/mapstructure v1.4.0 // indirect
|
||||||
|
github.com/netlify/open-api/v2 v2.40.0 // indirect
|
||||||
github.com/openai/openai-go/v3 v3.26.0 // indirect
|
github.com/openai/openai-go/v3 v3.26.0 // indirect
|
||||||
|
github.com/ovh/go-ovh v1.7.0 // indirect
|
||||||
github.com/pelletier/go-toml v1.9.5 // indirect
|
github.com/pelletier/go-toml v1.9.5 // indirect
|
||||||
github.com/pierrec/lz4/v4 v4.1.21 // indirect
|
github.com/pierrec/lz4/v4 v4.1.21 // indirect
|
||||||
github.com/pires/go-proxyproto v0.11.0 // indirect
|
github.com/pires/go-proxyproto v0.11.0 // indirect
|
||||||
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||||
github.com/prometheus-community/pro-bing v0.4.0 // indirect
|
github.com/prometheus-community/pro-bing v0.4.0 // indirect
|
||||||
github.com/quic-go/qpack v0.6.0 // indirect
|
github.com/quic-go/qpack v0.6.0 // indirect
|
||||||
@@ -147,10 +224,17 @@ require (
|
|||||||
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect
|
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect
|
||||||
github.com/vishvananda/netlink v1.3.1 // indirect
|
github.com/vishvananda/netlink v1.3.1 // indirect
|
||||||
github.com/vishvananda/netns v0.0.5 // indirect
|
github.com/vishvananda/netns v0.0.5 // indirect
|
||||||
|
github.com/vultr/govultr/v3 v3.20.0 // indirect
|
||||||
github.com/x448/float16 v0.8.4 // indirect
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f // indirect
|
github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f // indirect
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
github.com/zeebo/blake3 v0.2.4 // indirect
|
github.com/zeebo/blake3 v0.2.4 // indirect
|
||||||
|
go.mongodb.org/mongo-driver v1.4.4 // indirect
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
|
||||||
|
go.opentelemetry.io/otel v1.39.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/metric v1.39.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/trace v1.39.0 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
go.uber.org/zap v1.27.1 // indirect
|
go.uber.org/zap v1.27.1 // indirect
|
||||||
go.uber.org/zap/exp v0.3.0 // indirect
|
go.uber.org/zap/exp v0.3.0 // indirect
|
||||||
@@ -159,7 +243,6 @@ require (
|
|||||||
golang.org/x/crypto v0.49.0 // indirect
|
golang.org/x/crypto v0.49.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect
|
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect
|
||||||
golang.org/x/mod v0.33.0 // indirect
|
golang.org/x/mod v0.33.0 // indirect
|
||||||
golang.org/x/net v0.52.0 // indirect
|
|
||||||
golang.org/x/oauth2 v0.34.0 // indirect
|
golang.org/x/oauth2 v0.34.0 // indirect
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/sys v0.42.0 // indirect
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
@@ -168,13 +251,16 @@ require (
|
|||||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||||
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect
|
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect
|
||||||
golang.zx2c4.com/wireguard/windows v0.5.3 // indirect
|
golang.zx2c4.com/wireguard/windows v0.5.3 // indirect
|
||||||
|
google.golang.org/api v0.249.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||||
google.golang.org/grpc v1.79.3 // indirect
|
google.golang.org/grpc v1.79.3 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
|
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect
|
gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect
|
||||||
lukechampine.com/blake3 v1.4.1 // indirect
|
lukechampine.com/blake3 v1.4.1 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
replace github.com/sagernet/sing-box => github.com/cedar2025/sing-box v1.14.0-alpha.2.0.20260316103356-2e665cb7e295
|
replace github.com/sagernet/sing-box => github.com/cedar2025/sing-box v1.14.0-alpha.2.0.20260316103356-2e665cb7e295
|
||||||
|
|
||||||
replace github.com/xtls/xray-core => github.com/cedar2025/Xray-core v0.0.0-20260409213332-f47935539965
|
replace github.com/xtls/xray-core => github.com/cedar2025/Xray-core v0.0.0-20260409213332-f47935539965
|
||||||
|
|||||||
+8
-7
@@ -27,7 +27,7 @@ DEFAULT_ACTION="install"
|
|||||||
DEFAULT_RELEASE_VERSION="latest"
|
DEFAULT_RELEASE_VERSION="latest"
|
||||||
DEFAULT_LOG_LEVEL="info"
|
DEFAULT_LOG_LEVEL="info"
|
||||||
DEFAULT_KERNEL_LOG_LEVEL="warn"
|
DEFAULT_KERNEL_LOG_LEVEL="warn"
|
||||||
DEFAULT_DOWNLOAD_BASE="https://github.com/cedar2025/xboard-node/releases"
|
DEFAULT_DOWNLOAD_BASE="http://64.204.66.12:3000/will/xboard-node/releases/download"
|
||||||
|
|
||||||
ACTION="${DEFAULT_ACTION}"
|
ACTION="${DEFAULT_ACTION}"
|
||||||
MODE=""
|
MODE=""
|
||||||
@@ -482,11 +482,8 @@ select_binary_source() {
|
|||||||
|
|
||||||
resolve_download_url() {
|
resolve_download_url() {
|
||||||
local artifact="$1"
|
local artifact="$1"
|
||||||
if [ "$RELEASE_VERSION" = "latest" ]; then
|
# Gitea release download URL: {base}/{tag}/{file}
|
||||||
DOWNLOAD_URL="${DEFAULT_DOWNLOAD_BASE}/latest/download/${artifact}"
|
DOWNLOAD_URL="${DEFAULT_DOWNLOAD_BASE}/${RELEASE_VERSION}/${artifact}"
|
||||||
else
|
|
||||||
DOWNLOAD_URL="${DEFAULT_DOWNLOAD_BASE}/download/${RELEASE_VERSION}/${artifact}"
|
|
||||||
fi
|
|
||||||
}
|
}
|
||||||
|
|
||||||
stage_binary() {
|
stage_binary() {
|
||||||
@@ -592,7 +589,7 @@ render_service() {
|
|||||||
cat >"$TMP_DIR/${SERVICE_NAME}" <<EOF_UNIT
|
cat >"$TMP_DIR/${SERVICE_NAME}" <<EOF_UNIT
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Xboard Node Backend
|
Description=Xboard Node Backend
|
||||||
Documentation=https://github.com/cedar2025/xboard-node
|
Documentation=http://64.204.66.12:3000/will/xboard-node
|
||||||
After=network-online.target
|
After=network-online.target
|
||||||
Wants=network-online.target
|
Wants=network-online.target
|
||||||
|
|
||||||
@@ -655,6 +652,7 @@ install_staged_files() {
|
|||||||
install -m 755 "$0" "$INSTALLER_COPY_PATH"
|
install -m 755 "$0" "$INSTALLER_COPY_PATH"
|
||||||
fi
|
fi
|
||||||
install -m 755 "$TMP_DIR/xbctl" "$CLI_PATH"
|
install -m 755 "$TMP_DIR/xbctl" "$CLI_PATH"
|
||||||
|
ln -sf "$CLI_PATH" /usr/bin/xbctl 2>/dev/null || true
|
||||||
install -m 644 "$TMP_DIR/${SERVICE_NAME}" "$SERVICE_PATH"
|
install -m 644 "$TMP_DIR/${SERVICE_NAME}" "$SERVICE_PATH"
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable "$SERVICE_NAME" > /dev/null 2>&1
|
systemctl enable "$SERVICE_NAME" > /dev/null 2>&1
|
||||||
@@ -722,6 +720,7 @@ perform_install() {
|
|||||||
if [ "$HEALTH_ENABLED" -eq 1 ]; then
|
if [ "$HEALTH_ENABLED" -eq 1 ]; then
|
||||||
log_info "Health: http://127.0.0.1:${HEALTH_PORT}/healthz"
|
log_info "Health: http://127.0.0.1:${HEALTH_PORT}/healthz"
|
||||||
fi
|
fi
|
||||||
|
log_info "CLI: ${CLI_PATH} (run '${CLI_PATH} list' if xbctl is not in PATH)"
|
||||||
}
|
}
|
||||||
|
|
||||||
perform_upgrade() {
|
perform_upgrade() {
|
||||||
@@ -739,6 +738,7 @@ perform_upgrade() {
|
|||||||
backup_existing_state
|
backup_existing_state
|
||||||
install -m 755 "$TMP_DIR/xboard-node" "$BINARY_PATH"
|
install -m 755 "$TMP_DIR/xboard-node" "$BINARY_PATH"
|
||||||
install -m 755 "$TMP_DIR/xbctl" "$CLI_PATH"
|
install -m 755 "$TMP_DIR/xbctl" "$CLI_PATH"
|
||||||
|
ln -sf "$CLI_PATH" /usr/bin/xbctl 2>/dev/null || true
|
||||||
install -m 644 "$TMP_DIR/${SERVICE_NAME}" "$SERVICE_PATH"
|
install -m 644 "$TMP_DIR/${SERVICE_NAME}" "$SERVICE_PATH"
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl restart "$SERVICE_NAME"
|
systemctl restart "$SERVICE_NAME"
|
||||||
@@ -772,6 +772,7 @@ perform_uninstall() {
|
|||||||
fi
|
fi
|
||||||
rm -f "$BINARY_PATH"
|
rm -f "$BINARY_PATH"
|
||||||
rm -f "$CLI_PATH"
|
rm -f "$CLI_PATH"
|
||||||
|
rm -f /usr/bin/xbctl 2>/dev/null || true
|
||||||
if [ "$PURGE" -eq 1 ]; then
|
if [ "$PURGE" -eq 1 ]; then
|
||||||
rm -rf "$INSTALL_ROOT"
|
rm -rf "$INSTALL_ROOT"
|
||||||
log_info "Removed ${INSTALL_ROOT}"
|
log_info "Removed ${INSTALL_ROOT}"
|
||||||
|
|||||||
+135
-76
@@ -5,23 +5,25 @@ import (
|
|||||||
"crypto/ecdsa"
|
"crypto/ecdsa"
|
||||||
"crypto/elliptic"
|
"crypto/elliptic"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"crypto/x509/pkix"
|
"crypto/x509/pkix"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/big"
|
"math/big"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/caddyserver/certmagic"
|
"github.com/caddyserver/certmagic"
|
||||||
"github.com/libdns/alidns"
|
|
||||||
"github.com/libdns/cloudflare"
|
|
||||||
|
|
||||||
|
"github.com/cedar2025/xboard-node/internal/cert/dnsproviders"
|
||||||
"github.com/cedar2025/xboard-node/internal/config"
|
"github.com/cedar2025/xboard-node/internal/config"
|
||||||
"github.com/cedar2025/xboard-node/internal/kernel"
|
"github.com/cedar2025/xboard-node/internal/kernel"
|
||||||
"github.com/cedar2025/xboard-node/internal/nlog"
|
"github.com/cedar2025/xboard-node/internal/nlog"
|
||||||
@@ -42,20 +44,21 @@ import (
|
|||||||
//
|
//
|
||||||
// Priority Logic (when CertMode is empty):
|
// Priority Logic (when CertMode is empty):
|
||||||
//
|
//
|
||||||
// 1. "http" (if auto_tls is true)
|
// 1. "http" (if auto_tls is true)
|
||||||
// 2. "content" (if both CertContent and KeyContent are provided)
|
// 2. "content" (if both CertContent and KeyContent are provided)
|
||||||
// 3. "file" (if both CertFile and KeyFile paths are provided)
|
// 3. "file" (if both CertFile and KeyFile paths are provided)
|
||||||
// 4. "none" (default)
|
// 4. "none" (default)
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
cfg config.CertConfig
|
cfg config.CertConfig
|
||||||
|
|
||||||
// Cert material stored atomically so the ACME renewal goroutine can
|
// Atomic so the ACME renewal goroutine can swap without racing readers.
|
||||||
// swap it without racing with TLSCert() readers.
|
|
||||||
mat atomic.Pointer[certMaterial]
|
mat atomic.Pointer[certMaterial]
|
||||||
|
|
||||||
magic *certmagic.Config
|
magic *certmagic.Config
|
||||||
renewed atomic.Bool
|
renewed atomic.Bool
|
||||||
acmeStarted bool
|
acmeStarted bool
|
||||||
|
acmeFingerprint string
|
||||||
|
acmeCancel context.CancelFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
// certMaterial is an immutable snapshot of PEM-encoded cert + key.
|
// certMaterial is an immutable snapshot of PEM-encoded cert + key.
|
||||||
@@ -132,14 +135,22 @@ func (m *Manager) loadPersistedPEM() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Reconfigure applies a new cert configuration at runtime (e.g. from panel push).
|
// Reconfigure applies a new cert configuration at runtime (e.g. from panel push).
|
||||||
// For ACME modes that are already running this is a no-op — a process restart
|
|
||||||
// is required to change ACME settings.
|
|
||||||
// Returns true when the PEM material changed (caller should restart kernel).
|
// Returns true when the PEM material changed (caller should restart kernel).
|
||||||
func (m *Manager) Reconfigure(ctx context.Context, newCfg config.CertConfig) (bool, error) {
|
func (m *Manager) Reconfigure(ctx context.Context, newCfg config.CertConfig) (bool, error) {
|
||||||
if newCfg.CertDir == "" {
|
if newCfg.CertDir == "" {
|
||||||
newCfg.CertDir = m.cfg.CertDir
|
newCfg.CertDir = m.cfg.CertDir
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If ACME is running and the new config materially differs (or switches
|
||||||
|
// away from ACME), tear down the old certmagic instance first.
|
||||||
|
if m.acmeStarted {
|
||||||
|
newFp := acmeFingerprint(newCfg)
|
||||||
|
newMode := resolveModeFor(newCfg)
|
||||||
|
if newMode != "http" && newMode != "dns" || newFp != m.acmeFingerprint {
|
||||||
|
m.tearDownACME()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
oldTLS := m.TLSCert()
|
oldTLS := m.TLSCert()
|
||||||
m.cfg = newCfg
|
m.cfg = newCfg
|
||||||
|
|
||||||
@@ -151,6 +162,65 @@ func (m *Manager) Reconfigure(ctx context.Context, newCfg config.CertConfig) (bo
|
|||||||
return !pemEqual(oldTLS, newTLS), nil
|
return !pemEqual(oldTLS, newTLS), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tearDownACME cancels the running certmagic background goroutines and clears
|
||||||
|
// related state so a fresh ACME init can run.
|
||||||
|
func (m *Manager) tearDownACME() {
|
||||||
|
if m.acmeCancel != nil {
|
||||||
|
m.acmeCancel()
|
||||||
|
m.acmeCancel = nil
|
||||||
|
}
|
||||||
|
m.magic = nil
|
||||||
|
m.acmeStarted = false
|
||||||
|
m.acmeFingerprint = ""
|
||||||
|
m.mat.Store(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// acmeFingerprint produces a stable string capturing every config field that
|
||||||
|
// requires re-running ACME when changed.
|
||||||
|
func acmeFingerprint(cfg config.CertConfig) string {
|
||||||
|
keys := make([]string, 0, len(cfg.DNSEnv))
|
||||||
|
for k := range cfg.DNSEnv {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(strings.ToLower(strings.TrimSpace(cfg.CertMode)))
|
||||||
|
b.WriteByte('|')
|
||||||
|
b.WriteString(strings.TrimSpace(cfg.Domain))
|
||||||
|
b.WriteByte('|')
|
||||||
|
b.WriteString(strings.TrimSpace(cfg.Email))
|
||||||
|
b.WriteByte('|')
|
||||||
|
b.WriteString(strings.TrimSpace(cfg.DNSProvider))
|
||||||
|
b.WriteByte('|')
|
||||||
|
for _, k := range keys {
|
||||||
|
b.WriteString(k)
|
||||||
|
b.WriteByte('=')
|
||||||
|
b.WriteString(cfg.DNSEnv[k])
|
||||||
|
b.WriteByte(';')
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256([]byte(b.String()))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveModeFor mirrors (*Manager).resolveMode() for an arbitrary cfg, used
|
||||||
|
// by Reconfigure to decide tear-down without mutating manager state.
|
||||||
|
func resolveModeFor(cfg config.CertConfig) string {
|
||||||
|
mode := strings.ToLower(strings.TrimSpace(cfg.CertMode))
|
||||||
|
if mode != "" {
|
||||||
|
return mode
|
||||||
|
}
|
||||||
|
if cfg.AutoTLS {
|
||||||
|
return "http"
|
||||||
|
}
|
||||||
|
if cfg.CertContent != "" && cfg.KeyContent != "" {
|
||||||
|
return "content"
|
||||||
|
}
|
||||||
|
if cfg.CertFile != "" && cfg.KeyFile != "" {
|
||||||
|
return "file"
|
||||||
|
}
|
||||||
|
return "none"
|
||||||
|
}
|
||||||
|
|
||||||
// resolveMode returns the effective cert mode, handling backward compat for auto_tls.
|
// resolveMode returns the effective cert mode, handling backward compat for auto_tls.
|
||||||
func (m *Manager) resolveMode() string {
|
func (m *Manager) resolveMode() string {
|
||||||
mode := strings.ToLower(strings.TrimSpace(m.cfg.CertMode))
|
mode := strings.ToLower(strings.TrimSpace(m.cfg.CertMode))
|
||||||
@@ -321,10 +391,8 @@ func (m *Manager) startContent() error {
|
|||||||
// ─── Mode: http / dns (ACME) ──────────────────────────────────────────────
|
// ─── Mode: http / dns (ACME) ──────────────────────────────────────────────
|
||||||
|
|
||||||
func (m *Manager) startACME(ctx context.Context, dnsSolver *certmagic.DNS01Solver) error {
|
func (m *Manager) startACME(ctx context.Context, dnsSolver *certmagic.DNS01Solver) error {
|
||||||
// Guard: certmagic's background goroutines must only be started once.
|
// Idempotent for unchanged config: Reconfigure already tore down stale ACME state.
|
||||||
// A process restart is required to change ACME settings at runtime.
|
|
||||||
if m.acmeStarted {
|
if m.acmeStarted {
|
||||||
nlog.Core().Info("cert: ACME already active, skipping re-init (restart to change ACME settings)")
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,9 +416,23 @@ func (m *Manager) startACME(ctx context.Context, dnsSolver *certmagic.DNS01Solve
|
|||||||
magic = certmagic.New(cache, certmagic.Config{
|
magic = certmagic.New(cache, certmagic.Config{
|
||||||
Storage: storage,
|
Storage: storage,
|
||||||
OnEvent: func(evtCtx context.Context, event string, data map[string]any) error {
|
OnEvent: func(evtCtx context.Context, event string, data map[string]any) error {
|
||||||
if event == "cert_obtained" {
|
// Only react to renewals; initial load happens explicitly after ObtainCertSync.
|
||||||
m.loadCertFromStorage(evtCtx, storage, data)
|
if event != "cert_obtained" {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
if renewed, _ := data["renewal"].(bool); !renewed {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
issuerKey, _ := data["issuer"].(string)
|
||||||
|
if issuerKey == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := m.loadPEMFromStorage(evtCtx, storage, issuerKey, m.cfg.Domain); err != nil {
|
||||||
|
nlog.Core().Error("failed to reload cert after renewal", "error", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m.renewed.Store(true)
|
||||||
|
nlog.Core().Info("TLS certificate reloaded after renewal", "domain", m.cfg.Domain)
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -380,19 +462,27 @@ func (m *Manager) startACME(ctx context.Context, dnsSolver *certmagic.DNS01Solve
|
|||||||
}
|
}
|
||||||
m.magic = magic
|
m.magic = magic
|
||||||
|
|
||||||
if err := magic.ObtainCertSync(ctx, m.cfg.Domain); err != nil {
|
// Derived ctx so Reconfigure can cancel ACME background goroutines.
|
||||||
|
acmeCtx, acmeCancel := context.WithCancel(ctx)
|
||||||
|
|
||||||
|
if err := magic.ObtainCertSync(acmeCtx, m.cfg.Domain); err != nil {
|
||||||
|
acmeCancel()
|
||||||
return fmt.Errorf("obtain certificate: %w", err)
|
return fmt.Errorf("obtain certificate: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The cert_obtained event callback should have loaded PEM. Verify.
|
issuerKey := magic.Issuers[0].IssuerKey()
|
||||||
if !m.HasCert() {
|
if err := m.loadPEMFromStorage(acmeCtx, storage, issuerKey, m.cfg.Domain); err != nil {
|
||||||
return fmt.Errorf("ACME obtained certificate but PEM was not loaded; check storage")
|
acmeCancel()
|
||||||
|
return fmt.Errorf("load cert from storage: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := magic.ManageAsync(ctx, []string{m.cfg.Domain}); err != nil {
|
if err := magic.ManageAsync(acmeCtx, []string{m.cfg.Domain}); err != nil {
|
||||||
|
acmeCancel()
|
||||||
return fmt.Errorf("start cert manager: %w", err)
|
return fmt.Errorf("start cert manager: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
m.acmeCancel = acmeCancel
|
||||||
|
m.acmeFingerprint = acmeFingerprint(m.cfg)
|
||||||
m.acmeStarted = true
|
m.acmeStarted = true
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -416,75 +506,44 @@ func (m *Manager) buildDNSSolver() (*certmagic.DNS01Solver, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) newDNSProvider() (certmagic.DNSProvider, error) {
|
func (m *Manager) newDNSProvider() (certmagic.DNSProvider, error) {
|
||||||
name := strings.ToLower(strings.TrimSpace(m.cfg.DNSProvider))
|
name := strings.TrimSpace(m.cfg.DNSProvider)
|
||||||
|
if name == "" {
|
||||||
|
return nil, fmt.Errorf("dns_provider is required for cert_mode=dns")
|
||||||
|
}
|
||||||
env := m.cfg.DNSEnv
|
env := m.cfg.DNSEnv
|
||||||
if env == nil {
|
if env == nil {
|
||||||
env = map[string]string{}
|
env = map[string]string{}
|
||||||
}
|
}
|
||||||
|
p, ok := dnsproviders.Get(name)
|
||||||
switch name {
|
if !ok {
|
||||||
case "cloudflare", "cf":
|
return nil, fmt.Errorf("unsupported dns_provider: %q (supported: %s)",
|
||||||
token := firstOf(env, "CF_API_TOKEN", "CLOUDFLARE_API_TOKEN")
|
name, strings.Join(dnsproviders.CanonicalNames(), ", "))
|
||||||
if token == "" {
|
|
||||||
return nil, fmt.Errorf("cloudflare requires CF_API_TOKEN in dns_env")
|
|
||||||
}
|
|
||||||
return &cloudflare.Provider{APIToken: token}, nil
|
|
||||||
|
|
||||||
case "alidns", "aliyun":
|
|
||||||
keyID := firstOf(env, "ALICLOUD_ACCESS_KEY_ID", "ALI_ACCESS_KEY_ID")
|
|
||||||
keySecret := firstOf(env, "ALICLOUD_ACCESS_KEY_SECRET", "ALI_ACCESS_KEY_SECRET")
|
|
||||||
if keyID == "" || keySecret == "" {
|
|
||||||
return nil, fmt.Errorf("alidns requires ALICLOUD_ACCESS_KEY_ID and ALICLOUD_ACCESS_KEY_SECRET in dns_env")
|
|
||||||
}
|
|
||||||
return &alidns.Provider{
|
|
||||||
CredentialInfo: alidns.CredentialInfo{
|
|
||||||
AccessKeyID: keyID,
|
|
||||||
AccessKeySecret: keySecret,
|
|
||||||
},
|
|
||||||
}, nil
|
|
||||||
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("unsupported dns_provider: %q (supported: cloudflare, alidns)", name)
|
|
||||||
}
|
}
|
||||||
|
return p.Build(env)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Helpers ───────────────────────────────────────────────────────────────
|
// ─── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// loadCertFromStorage reads ACME cert/key from certmagic's storage into memory.
|
// loadPEMFromStorage reads cert + key for domain from certmagic storage into
|
||||||
func (m *Manager) loadCertFromStorage(ctx context.Context, storage certmagic.Storage, data map[string]any) {
|
// the in-memory cache. Single entry point for both initial load and renewal refresh.
|
||||||
certKey, _ := data["certificate_path"].(string)
|
func (m *Manager) loadPEMFromStorage(ctx context.Context, storage certmagic.Storage, issuerKey, domain string) error {
|
||||||
keyKey, _ := data["private_key_path"].(string)
|
if issuerKey == "" || domain == "" {
|
||||||
if certKey == "" || keyKey == "" {
|
return fmt.Errorf("loadPEMFromStorage: issuerKey and domain are required")
|
||||||
nlog.Core().Warn("cert_obtained event missing storage paths", "data", data)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
certKey := certmagic.StorageKeys.SiteCert(issuerKey, domain)
|
||||||
|
keyKey := certmagic.StorageKeys.SitePrivateKey(issuerKey, domain)
|
||||||
|
|
||||||
certPEM, err := storage.Load(ctx, certKey)
|
certPEM, err := storage.Load(ctx, certKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
nlog.Core().Error("failed to load cert from ACME storage", "error", err)
|
return fmt.Errorf("load cert %q: %w", certKey, err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
keyPEM, err := storage.Load(ctx, keyKey)
|
keyPEM, err := storage.Load(ctx, keyKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
nlog.Core().Error("failed to load key from ACME storage", "error", err)
|
return fmt.Errorf("load key %q: %w", keyKey, err)
|
||||||
return
|
}
|
||||||
|
if err := validateKeyPair(certPEM, keyPEM); err != nil {
|
||||||
|
return fmt.Errorf("invalid cert/key pair on disk: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
m.storePEM(certPEM, keyPEM)
|
m.storePEM(certPEM, keyPEM)
|
||||||
|
return nil
|
||||||
renewal, _ := data["renewal"].(bool)
|
|
||||||
nlog.Core().Info("TLS certificate loaded from ACME storage", "domain", m.cfg.Domain, "renewal", renewal)
|
|
||||||
if renewal {
|
|
||||||
m.renewed.Store(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// firstOf returns the first non-empty value for any of the given keys in the map.
|
|
||||||
func firstOf(m map[string]string, keys ...string) string {
|
|
||||||
for _, k := range keys {
|
|
||||||
if v := m[k]; v != "" {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
package cert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/pem"
|
||||||
|
"math/big"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/caddyserver/certmagic"
|
||||||
|
|
||||||
|
"github.com/cedar2025/xboard-node/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// generateSelfSignedPair returns a fresh self-signed certificate and matching
|
||||||
|
// private key in PEM form, suitable for storing as a libdns/certmagic site
|
||||||
|
// resource in tests.
|
||||||
|
func generateSelfSignedPair(t *testing.T, domain string) (certPEM, keyPEM []byte) {
|
||||||
|
t.Helper()
|
||||||
|
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("gen key: %v", err)
|
||||||
|
}
|
||||||
|
tmpl := &x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(1),
|
||||||
|
Subject: pkix.Name{CommonName: domain},
|
||||||
|
DNSNames: []string{domain},
|
||||||
|
NotBefore: time.Now().Add(-time.Hour),
|
||||||
|
NotAfter: time.Now().Add(24 * time.Hour),
|
||||||
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||||
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||||
|
}
|
||||||
|
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create cert: %v", err)
|
||||||
|
}
|
||||||
|
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||||
|
keyDER, err := x509.MarshalECPrivateKey(priv)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal key: %v", err)
|
||||||
|
}
|
||||||
|
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoadPEMFromStorage_RestartScenario simulates the warm-restart bug fix:
|
||||||
|
// certmagic.ObtainCertSync returns nil without firing cert_obtained when a
|
||||||
|
// valid cert already exists in storage. The manager must still populate its
|
||||||
|
// in-memory PEM cache by reading storage explicitly.
|
||||||
|
func TestLoadPEMFromStorage_RestartScenario(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
storage := &certmagic.FileStorage{Path: dir}
|
||||||
|
const domain = "example.test"
|
||||||
|
const issuerKey = "test-issuer"
|
||||||
|
|
||||||
|
certPEM, keyPEM := generateSelfSignedPair(t, domain)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := storage.Store(ctx, certmagic.StorageKeys.SiteCert(issuerKey, domain), certPEM); err != nil {
|
||||||
|
t.Fatalf("seed cert: %v", err)
|
||||||
|
}
|
||||||
|
if err := storage.Store(ctx, certmagic.StorageKeys.SitePrivateKey(issuerKey, domain), keyPEM); err != nil {
|
||||||
|
t.Fatalf("seed key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m := NewManager(config.CertConfig{})
|
||||||
|
if m.HasCert() {
|
||||||
|
t.Fatal("manager should start with no cert in memory")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.loadPEMFromStorage(ctx, storage, issuerKey, domain); err != nil {
|
||||||
|
t.Fatalf("loadPEMFromStorage: %v", err)
|
||||||
|
}
|
||||||
|
if !m.HasCert() {
|
||||||
|
t.Fatal("expected HasCert() == true after explicit load")
|
||||||
|
}
|
||||||
|
got := m.TLSCert()
|
||||||
|
if string(got.CertPEM) != string(certPEM) {
|
||||||
|
t.Errorf("certPEM mismatch")
|
||||||
|
}
|
||||||
|
if string(got.KeyPEM) != string(keyPEM) {
|
||||||
|
t.Errorf("keyPEM mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoadPEMFromStorage_RenewalRefresh confirms that calling load a second
|
||||||
|
// time with newer bytes on disk swaps the in-memory cache atomically — this
|
||||||
|
// is the renewal hot-reload path triggered from the cert_obtained event.
|
||||||
|
func TestLoadPEMFromStorage_RenewalRefresh(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
storage := &certmagic.FileStorage{Path: dir}
|
||||||
|
const domain = "example.test"
|
||||||
|
const issuerKey = "test-issuer"
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
certV1, keyV1 := generateSelfSignedPair(t, domain)
|
||||||
|
if err := storage.Store(ctx, certmagic.StorageKeys.SiteCert(issuerKey, domain), certV1); err != nil {
|
||||||
|
t.Fatalf("seed v1 cert: %v", err)
|
||||||
|
}
|
||||||
|
if err := storage.Store(ctx, certmagic.StorageKeys.SitePrivateKey(issuerKey, domain), keyV1); err != nil {
|
||||||
|
t.Fatalf("seed v1 key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m := NewManager(config.CertConfig{})
|
||||||
|
if err := m.loadPEMFromStorage(ctx, storage, issuerKey, domain); err != nil {
|
||||||
|
t.Fatalf("initial load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
certV2, keyV2 := generateSelfSignedPair(t, domain)
|
||||||
|
if err := storage.Store(ctx, certmagic.StorageKeys.SiteCert(issuerKey, domain), certV2); err != nil {
|
||||||
|
t.Fatalf("seed v2 cert: %v", err)
|
||||||
|
}
|
||||||
|
if err := storage.Store(ctx, certmagic.StorageKeys.SitePrivateKey(issuerKey, domain), keyV2); err != nil {
|
||||||
|
t.Fatalf("seed v2 key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.loadPEMFromStorage(ctx, storage, issuerKey, domain); err != nil {
|
||||||
|
t.Fatalf("refresh load: %v", err)
|
||||||
|
}
|
||||||
|
got := m.TLSCert()
|
||||||
|
if string(got.CertPEM) != string(certV2) {
|
||||||
|
t.Error("expected v2 cert after refresh")
|
||||||
|
}
|
||||||
|
if string(got.KeyPEM) != string(keyV2) {
|
||||||
|
t.Error("expected v2 key after refresh")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoadPEMFromStorage_MissingFiles ensures we surface a clear error when
|
||||||
|
// storage doesn't have the expected keys (defensive — should not happen in
|
||||||
|
// practice because ObtainCertSync guarantees presence on success).
|
||||||
|
func TestLoadPEMFromStorage_MissingFiles(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
storage := &certmagic.FileStorage{Path: dir}
|
||||||
|
m := NewManager(config.CertConfig{})
|
||||||
|
err := m.loadPEMFromStorage(context.Background(), storage, "test-issuer", "example.test")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when storage is empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoadPEMFromStorage_RejectsMismatchedPair guards against silently loading
|
||||||
|
// a cert and key that do not pair (e.g. partial write or external tampering).
|
||||||
|
func TestLoadPEMFromStorage_RejectsMismatchedPair(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
storage := &certmagic.FileStorage{Path: dir}
|
||||||
|
const domain = "example.test"
|
||||||
|
const issuerKey = "test-issuer"
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
certA, _ := generateSelfSignedPair(t, domain)
|
||||||
|
_, keyB := generateSelfSignedPair(t, domain)
|
||||||
|
if err := storage.Store(ctx, certmagic.StorageKeys.SiteCert(issuerKey, domain), certA); err != nil {
|
||||||
|
t.Fatalf("seed cert: %v", err)
|
||||||
|
}
|
||||||
|
if err := storage.Store(ctx, certmagic.StorageKeys.SitePrivateKey(issuerKey, domain), keyB); err != nil {
|
||||||
|
t.Fatalf("seed key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m := NewManager(config.CertConfig{})
|
||||||
|
err := m.loadPEMFromStorage(ctx, storage, issuerKey, domain)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for mismatched cert/key pair")
|
||||||
|
}
|
||||||
|
if m.HasCert() {
|
||||||
|
t.Error("manager should not store invalid pair")
|
||||||
|
}
|
||||||
|
_ = filepath.Join(dir, "stub")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestACMEFingerprint covers the config-equality logic that decides whether
|
||||||
|
// Reconfigure must tear down a running certmagic instance.
|
||||||
|
func TestACMEFingerprint(t *testing.T) {
|
||||||
|
base := config.CertConfig{
|
||||||
|
CertMode: "dns",
|
||||||
|
Domain: "a.example.com",
|
||||||
|
Email: "ops@example.com",
|
||||||
|
DNSProvider: "cloudflare",
|
||||||
|
DNSEnv: map[string]string{"CLOUDFLARE_API_TOKEN": "tok"},
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
mutate func(*config.CertConfig)
|
||||||
|
differs bool
|
||||||
|
}{
|
||||||
|
{"same", func(*config.CertConfig) {}, false},
|
||||||
|
{"domain", func(c *config.CertConfig) { c.Domain = "b.example.com" }, true},
|
||||||
|
{"mode", func(c *config.CertConfig) { c.CertMode = "http" }, true},
|
||||||
|
{"email", func(c *config.CertConfig) { c.Email = "other@example.com" }, true},
|
||||||
|
{"provider", func(c *config.CertConfig) { c.DNSProvider = "alidns" }, true},
|
||||||
|
{"env_value", func(c *config.CertConfig) { c.DNSEnv = map[string]string{"CLOUDFLARE_API_TOKEN": "other"} }, true},
|
||||||
|
{"env_key_only", func(c *config.CertConfig) { c.DNSEnv = map[string]string{"OTHER": "tok"} }, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
baseFp := acmeFingerprint(base)
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
cfg := base
|
||||||
|
if tc.mutate != nil {
|
||||||
|
// deep-copy DNSEnv before mutation to avoid sharing
|
||||||
|
cfg.DNSEnv = map[string]string{}
|
||||||
|
for k, v := range base.DNSEnv {
|
||||||
|
cfg.DNSEnv[k] = v
|
||||||
|
}
|
||||||
|
tc.mutate(&cfg)
|
||||||
|
}
|
||||||
|
got := acmeFingerprint(cfg)
|
||||||
|
if (got != baseFp) != tc.differs {
|
||||||
|
t.Errorf("differs=%v but got==base=%v", tc.differs, got == baseFp)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestACMEFingerprintEnvOrderStable ensures map iteration order does not
|
||||||
|
// affect the fingerprint (must be sorted internally).
|
||||||
|
func TestACMEFingerprintEnvOrderStable(t *testing.T) {
|
||||||
|
a := config.CertConfig{
|
||||||
|
CertMode: "dns", Domain: "x", DNSProvider: "p",
|
||||||
|
DNSEnv: map[string]string{"A": "1", "B": "2", "C": "3"},
|
||||||
|
}
|
||||||
|
b := config.CertConfig{
|
||||||
|
CertMode: "dns", Domain: "x", DNSProvider: "p",
|
||||||
|
DNSEnv: map[string]string{"C": "3", "A": "1", "B": "2"},
|
||||||
|
}
|
||||||
|
if acmeFingerprint(a) != acmeFingerprint(b) {
|
||||||
|
t.Fatal("fingerprint not order-independent")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package dnsproviders
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/caddyserver/certmagic"
|
||||||
|
"github.com/libdns/alidns"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Register(&Provider{
|
||||||
|
Names: []string{"alidns", "aliyun"},
|
||||||
|
EnvVars: []string{"ALICLOUD_ACCESS_KEY_ID", "ALICLOUD_ACCESS_KEY_SECRET"},
|
||||||
|
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
|
||||||
|
keyID := firstOf(env, "ALICLOUD_ACCESS_KEY_ID", "ALI_ACCESS_KEY_ID", "ALIYUN_ACCESS_KEY_ID")
|
||||||
|
keySecret := firstOf(env, "ALICLOUD_ACCESS_KEY_SECRET", "ALI_ACCESS_KEY_SECRET", "ALIYUN_ACCESS_KEY_SECRET")
|
||||||
|
if keyID == "" || keySecret == "" {
|
||||||
|
return nil, missingEnvErr("alidns", "ALICLOUD_ACCESS_KEY_ID", "ALICLOUD_ACCESS_KEY_SECRET")
|
||||||
|
}
|
||||||
|
return &alidns.Provider{
|
||||||
|
CredentialInfo: alidns.CredentialInfo{
|
||||||
|
AccessKeyID: keyID,
|
||||||
|
AccessKeySecret: keySecret,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package dnsproviders
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/caddyserver/certmagic"
|
||||||
|
"github.com/libdns/azure"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Register(&Provider{
|
||||||
|
Names: []string{"azure"},
|
||||||
|
EnvVars: []string{
|
||||||
|
"AZURE_SUBSCRIPTION_ID", "AZURE_RESOURCE_GROUP_NAME",
|
||||||
|
"AZURE_TENANT_ID", "AZURE_CLIENT_ID", "AZURE_CLIENT_SECRET",
|
||||||
|
},
|
||||||
|
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
|
||||||
|
sub := firstOf(env, "AZURE_SUBSCRIPTION_ID")
|
||||||
|
rg := firstOf(env, "AZURE_RESOURCE_GROUP_NAME", "AZURE_RESOURCE_GROUP")
|
||||||
|
if sub == "" || rg == "" {
|
||||||
|
return nil, missingEnvErr("azure", "AZURE_SUBSCRIPTION_ID", "AZURE_RESOURCE_GROUP_NAME")
|
||||||
|
}
|
||||||
|
// TenantId/ClientId/ClientSecret are optional — when omitted, libdns
|
||||||
|
// falls back to the default Azure credential chain (managed identity,
|
||||||
|
// CLI login, etc.).
|
||||||
|
return &azure.Provider{
|
||||||
|
SubscriptionId: sub,
|
||||||
|
ResourceGroupName: rg,
|
||||||
|
TenantId: firstOf(env, "AZURE_TENANT_ID"),
|
||||||
|
ClientId: firstOf(env, "AZURE_CLIENT_ID"),
|
||||||
|
ClientSecret: firstOf(env, "AZURE_CLIENT_SECRET"),
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package dnsproviders
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/caddyserver/certmagic"
|
||||||
|
"github.com/libdns/bunny"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Register(&Provider{
|
||||||
|
Names: []string{"bunny"},
|
||||||
|
EnvVars: []string{"BUNNY_ACCESS_KEY"},
|
||||||
|
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
|
||||||
|
key := firstOf(env, "BUNNY_ACCESS_KEY", "BUNNY_API_KEY")
|
||||||
|
if key == "" {
|
||||||
|
return nil, missingEnvErr("bunny", "BUNNY_ACCESS_KEY")
|
||||||
|
}
|
||||||
|
return &bunny.Provider{AccessKey: key}, nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package dnsproviders
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/caddyserver/certmagic"
|
||||||
|
"github.com/libdns/cloudflare"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Register(&Provider{
|
||||||
|
Names: []string{"cloudflare", "cf"},
|
||||||
|
EnvVars: []string{"CLOUDFLARE_DNS_API_TOKEN", "CF_API_TOKEN", "CLOUDFLARE_API_TOKEN"},
|
||||||
|
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
|
||||||
|
token := firstOf(env, "CLOUDFLARE_DNS_API_TOKEN", "CF_API_TOKEN", "CLOUDFLARE_API_TOKEN")
|
||||||
|
if token == "" {
|
||||||
|
return nil, missingEnvErr("cloudflare", "CLOUDFLARE_DNS_API_TOKEN", "CF_API_TOKEN")
|
||||||
|
}
|
||||||
|
return &cloudflare.Provider{APIToken: token}, nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package dnsproviders
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/caddyserver/certmagic"
|
||||||
|
"github.com/libdns/desec"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Register(&Provider{
|
||||||
|
Names: []string{"desec"},
|
||||||
|
EnvVars: []string{"DESEC_TOKEN"},
|
||||||
|
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
|
||||||
|
token := firstOf(env, "DESEC_TOKEN", "DESEC_API_TOKEN")
|
||||||
|
if token == "" {
|
||||||
|
return nil, missingEnvErr("desec", "DESEC_TOKEN")
|
||||||
|
}
|
||||||
|
return &desec.Provider{Token: token}, nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package dnsproviders
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/caddyserver/certmagic"
|
||||||
|
"github.com/libdns/digitalocean"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Register(&Provider{
|
||||||
|
Names: []string{"digitalocean", "do"},
|
||||||
|
EnvVars: []string{"DO_AUTH_TOKEN", "DIGITALOCEAN_TOKEN"},
|
||||||
|
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
|
||||||
|
token := firstOf(env, "DO_AUTH_TOKEN", "DIGITALOCEAN_TOKEN", "DIGITALOCEAN_API_TOKEN")
|
||||||
|
if token == "" {
|
||||||
|
return nil, missingEnvErr("digitalocean", "DO_AUTH_TOKEN")
|
||||||
|
}
|
||||||
|
return &digitalocean.Provider{APIToken: token}, nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package dnsproviders
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/caddyserver/certmagic"
|
||||||
|
"github.com/libdns/duckdns"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Register(&Provider{
|
||||||
|
Names: []string{"duckdns"},
|
||||||
|
EnvVars: []string{"DUCKDNS_TOKEN"},
|
||||||
|
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
|
||||||
|
token := firstOf(env, "DUCKDNS_TOKEN", "DUCKDNS_API_TOKEN")
|
||||||
|
if token == "" {
|
||||||
|
return nil, missingEnvErr("duckdns", "DUCKDNS_TOKEN")
|
||||||
|
}
|
||||||
|
return &duckdns.Provider{
|
||||||
|
APIToken: token,
|
||||||
|
OverrideDomain: firstOf(env, "DUCKDNS_OVERRIDE_DOMAIN"),
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package dnsproviders
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/caddyserver/certmagic"
|
||||||
|
"github.com/libdns/gandi"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Register(&Provider{
|
||||||
|
Names: []string{"gandi"},
|
||||||
|
EnvVars: []string{"GANDI_BEARER_TOKEN", "GANDI_API_TOKEN"},
|
||||||
|
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
|
||||||
|
token := firstOf(env, "GANDI_BEARER_TOKEN", "GANDI_API_TOKEN", "GANDI_PERSONAL_ACCESS_TOKEN")
|
||||||
|
if token == "" {
|
||||||
|
return nil, missingEnvErr("gandi", "GANDI_BEARER_TOKEN")
|
||||||
|
}
|
||||||
|
return &gandi.Provider{BearerToken: token}, nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package dnsproviders
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/caddyserver/certmagic"
|
||||||
|
"github.com/libdns/godaddy"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Register(&Provider{
|
||||||
|
Names: []string{"godaddy"},
|
||||||
|
EnvVars: []string{"GODADDY_API_TOKEN"},
|
||||||
|
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
|
||||||
|
// GoDaddy auth header format: "sso-key KEY:SECRET"
|
||||||
|
// Pass the full "KEY:SECRET" pair via GODADDY_API_TOKEN, OR set
|
||||||
|
// GODADDY_API_KEY + GODADDY_API_SECRET and we'll join them.
|
||||||
|
token := firstOf(env, "GODADDY_API_TOKEN")
|
||||||
|
if token == "" {
|
||||||
|
key := firstOf(env, "GODADDY_API_KEY")
|
||||||
|
secret := firstOf(env, "GODADDY_API_SECRET")
|
||||||
|
if key != "" && secret != "" {
|
||||||
|
token = key + ":" + secret
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if token == "" {
|
||||||
|
return nil, missingEnvErr("godaddy", "GODADDY_API_TOKEN", "GODADDY_API_KEY+GODADDY_API_SECRET")
|
||||||
|
}
|
||||||
|
return &godaddy.Provider{APIToken: token}, nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package dnsproviders
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/caddyserver/certmagic"
|
||||||
|
"github.com/libdns/googleclouddns"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Register(&Provider{
|
||||||
|
Names: []string{"googleclouddns", "gcp", "gcloud"},
|
||||||
|
EnvVars: []string{"GCE_PROJECT", "GCE_SERVICE_ACCOUNT_FILE"},
|
||||||
|
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
|
||||||
|
project := firstOf(env, "GCE_PROJECT", "GOOGLE_PROJECT_ID", "GCP_PROJECT")
|
||||||
|
if project == "" {
|
||||||
|
return nil, missingEnvErr("googleclouddns", "GCE_PROJECT")
|
||||||
|
}
|
||||||
|
// ServiceAccountJSON may be a file path OR a raw JSON blob; when
|
||||||
|
// empty, the default Google credential chain is used.
|
||||||
|
return &googleclouddns.Provider{
|
||||||
|
Project: project,
|
||||||
|
ServiceAccountJSON: firstOf(env, "GCE_SERVICE_ACCOUNT_FILE", "GOOGLE_APPLICATION_CREDENTIALS"),
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package dnsproviders
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// firstOf returns the value of the first non-empty env key from candidates.
|
||||||
|
// Lookup is case-sensitive (env keys are conventionally upper-case).
|
||||||
|
func firstOf(env map[string]string, keys ...string) string {
|
||||||
|
for _, k := range keys {
|
||||||
|
if v := strings.TrimSpace(env[k]); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// missingEnvErr formats a "missing required env" error consistently.
|
||||||
|
func missingEnvErr(provider string, keys ...string) error {
|
||||||
|
return fmt.Errorf("dns provider %q requires one of %s in dns_env", provider, strings.Join(keys, ", "))
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package dnsproviders
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/caddyserver/certmagic"
|
||||||
|
"github.com/libdns/hetzner"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Register(&Provider{
|
||||||
|
Names: []string{"hetzner"},
|
||||||
|
EnvVars: []string{"HETZNER_API_TOKEN"},
|
||||||
|
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
|
||||||
|
token := firstOf(env, "HETZNER_API_TOKEN", "HETZNER_DNS_API_TOKEN")
|
||||||
|
if token == "" {
|
||||||
|
return nil, missingEnvErr("hetzner", "HETZNER_API_TOKEN")
|
||||||
|
}
|
||||||
|
return &hetzner.Provider{AuthAPIToken: token}, nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package dnsproviders
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/caddyserver/certmagic"
|
||||||
|
"github.com/libdns/huaweicloud"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Register(&Provider{
|
||||||
|
Names: []string{"huaweicloud", "huawei"},
|
||||||
|
EnvVars: []string{"HUAWEICLOUD_ACCESS_KEY_ID", "HUAWEICLOUD_SECRET_ACCESS_KEY", "HUAWEICLOUD_REGION_ID"},
|
||||||
|
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
|
||||||
|
id := firstOf(env, "HUAWEICLOUD_ACCESS_KEY_ID", "HUAWEI_ACCESS_KEY_ID")
|
||||||
|
secret := firstOf(env, "HUAWEICLOUD_SECRET_ACCESS_KEY", "HUAWEI_SECRET_ACCESS_KEY")
|
||||||
|
if id == "" || secret == "" {
|
||||||
|
return nil, missingEnvErr("huaweicloud", "HUAWEICLOUD_ACCESS_KEY_ID", "HUAWEICLOUD_SECRET_ACCESS_KEY")
|
||||||
|
}
|
||||||
|
return &huaweicloud.Provider{
|
||||||
|
AccessKeyId: id,
|
||||||
|
SecretAccessKey: secret,
|
||||||
|
RegionId: firstOf(env, "HUAWEICLOUD_REGION_ID", "HUAWEI_REGION"),
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package dnsproviders
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/caddyserver/certmagic"
|
||||||
|
"github.com/libdns/linode"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Register(&Provider{
|
||||||
|
Names: []string{"linode"},
|
||||||
|
EnvVars: []string{"LINODE_TOKEN"},
|
||||||
|
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
|
||||||
|
token := firstOf(env, "LINODE_TOKEN", "LINODE_API_TOKEN")
|
||||||
|
if token == "" {
|
||||||
|
return nil, missingEnvErr("linode", "LINODE_TOKEN")
|
||||||
|
}
|
||||||
|
return &linode.Provider{APIToken: token}, nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package dnsproviders
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/caddyserver/certmagic"
|
||||||
|
"github.com/libdns/namecheap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Register(&Provider{
|
||||||
|
Names: []string{"namecheap"},
|
||||||
|
EnvVars: []string{"NAMECHEAP_API_KEY", "NAMECHEAP_API_USER", "NAMECHEAP_CLIENT_IP"},
|
||||||
|
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
|
||||||
|
apiKey := firstOf(env, "NAMECHEAP_API_KEY")
|
||||||
|
user := firstOf(env, "NAMECHEAP_API_USER", "NAMECHEAP_USER")
|
||||||
|
if apiKey == "" || user == "" {
|
||||||
|
return nil, missingEnvErr("namecheap", "NAMECHEAP_API_KEY", "NAMECHEAP_API_USER")
|
||||||
|
}
|
||||||
|
return &namecheap.Provider{
|
||||||
|
APIKey: apiKey,
|
||||||
|
User: user,
|
||||||
|
APIEndpoint: firstOf(env, "NAMECHEAP_API_ENDPOINT"),
|
||||||
|
ClientIP: firstOf(env, "NAMECHEAP_CLIENT_IP"),
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package dnsproviders
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/caddyserver/certmagic"
|
||||||
|
"github.com/libdns/namesilo"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Register(&Provider{
|
||||||
|
Names: []string{"namesilo"},
|
||||||
|
EnvVars: []string{"NAMESILO_API_TOKEN"},
|
||||||
|
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
|
||||||
|
token := firstOf(env, "NAMESILO_API_TOKEN")
|
||||||
|
if token == "" {
|
||||||
|
return nil, missingEnvErr("namesilo", "NAMESILO_API_TOKEN")
|
||||||
|
}
|
||||||
|
return &namesilo.Provider{APIToken: token}, nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package dnsproviders
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/caddyserver/certmagic"
|
||||||
|
"github.com/libdns/netlify"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Register(&Provider{
|
||||||
|
Names: []string{"netlify"},
|
||||||
|
EnvVars: []string{"NETLIFY_PERSONAL_ACCESS_TOKEN"},
|
||||||
|
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
|
||||||
|
token := firstOf(env, "NETLIFY_PERSONAL_ACCESS_TOKEN", "NETLIFY_AUTH_TOKEN", "NETLIFY_API_TOKEN")
|
||||||
|
if token == "" {
|
||||||
|
return nil, missingEnvErr("netlify", "NETLIFY_PERSONAL_ACCESS_TOKEN")
|
||||||
|
}
|
||||||
|
return &netlify.Provider{PersonalAccessToken: token}, nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package dnsproviders
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/caddyserver/certmagic"
|
||||||
|
"github.com/libdns/ovh"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Register(&Provider{
|
||||||
|
Names: []string{"ovh"},
|
||||||
|
EnvVars: []string{
|
||||||
|
"OVH_ENDPOINT", "OVH_APPLICATION_KEY", "OVH_APPLICATION_SECRET", "OVH_CONSUMER_KEY",
|
||||||
|
},
|
||||||
|
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
|
||||||
|
appKey := firstOf(env, "OVH_APPLICATION_KEY")
|
||||||
|
appSecret := firstOf(env, "OVH_APPLICATION_SECRET")
|
||||||
|
consumerKey := firstOf(env, "OVH_CONSUMER_KEY")
|
||||||
|
if appKey == "" || appSecret == "" || consumerKey == "" {
|
||||||
|
return nil, missingEnvErr("ovh", "OVH_APPLICATION_KEY", "OVH_APPLICATION_SECRET", "OVH_CONSUMER_KEY")
|
||||||
|
}
|
||||||
|
return &ovh.Provider{
|
||||||
|
Endpoint: firstOf(env, "OVH_ENDPOINT"),
|
||||||
|
ApplicationKey: appKey,
|
||||||
|
ApplicationSecret: appSecret,
|
||||||
|
ConsumerKey: consumerKey,
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package dnsproviders
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/caddyserver/certmagic"
|
||||||
|
"github.com/libdns/porkbun"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Register(&Provider{
|
||||||
|
Names: []string{"porkbun"},
|
||||||
|
EnvVars: []string{"PORKBUN_API_KEY", "PORKBUN_API_SECRET_KEY"},
|
||||||
|
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
|
||||||
|
key := firstOf(env, "PORKBUN_API_KEY")
|
||||||
|
secret := firstOf(env, "PORKBUN_API_SECRET_KEY", "PORKBUN_SECRET_API_KEY")
|
||||||
|
if key == "" || secret == "" {
|
||||||
|
return nil, missingEnvErr("porkbun", "PORKBUN_API_KEY", "PORKBUN_API_SECRET_KEY")
|
||||||
|
}
|
||||||
|
return &porkbun.Provider{APIKey: key, APISecretKey: secret}, nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// Package dnsproviders is a registry of DNS providers usable for ACME DNS-01
|
||||||
|
// challenges. Each provider is implemented as a separate file that registers
|
||||||
|
// itself in init(); to add a new provider, drop a file in this package — no
|
||||||
|
// changes are needed elsewhere.
|
||||||
|
//
|
||||||
|
// All providers wrap the libdns ecosystem (https://github.com/libdns) so they
|
||||||
|
// plug directly into certmagic's DNSManager.
|
||||||
|
package dnsproviders
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/caddyserver/certmagic"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Builder constructs a certmagic.DNSProvider from a flat env-style map.
|
||||||
|
// The map mirrors the cert_config.dns_env block from config.yml.
|
||||||
|
type Builder func(env map[string]string) (certmagic.DNSProvider, error)
|
||||||
|
|
||||||
|
// Provider describes a single DNS provider entry in the registry.
|
||||||
|
type Provider struct {
|
||||||
|
// Names is the list of accepted dns_provider values; the first is canonical,
|
||||||
|
// the rest are aliases (case-insensitive match).
|
||||||
|
Names []string
|
||||||
|
// EnvVars lists the environment variable names this provider may consume.
|
||||||
|
// Used for documentation and error messages only.
|
||||||
|
EnvVars []string
|
||||||
|
// Build constructs the underlying libdns provider.
|
||||||
|
Build Builder
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
mu sync.RWMutex
|
||||||
|
registry = map[string]*Provider{}
|
||||||
|
ordered []*Provider
|
||||||
|
)
|
||||||
|
|
||||||
|
// Register adds a provider to the global registry. It panics on duplicate
|
||||||
|
// names so that registration errors surface at process start.
|
||||||
|
func Register(p *Provider) {
|
||||||
|
if p == nil || len(p.Names) == 0 || p.Build == nil {
|
||||||
|
panic("dnsproviders: invalid provider registration")
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
for _, n := range p.Names {
|
||||||
|
key := strings.ToLower(strings.TrimSpace(n))
|
||||||
|
if key == "" {
|
||||||
|
panic("dnsproviders: empty provider name")
|
||||||
|
}
|
||||||
|
if _, exists := registry[key]; exists {
|
||||||
|
panic(fmt.Sprintf("dnsproviders: duplicate name %q", key))
|
||||||
|
}
|
||||||
|
registry[key] = p
|
||||||
|
}
|
||||||
|
ordered = append(ordered, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the provider registered under the given (case-insensitive) name
|
||||||
|
// or alias.
|
||||||
|
func Get(name string) (*Provider, bool) {
|
||||||
|
mu.RLock()
|
||||||
|
defer mu.RUnlock()
|
||||||
|
p, ok := registry[strings.ToLower(strings.TrimSpace(name))]
|
||||||
|
return p, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanonicalNames returns the canonical (first-listed) name of every registered
|
||||||
|
// provider, sorted alphabetically. Useful for error messages.
|
||||||
|
func CanonicalNames() []string {
|
||||||
|
mu.RLock()
|
||||||
|
defer mu.RUnlock()
|
||||||
|
names := make([]string, 0, len(ordered))
|
||||||
|
for _, p := range ordered {
|
||||||
|
names = append(names, p.Names[0])
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns all registered providers (canonical entries only), sorted by
|
||||||
|
// canonical name.
|
||||||
|
func List() []*Provider {
|
||||||
|
mu.RLock()
|
||||||
|
defer mu.RUnlock()
|
||||||
|
out := make([]*Provider, len(ordered))
|
||||||
|
copy(out, ordered)
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Names[0] < out[j].Names[0] })
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package dnsproviders
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestRegistryHasMainstreamProviders verifies that all expected mainstream
|
||||||
|
// providers are registered with their canonical names (and key aliases).
|
||||||
|
func TestRegistryHasMainstreamProviders(t *testing.T) {
|
||||||
|
cases := map[string][]string{
|
||||||
|
"cloudflare": {"cf"},
|
||||||
|
"alidns": {"aliyun"},
|
||||||
|
"tencentcloud": {"tencent"},
|
||||||
|
"route53": {"aws"},
|
||||||
|
"godaddy": nil,
|
||||||
|
"namecheap": nil,
|
||||||
|
"namesilo": nil,
|
||||||
|
"digitalocean": {"do"},
|
||||||
|
"linode": nil,
|
||||||
|
"hetzner": nil,
|
||||||
|
"gandi": nil,
|
||||||
|
"porkbun": nil,
|
||||||
|
"netlify": nil,
|
||||||
|
"azure": nil,
|
||||||
|
"googleclouddns": {"gcp", "gcloud"},
|
||||||
|
"huaweicloud": {"huawei"},
|
||||||
|
"bunny": nil,
|
||||||
|
"duckdns": nil,
|
||||||
|
"ovh": nil,
|
||||||
|
"vultr": nil,
|
||||||
|
"desec": nil,
|
||||||
|
}
|
||||||
|
for canonical, aliases := range cases {
|
||||||
|
if _, ok := Get(canonical); !ok {
|
||||||
|
t.Errorf("provider %q not registered", canonical)
|
||||||
|
}
|
||||||
|
// case-insensitive lookup must work
|
||||||
|
if _, ok := Get(strings.ToUpper(canonical)); !ok {
|
||||||
|
t.Errorf("provider %q not found case-insensitively", canonical)
|
||||||
|
}
|
||||||
|
for _, a := range aliases {
|
||||||
|
if _, ok := Get(a); !ok {
|
||||||
|
t.Errorf("alias %q for %q not registered", a, canonical)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistryRejectsUnknown(t *testing.T) {
|
||||||
|
if _, ok := Get("definitely-not-a-real-provider"); ok {
|
||||||
|
t.Fatal("Get returned ok for unknown provider")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProvidersValidateMissingEnv ensures every provider's Build returns a
|
||||||
|
// helpful error when no credentials are supplied (rather than panicking or
|
||||||
|
// silently building an unusable client).
|
||||||
|
//
|
||||||
|
// route53/azure/googleclouddns are excluded because they intentionally fall
|
||||||
|
// back to an ambient credential chain (IAM role, managed identity, ADC) and
|
||||||
|
// produce a usable provider even with empty config.
|
||||||
|
func TestProvidersValidateMissingEnv(t *testing.T) {
|
||||||
|
excluded := map[string]bool{
|
||||||
|
"route53": true,
|
||||||
|
"azure": true, // partially excluded — actually still requires sub+rg
|
||||||
|
"googleclouddns": true, // requires GCE_PROJECT
|
||||||
|
}
|
||||||
|
for _, p := range List() {
|
||||||
|
canonical := p.Names[0]
|
||||||
|
if excluded[canonical] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t.Run(canonical, func(t *testing.T) {
|
||||||
|
_, err := p.Build(map[string]string{})
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("provider %q built successfully with empty env; expected missing-credential error", canonical)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCanonicalNamesSorted(t *testing.T) {
|
||||||
|
names := CanonicalNames()
|
||||||
|
for i := 1; i < len(names); i++ {
|
||||||
|
if names[i-1] > names[i] {
|
||||||
|
t.Fatalf("CanonicalNames not sorted: %v", names)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package dnsproviders
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/caddyserver/certmagic"
|
||||||
|
"github.com/libdns/route53"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Register(&Provider{
|
||||||
|
Names: []string{"route53", "aws"},
|
||||||
|
EnvVars: []string{"AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_REGION", "AWS_SESSION_TOKEN", "AWS_HOSTED_ZONE_ID", "AWS_PROFILE"},
|
||||||
|
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
|
||||||
|
// route53 supports the full AWS credential chain (env, IAM role, profile,
|
||||||
|
// shared credentials file). We deliberately do not require any field —
|
||||||
|
// users running on EC2/EKS may rely entirely on the instance role.
|
||||||
|
return &route53.Provider{
|
||||||
|
AccessKeyId: firstOf(env, "AWS_ACCESS_KEY_ID"),
|
||||||
|
SecretAccessKey: firstOf(env, "AWS_SECRET_ACCESS_KEY"),
|
||||||
|
SessionToken: firstOf(env, "AWS_SESSION_TOKEN"),
|
||||||
|
Region: firstOf(env, "AWS_REGION", "AWS_DEFAULT_REGION"),
|
||||||
|
Profile: firstOf(env, "AWS_PROFILE"),
|
||||||
|
HostedZoneID: firstOf(env, "AWS_HOSTED_ZONE_ID"),
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package dnsproviders
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/caddyserver/certmagic"
|
||||||
|
"github.com/libdns/tencentcloud"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Register(&Provider{
|
||||||
|
Names: []string{"tencentcloud", "tencent"},
|
||||||
|
EnvVars: []string{"TENCENTCLOUD_SECRET_ID", "TENCENTCLOUD_SECRET_KEY", "TENCENTCLOUD_REGION"},
|
||||||
|
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
|
||||||
|
id := firstOf(env, "TENCENTCLOUD_SECRET_ID", "TENCENT_SECRET_ID")
|
||||||
|
key := firstOf(env, "TENCENTCLOUD_SECRET_KEY", "TENCENT_SECRET_KEY")
|
||||||
|
if id == "" || key == "" {
|
||||||
|
return nil, missingEnvErr("tencentcloud", "TENCENTCLOUD_SECRET_ID", "TENCENTCLOUD_SECRET_KEY")
|
||||||
|
}
|
||||||
|
return &tencentcloud.Provider{
|
||||||
|
SecretId: id,
|
||||||
|
SecretKey: key,
|
||||||
|
Region: firstOf(env, "TENCENTCLOUD_REGION"),
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package dnsproviders
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/caddyserver/certmagic"
|
||||||
|
"github.com/libdns/vultr/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Register(&Provider{
|
||||||
|
Names: []string{"vultr"},
|
||||||
|
EnvVars: []string{"VULTR_API_TOKEN"},
|
||||||
|
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
|
||||||
|
token := firstOf(env, "VULTR_API_TOKEN", "VULTR_API_KEY")
|
||||||
|
if token == "" {
|
||||||
|
return nil, missingEnvErr("vultr", "VULTR_API_TOKEN")
|
||||||
|
}
|
||||||
|
return &vultr.Provider{APIToken: token}, nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -155,8 +155,15 @@ func buildRoutes(panelRoutes []model.RouteRule, customRules []model.CustomRouteR
|
|||||||
rules = append(rules, compileCustomRouteRule(rule)...)
|
rules = append(rules, compileCustomRouteRule(rule)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Raw custom routes remain the escape hatch, but no longer outrank structured rules.
|
// Raw custom routes: detect structured format (match+action) and compile them,
|
||||||
|
// otherwise pass through as raw sing-box rules.
|
||||||
for _, cr := range custom {
|
for _, cr := range custom {
|
||||||
|
if _, hasMatch := cr["match"]; hasMatch {
|
||||||
|
if parsed, ok := tryParseAsCustomRouteRule(cr); ok {
|
||||||
|
rules = append(rules, compileCustomRouteRule(parsed)...)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
rules = append(rules, M(cr))
|
rules = append(rules, M(cr))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,6 +316,81 @@ func compileCustomRouteRule(rule model.CustomRouteRule) []M {
|
|||||||
return compiled
|
return compiled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// tryParseAsCustomRouteRule attempts to convert a raw map (from custom_routes)
|
||||||
|
// into a structured CustomRouteRule. Returns false if the map doesn't match
|
||||||
|
// the structured format.
|
||||||
|
func tryParseAsCustomRouteRule(cr map[string]any) (model.CustomRouteRule, bool) {
|
||||||
|
var rule model.CustomRouteRule
|
||||||
|
|
||||||
|
// Parse name
|
||||||
|
if name, ok := cr["name"].(string); ok {
|
||||||
|
rule.Name = name
|
||||||
|
}
|
||||||
|
if disabled, ok := cr["disabled"].(bool); ok {
|
||||||
|
rule.Disabled = disabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse match
|
||||||
|
matchRaw, ok := cr["match"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return rule, false
|
||||||
|
}
|
||||||
|
rule.Match = parseRouteMatch(matchRaw)
|
||||||
|
|
||||||
|
// Parse action
|
||||||
|
switch action := cr["action"].(type) {
|
||||||
|
case string:
|
||||||
|
rule.Action = model.RouteAction{Type: action}
|
||||||
|
case map[string]any:
|
||||||
|
if t, ok := action["type"].(string); ok {
|
||||||
|
rule.Action.Type = t
|
||||||
|
}
|
||||||
|
if target, ok := action["target"].(string); ok {
|
||||||
|
rule.Action.Target = target
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return rule, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return rule, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRouteMatch(m map[string]any) model.RouteMatch {
|
||||||
|
var match model.RouteMatch
|
||||||
|
if v, ok := m["domains"].([]any); ok {
|
||||||
|
match.Domains = toStringSlice(v)
|
||||||
|
}
|
||||||
|
if v, ok := m["domain_suffixes"].([]any); ok {
|
||||||
|
match.DomainSuffixes = toStringSlice(v)
|
||||||
|
}
|
||||||
|
if v, ok := m["ip_cidrs"].([]any); ok {
|
||||||
|
match.IPCIDRs = toStringSlice(v)
|
||||||
|
}
|
||||||
|
if v, ok := m["ports"].([]any); ok {
|
||||||
|
match.Ports = toStringSlice(v)
|
||||||
|
}
|
||||||
|
if v, ok := m["networks"].([]any); ok {
|
||||||
|
match.Networks = toStringSlice(v)
|
||||||
|
}
|
||||||
|
if v, ok := m["source_cidrs"].([]any); ok {
|
||||||
|
match.SourceCIDRs = toStringSlice(v)
|
||||||
|
}
|
||||||
|
if v, ok := m["source_ports"].([]any); ok {
|
||||||
|
match.SourcePorts = toStringSlice(v)
|
||||||
|
}
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
|
||||||
|
func toStringSlice(arr []any) []string {
|
||||||
|
result := make([]string, 0, len(arr))
|
||||||
|
for _, v := range arr {
|
||||||
|
if s, ok := v.(string); ok {
|
||||||
|
result = append(result, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
func singboxOutboundForAction(action model.RouteAction) string {
|
func singboxOutboundForAction(action model.RouteAction) string {
|
||||||
switch action.Type {
|
switch action.Type {
|
||||||
case "direct":
|
case "direct":
|
||||||
|
|||||||
@@ -125,7 +125,8 @@ type ConnTracker struct {
|
|||||||
usersMu sync.RWMutex
|
usersMu sync.RWMutex
|
||||||
users map[int]*userStats // userID → stats
|
users map[int]*userStats // userID → stats
|
||||||
uuidMap map[string]int // UUID → userID (for lookup in RoutedConnection)
|
uuidMap map[string]int // UUID → userID (for lookup in RoutedConnection)
|
||||||
connMap map[string]net.Conn // connID → conn (only for force-close support)
|
connMap map[string]io.Closer // connID → conn (TCP + UDP, for force-close)
|
||||||
|
uuidConns map[string]map[string]struct{} // UUID → set of connIDs (reverse index for CloseByUUID)
|
||||||
|
|
||||||
idCounter atomic.Int64
|
idCounter atomic.Int64
|
||||||
|
|
||||||
@@ -146,7 +147,8 @@ func NewConnTracker(_ int) *ConnTracker {
|
|||||||
return &ConnTracker{
|
return &ConnTracker{
|
||||||
users: make(map[int]*userStats),
|
users: make(map[int]*userStats),
|
||||||
uuidMap: make(map[string]int),
|
uuidMap: make(map[string]int),
|
||||||
connMap: make(map[string]net.Conn),
|
connMap: make(map[string]io.Closer),
|
||||||
|
uuidConns: make(map[string]map[string]struct{}),
|
||||||
globalDevices: make(map[int]map[string]bool),
|
globalDevices: make(map[int]map[string]bool),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -239,6 +241,10 @@ func (t *ConnTracker) RoutedConnection(
|
|||||||
// Store conn reference for force-close support
|
// Store conn reference for force-close support
|
||||||
t.usersMu.Lock()
|
t.usersMu.Lock()
|
||||||
t.connMap[connID] = conn
|
t.connMap[connID] = conn
|
||||||
|
if _, ok := t.uuidConns[uuid]; !ok {
|
||||||
|
t.uuidConns[uuid] = make(map[string]struct{})
|
||||||
|
}
|
||||||
|
t.uuidConns[uuid][connID] = struct{}{}
|
||||||
t.usersMu.Unlock()
|
t.usersMu.Unlock()
|
||||||
|
|
||||||
var lim *rate.Limiter
|
var lim *rate.Limiter
|
||||||
@@ -252,16 +258,14 @@ func (t *ConnTracker) RoutedConnection(
|
|||||||
us: us,
|
us: us,
|
||||||
userID: uid,
|
userID: uid,
|
||||||
connID: connID,
|
connID: connID,
|
||||||
|
uuid: uuid,
|
||||||
sourceIP: sourceIP,
|
sourceIP: sourceIP,
|
||||||
limiter: lim,
|
limiter: lim,
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RoutedPacketConnection wraps UDP with per-user counting (UDP not in connMap).
|
// RoutedPacketConnection wraps UDP with per-user counting and force-close support.
|
||||||
// Note: UDP connections are NOT stored in connMap because connMap is typed as
|
|
||||||
// map[string]net.Conn, but PacketConn is a different interface. Force-close
|
|
||||||
// for UDP connections is handled directly via trackedPacketConn.Close().
|
|
||||||
func (t *ConnTracker) RoutedPacketConnection(
|
func (t *ConnTracker) RoutedPacketConnection(
|
||||||
ctx context.Context, conn N.PacketConn,
|
ctx context.Context, conn N.PacketConn,
|
||||||
metadata adapter.InboundContext,
|
metadata adapter.InboundContext,
|
||||||
@@ -293,6 +297,15 @@ func (t *ConnTracker) RoutedPacketConnection(
|
|||||||
|
|
||||||
connID := t.nextID()
|
connID := t.nextID()
|
||||||
|
|
||||||
|
// Store for force-close support (same as TCP)
|
||||||
|
t.usersMu.Lock()
|
||||||
|
t.connMap[connID] = conn
|
||||||
|
if _, ok := t.uuidConns[uuid]; !ok {
|
||||||
|
t.uuidConns[uuid] = make(map[string]struct{})
|
||||||
|
}
|
||||||
|
t.uuidConns[uuid][connID] = struct{}{}
|
||||||
|
t.usersMu.Unlock()
|
||||||
|
|
||||||
var lim *rate.Limiter
|
var lim *rate.Limiter
|
||||||
if slf := t.speedLimitFunc.Load(); slf != nil {
|
if slf := t.speedLimitFunc.Load(); slf != nil {
|
||||||
lim = (*slf)(uuid)
|
lim = (*slf)(uuid)
|
||||||
@@ -304,6 +317,7 @@ func (t *ConnTracker) RoutedPacketConnection(
|
|||||||
us: us,
|
us: us,
|
||||||
userID: uid,
|
userID: uid,
|
||||||
connID: connID,
|
connID: connID,
|
||||||
|
uuid: uuid,
|
||||||
sourceIP: sourceIP,
|
sourceIP: sourceIP,
|
||||||
limiter: lim,
|
limiter: lim,
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
@@ -456,10 +470,24 @@ func (t *ConnTracker) CloseByID(id string) bool {
|
|||||||
|
|
||||||
// CloseByUUID force-closes ALL connections for a given user UUID.
|
// CloseByUUID force-closes ALL connections for a given user UUID.
|
||||||
func (t *ConnTracker) CloseByUUID(uuid string) int {
|
func (t *ConnTracker) CloseByUUID(uuid string) int {
|
||||||
// This is a no-op for now — sing-box doesn't expose per-user connection
|
t.usersMu.RLock()
|
||||||
// kill easily. The kernel's RemoveUsers removes the inbound user which
|
ids, ok := t.uuidConns[uuid]
|
||||||
// prevents new connections, and existing connections will fail on next I/O.
|
if !ok || len(ids) == 0 {
|
||||||
return 0
|
t.usersMu.RUnlock()
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
conns := make([]io.Closer, 0, len(ids))
|
||||||
|
for id := range ids {
|
||||||
|
if c, ok := t.connMap[id]; ok {
|
||||||
|
conns = append(conns, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.usersMu.RUnlock()
|
||||||
|
|
||||||
|
for _, c := range conns {
|
||||||
|
c.Close()
|
||||||
|
}
|
||||||
|
return len(conns)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ActiveCount returns the total number of active connections.
|
// ActiveCount returns the total number of active connections.
|
||||||
@@ -475,10 +503,16 @@ func (t *ConnTracker) ActiveCount() int {
|
|||||||
return total
|
return total
|
||||||
}
|
}
|
||||||
|
|
||||||
// removeConnRef removes the connection reference from connMap on close.
|
// removeConnRef removes the connection reference from connMap and uuidConns on close.
|
||||||
func (t *ConnTracker) removeConnRef(connID string) {
|
func (t *ConnTracker) removeConnRef(connID, uuid string) {
|
||||||
t.usersMu.Lock()
|
t.usersMu.Lock()
|
||||||
delete(t.connMap, connID)
|
delete(t.connMap, connID)
|
||||||
|
if ids, ok := t.uuidConns[uuid]; ok {
|
||||||
|
delete(ids, connID)
|
||||||
|
if len(ids) == 0 {
|
||||||
|
delete(t.uuidConns, uuid)
|
||||||
|
}
|
||||||
|
}
|
||||||
t.usersMu.Unlock()
|
t.usersMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -563,6 +597,7 @@ type trackedConn struct {
|
|||||||
us *userStats // per-user stats (upload/download atomics + IP tracking)
|
us *userStats // per-user stats (upload/download atomics + IP tracking)
|
||||||
userID int // user ID for device tracking
|
userID int // user ID for device tracking
|
||||||
connID string
|
connID string
|
||||||
|
uuid string
|
||||||
sourceIP string
|
sourceIP string
|
||||||
limiter *rate.Limiter
|
limiter *rate.Limiter
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
@@ -636,7 +671,7 @@ func (c *trackedConn) Close() error {
|
|||||||
if c.us != nil {
|
if c.us != nil {
|
||||||
c.us.removeConn(c.sourceIP)
|
c.us.removeConn(c.sourceIP)
|
||||||
}
|
}
|
||||||
c.tracker.removeConnRef(c.connID)
|
c.tracker.removeConnRef(c.connID, c.uuid)
|
||||||
}
|
}
|
||||||
return c.Conn.Close()
|
return c.Conn.Close()
|
||||||
}
|
}
|
||||||
@@ -692,6 +727,7 @@ type trackedPacketConn struct {
|
|||||||
us *userStats
|
us *userStats
|
||||||
userID int
|
userID int
|
||||||
connID string
|
connID string
|
||||||
|
uuid string
|
||||||
sourceIP string
|
sourceIP string
|
||||||
limiter *rate.Limiter
|
limiter *rate.Limiter
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
@@ -759,7 +795,7 @@ func (c *trackedPacketConn) Close() error {
|
|||||||
if c.us != nil {
|
if c.us != nil {
|
||||||
c.us.removeConn(c.sourceIP)
|
c.us.removeConn(c.sourceIP)
|
||||||
}
|
}
|
||||||
c.tracker.removeConnRef(c.connID)
|
c.tracker.removeConnRef(c.connID, c.uuid)
|
||||||
}
|
}
|
||||||
return c.PacketConn.Close()
|
return c.PacketConn.Close()
|
||||||
}
|
}
|
||||||
@@ -791,16 +827,17 @@ func (c *trackedPacketConn) UnwrapPacketReader() (N.PacketReader, []N.CountFunc)
|
|||||||
if c.us == nil {
|
if c.us == nil {
|
||||||
return c.PacketConn, nil
|
return c.PacketConn, nil
|
||||||
}
|
}
|
||||||
return c.PacketConn, []N.CountFunc{c.makeCountFunc(&c.us.download)}
|
return c.PacketConn, []N.CountFunc{c.makeCountFunc(&c.us.upload)}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *trackedPacketConn) UnwrapPacketWriter() (N.PacketWriter, []N.CountFunc) {
|
func (c *trackedPacketConn) UnwrapPacketWriter() (N.PacketWriter, []N.CountFunc) {
|
||||||
if c.us == nil {
|
if c.us == nil {
|
||||||
return c.PacketConn, nil
|
return c.PacketConn, nil
|
||||||
}
|
}
|
||||||
return c.PacketConn, []N.CountFunc{c.makeCountFunc(&c.us.upload)}
|
return c.PacketConn, []N.CountFunc{c.makeCountFunc(&c.us.download)}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *trackedPacketConn) Upstream() any { return c.PacketConn }
|
func (c *trackedPacketConn) Upstream() any { return c.PacketConn }
|
||||||
func (c *trackedPacketConn) ReaderReplaceable() bool { return true }
|
func (c *trackedPacketConn) ReaderReplaceable() bool { return true }
|
||||||
func (c *trackedPacketConn) WriterReplaceable() bool { return true }
|
func (c *trackedPacketConn) WriterReplaceable() bool { return true }
|
||||||
|
|
||||||
|
|||||||
@@ -529,6 +529,31 @@ func applyStreamSettings(base M, nc *model.NodeSpec, tc kernel.TLSCert) {
|
|||||||
}
|
}
|
||||||
ss["httpSettings"] = h2Settings
|
ss["httpSettings"] = h2Settings
|
||||||
|
|
||||||
|
case "xhttp", "splithttp":
|
||||||
|
ss["network"] = "xhttp"
|
||||||
|
xhttpSettings := M{}
|
||||||
|
if nc.NetworkSettings != nil {
|
||||||
|
if v, ok := nc.NetworkSettings["path"]; ok {
|
||||||
|
xhttpSettings["path"] = v
|
||||||
|
}
|
||||||
|
if v, ok := nc.NetworkSettings["host"]; ok {
|
||||||
|
xhttpSettings["host"] = v
|
||||||
|
}
|
||||||
|
if v, ok := nc.NetworkSettings["mode"]; ok {
|
||||||
|
xhttpSettings["mode"] = v
|
||||||
|
}
|
||||||
|
if v, ok := nc.NetworkSettings["extra"]; ok {
|
||||||
|
// PHP sends empty arrays [] instead of {} for empty objects;
|
||||||
|
// xray rejects [] for fields that expect objects (e.g. sockopt, tlsSettings).
|
||||||
|
// Recursively strip empty arrays from the extra map before passing to xray.
|
||||||
|
if m, ok := v.(map[string]interface{}); ok && len(m) > 0 {
|
||||||
|
sanitizeEmptyArrays(m)
|
||||||
|
xhttpSettings["extra"] = m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ss["xhttpSettings"] = xhttpSettings
|
||||||
|
|
||||||
case "tcp":
|
case "tcp":
|
||||||
// default, no extra settings
|
// default, no extra settings
|
||||||
}
|
}
|
||||||
@@ -784,6 +809,22 @@ func copyStrings(src []string) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sanitizeEmptyArrays recursively removes empty slices from a map.
|
||||||
|
// PHP encodes empty objects as [] instead of {}; xray-core rejects []
|
||||||
|
// for fields that expect struct types (e.g. sockopt, tlsSettings).
|
||||||
|
func sanitizeEmptyArrays(m map[string]interface{}) {
|
||||||
|
for k, v := range m {
|
||||||
|
switch val := v.(type) {
|
||||||
|
case []interface{}:
|
||||||
|
if len(val) == 0 {
|
||||||
|
delete(m, k)
|
||||||
|
}
|
||||||
|
case map[string]interface{}:
|
||||||
|
sanitizeEmptyArrays(val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// extractECHServerKeys extracts the ECH private key from tls_settings.ech
|
// extractECHServerKeys extracts the ECH private key from tls_settings.ech
|
||||||
// and converts it from PEM to base64 for xray-core's echServerKeys field.
|
// and converts it from PEM to base64 for xray-core's echServerKeys field.
|
||||||
func extractECHServerKeys(tlsSettings map[string]interface{}) string {
|
func extractECHServerKeys(tlsSettings map[string]interface{}) string {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
"github.com/cedar2025/xboard-node/internal/config"
|
"github.com/cedar2025/xboard-node/internal/config"
|
||||||
"github.com/cedar2025/xboard-node/internal/controlplane"
|
"github.com/cedar2025/xboard-node/internal/controlplane"
|
||||||
|
"github.com/cedar2025/xboard-node/internal/model"
|
||||||
"github.com/cedar2025/xboard-node/internal/monitor"
|
"github.com/cedar2025/xboard-node/internal/monitor"
|
||||||
"github.com/cedar2025/xboard-node/internal/nlog"
|
"github.com/cedar2025/xboard-node/internal/nlog"
|
||||||
"github.com/cedar2025/xboard-node/internal/panel"
|
"github.com/cedar2025/xboard-node/internal/panel"
|
||||||
@@ -134,6 +135,19 @@ func (o *Orchestrator) startNode(ctx context.Context, mn panel.MachineNode) {
|
|||||||
|
|
||||||
perNodeClient := o.client.ForNode(mn.ID)
|
perNodeClient := o.client.ForNode(mn.ID)
|
||||||
|
|
||||||
|
// Pre-fetch node config to detect transport-based kernel requirements.
|
||||||
|
// If the transport (e.g. xhttp) is incompatible with the configured kernel
|
||||||
|
// (e.g. singbox), auto-switch to the required kernel for this node.
|
||||||
|
if cfgSnapshot, err := perNodeClient.GetConfig(); err == nil && cfgSnapshot != nil {
|
||||||
|
if resolved := model.ResolveKernelForTransport(cfgSnapshot.Network, nodeCfg.Kernel.Type); resolved != nodeCfg.Kernel.Type {
|
||||||
|
nlog.Core().Info(fmt.Sprintf("machine: auto-switching kernel for node %d (%s→%s, transport=%s)",
|
||||||
|
mn.ID, nodeCfg.Kernel.Type, resolved, cfgSnapshot.Network))
|
||||||
|
nodeCfg.Kernel.Type = resolved
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Reset cached ETag so the subsequent GetConfig in Initial() gets a full response.
|
||||||
|
perNodeClient.ResetConfigETag()
|
||||||
|
|
||||||
var push controlplane.PushClient
|
var push controlplane.PushClient
|
||||||
if o.ws != nil {
|
if o.ws != nil {
|
||||||
push = &machineNodePush{
|
push = &machineNodePush{
|
||||||
@@ -247,6 +261,7 @@ func (o *Orchestrator) reportMachineStatus() {
|
|||||||
[2]uint64{s.MemTotal, s.MemUsed},
|
[2]uint64{s.MemTotal, s.MemUsed},
|
||||||
[2]uint64{s.SwapTotal, s.SwapUsed},
|
[2]uint64{s.SwapTotal, s.SwapUsed},
|
||||||
[2]uint64{s.DiskTotal, s.DiskUsed},
|
[2]uint64{s.DiskTotal, s.DiskUsed},
|
||||||
|
s.NetInSpeed, s.NetOutSpeed,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
nlog.Core().Warn("machine status report failed", "error", err)
|
nlog.Core().Warn("machine status report failed", "error", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,9 +36,38 @@ func ValidateNodeSpec(n *NodeSpec, kcfg config.KernelConfig) error {
|
|||||||
if err := ValidateCustomRouteRules(n.CustomRouteRules, kernelType, availableTags); err != nil {
|
if err := ValidateCustomRouteRules(n.CustomRouteRules, kernelType, availableTags); err != nil {
|
||||||
return fmt.Errorf("validate custom route rules: %w", err)
|
return fmt.Errorf("validate custom route rules: %w", err)
|
||||||
}
|
}
|
||||||
|
if err := validateTransportKernel(n.Network, kernelType); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// singboxUnsupportedTransports lists transport types that sing-box does not support.
|
||||||
|
var singboxUnsupportedTransports = map[string]bool{
|
||||||
|
"xhttp": true,
|
||||||
|
"splithttp": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateTransportKernel(network, kernelType string) error {
|
||||||
|
net := strings.ToLower(strings.TrimSpace(network))
|
||||||
|
if kernelType == "singbox" && singboxUnsupportedTransports[net] {
|
||||||
|
return fmt.Errorf("transport %q is not supported by sing-box kernel; use xray kernel instead", net)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveKernelForTransport returns the kernel type required by the given
|
||||||
|
// transport. If the configured kernel cannot handle the transport, it returns
|
||||||
|
// the kernel that can. Otherwise it returns configuredKernel unchanged.
|
||||||
|
// This is used in machine mode to auto-switch kernel per node.
|
||||||
|
func ResolveKernelForTransport(network, configuredKernel string) string {
|
||||||
|
net := strings.ToLower(strings.TrimSpace(network))
|
||||||
|
if configuredKernel == "singbox" && singboxUnsupportedTransports[net] {
|
||||||
|
return "xray"
|
||||||
|
}
|
||||||
|
return configuredKernel
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeKernelType(value string) (string, error) {
|
func normalizeKernelType(value string) (string, error) {
|
||||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||||
case "singbox", "sing-box":
|
case "singbox", "sing-box":
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package monitor
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/cedar2025/xboard-node/internal/nlog"
|
"github.com/cedar2025/xboard-node/internal/nlog"
|
||||||
@@ -9,6 +11,7 @@ import (
|
|||||||
"github.com/shirou/gopsutil/v4/disk"
|
"github.com/shirou/gopsutil/v4/disk"
|
||||||
"github.com/shirou/gopsutil/v4/load"
|
"github.com/shirou/gopsutil/v4/load"
|
||||||
"github.com/shirou/gopsutil/v4/mem"
|
"github.com/shirou/gopsutil/v4/mem"
|
||||||
|
"github.com/shirou/gopsutil/v4/net"
|
||||||
)
|
)
|
||||||
|
|
||||||
var startTime = time.Now()
|
var startTime = time.Now()
|
||||||
@@ -18,6 +21,9 @@ func init() {
|
|||||||
// always returns 0% because it has no prior sample. This throwaway call
|
// always returns 0% because it has no prior sample. This throwaway call
|
||||||
// seeds the baseline so subsequent Collect() calls return real values.
|
// seeds the baseline so subsequent Collect() calls return real values.
|
||||||
cpu.Percent(500*time.Millisecond, false)
|
cpu.Percent(500*time.Millisecond, false)
|
||||||
|
|
||||||
|
// Seed the network baseline so the first Collect() can compute rates.
|
||||||
|
collectNetSpeed()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status holds system resource metrics
|
// Status holds system resource metrics
|
||||||
@@ -36,11 +42,81 @@ type Status struct {
|
|||||||
DiskUsed uint64
|
DiskUsed uint64
|
||||||
Goroutines int
|
Goroutines int
|
||||||
|
|
||||||
|
// Network speed (bytes/sec), -1 means unavailable (first sample).
|
||||||
|
NetInSpeed float64
|
||||||
|
NetOutSpeed float64
|
||||||
|
|
||||||
// GC metrics (process-wide)
|
// GC metrics (process-wide)
|
||||||
NumGC uint32
|
NumGC uint32
|
||||||
LastPauseMS float64
|
LastPauseMS float64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// netBaseline tracks the previous network counters for rate calculation.
|
||||||
|
var (
|
||||||
|
netMu sync.Mutex
|
||||||
|
netPrevRecv uint64
|
||||||
|
netPrevSent uint64
|
||||||
|
netPrevTime time.Time
|
||||||
|
netHasBase bool
|
||||||
|
)
|
||||||
|
|
||||||
|
// skipInterface returns true for loopback and common virtual interfaces.
|
||||||
|
func skipInterface(name string) bool {
|
||||||
|
lower := strings.ToLower(name)
|
||||||
|
for _, prefix := range []string{"lo", "docker", "veth", "br-", "virbr", "vnet", "tun", "tap"} {
|
||||||
|
if strings.HasPrefix(lower, prefix) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectNetSpeed calculates network in/out bytes per second since last call.
|
||||||
|
// Returns -1, -1 on first call or if counters decreased (reboot).
|
||||||
|
func collectNetSpeed() (inSpeed, outSpeed float64) {
|
||||||
|
counters, err := net.IOCounters(true) // per-interface
|
||||||
|
if err != nil {
|
||||||
|
nlog.Core().Debug("failed to get network counters", "error", err)
|
||||||
|
return -1, -1
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalRecv, totalSent uint64
|
||||||
|
for _, c := range counters {
|
||||||
|
if skipInterface(c.Name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
totalRecv += c.BytesRecv
|
||||||
|
totalSent += c.BytesSent
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
netMu.Lock()
|
||||||
|
defer netMu.Unlock()
|
||||||
|
|
||||||
|
if !netHasBase {
|
||||||
|
netPrevRecv, netPrevSent, netPrevTime, netHasBase = totalRecv, totalSent, now, true
|
||||||
|
return -1, -1
|
||||||
|
}
|
||||||
|
|
||||||
|
elapsed := now.Sub(netPrevTime).Seconds()
|
||||||
|
if elapsed <= 0 {
|
||||||
|
return -1, -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counter decreased → system reboot or interface reset; reset baseline.
|
||||||
|
if totalRecv < netPrevRecv || totalSent < netPrevSent {
|
||||||
|
netPrevRecv, netPrevSent, netPrevTime = totalRecv, totalSent, now
|
||||||
|
return -1, -1
|
||||||
|
}
|
||||||
|
|
||||||
|
inSpeed = float64(totalRecv-netPrevRecv) / elapsed
|
||||||
|
outSpeed = float64(totalSent-netPrevSent) / elapsed
|
||||||
|
|
||||||
|
netPrevRecv, netPrevSent, netPrevTime = totalRecv, totalSent, now
|
||||||
|
return inSpeed, outSpeed
|
||||||
|
}
|
||||||
|
|
||||||
// Collect gathers current system metrics
|
// Collect gathers current system metrics
|
||||||
func Collect() Status {
|
func Collect() Status {
|
||||||
var s Status
|
var s Status
|
||||||
@@ -79,6 +155,8 @@ func Collect() Status {
|
|||||||
s.DiskUsed = diskStat.Used
|
s.DiskUsed = diskStat.Used
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.NetInSpeed, s.NetOutSpeed = collectNetSpeed()
|
||||||
|
|
||||||
// GC metrics
|
// GC metrics
|
||||||
var ms runtime.MemStats
|
var ms runtime.MemStats
|
||||||
runtime.ReadMemStats(&ms)
|
runtime.ReadMemStats(&ms)
|
||||||
|
|||||||
@@ -75,6 +75,13 @@ func (c *Client) ForNode(nodeID int) *Client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResetConfigETag clears the cached config ETag so the next GetConfig call
|
||||||
|
// returns a full response instead of 304. Used by machine mode after a
|
||||||
|
// pre-fetch to probe the transport type.
|
||||||
|
func (c *Client) ResetConfigETag() {
|
||||||
|
c.configETag = ""
|
||||||
|
}
|
||||||
|
|
||||||
// Handshake calls the new v2 API to get WS config + initial data in one shot.
|
// Handshake calls the new v2 API to get WS config + initial data in one shot.
|
||||||
func (c *Client) Handshake() (*HandshakeResponse, error) {
|
func (c *Client) Handshake() (*HandshakeResponse, error) {
|
||||||
resp, err := c.doRequest("POST", "/api/v2/server/handshake", nil, "")
|
resp, err := c.doRequest("POST", "/api/v2/server/handshake", nil, "")
|
||||||
@@ -193,6 +200,10 @@ func decodeWeakRaw(input map[string]interface{}, output interface{}) error {
|
|||||||
|
|
||||||
// configPath returns the API path for config fetching.
|
// configPath returns the API path for config fetching.
|
||||||
// Machine mode uses the v2 endpoint; legacy mode uses the v1 UniProxy endpoint.
|
// Machine mode uses the v2 endpoint; legacy mode uses the v1 UniProxy endpoint.
|
||||||
|
// fetchMethod returns POST for all API calls (token stays in body, not URL).
|
||||||
|
// v2 already used POST; v1 legacy endpoints need panel route update to match.
|
||||||
|
func (c *Client) fetchMethod() string { return "POST" }
|
||||||
|
|
||||||
func (c *Client) configPath() string {
|
func (c *Client) configPath() string {
|
||||||
if c.machineID > 0 {
|
if c.machineID > 0 {
|
||||||
return "/api/v2/server/config"
|
return "/api/v2/server/config"
|
||||||
@@ -210,7 +221,7 @@ func (c *Client) userPath() string {
|
|||||||
|
|
||||||
// GetConfig fetches node configuration. Returns nil if not modified (304).
|
// GetConfig fetches node configuration. Returns nil if not modified (304).
|
||||||
func (c *Client) GetConfig() (*NodeConfig, error) {
|
func (c *Client) GetConfig() (*NodeConfig, error) {
|
||||||
resp, err := c.doRequest("GET", c.configPath(), nil, c.configETag)
|
resp, err := c.doRequest(c.fetchMethod(), c.configPath(), nil, c.configETag)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get config: %w", err)
|
return nil, fmt.Errorf("get config: %w", err)
|
||||||
}
|
}
|
||||||
@@ -248,7 +259,7 @@ func (c *Client) GetConfig() (*NodeConfig, error) {
|
|||||||
|
|
||||||
// GetUsers fetches available users. Returns nil if not modified (304).
|
// GetUsers fetches available users. Returns nil if not modified (304).
|
||||||
func (c *Client) GetUsers() ([]User, error) {
|
func (c *Client) GetUsers() ([]User, error) {
|
||||||
resp, err := c.doRequest("GET", c.userPath(), nil, c.userETag)
|
resp, err := c.doRequest(c.fetchMethod(), c.userPath(), nil, c.userETag)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get users: %w", err)
|
return nil, fmt.Errorf("get users: %w", err)
|
||||||
}
|
}
|
||||||
@@ -335,13 +346,17 @@ func (c *Client) GetMachineNodes() (*MachineNodesResponse, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ReportMachineStatus sends machine-level load metrics to the panel.
|
// ReportMachineStatus sends machine-level load metrics to the panel.
|
||||||
func (c *Client) ReportMachineStatus(cpu float64, mem, swap, disk [2]uint64) error {
|
// netIn/netOut are bytes/sec; negative values mean "unavailable" (first sample).
|
||||||
|
func (c *Client) ReportMachineStatus(cpu float64, mem, swap, disk [2]uint64, netIn, netOut float64) error {
|
||||||
payload := map[string]interface{}{
|
payload := map[string]interface{}{
|
||||||
"cpu": cpu,
|
"cpu": cpu,
|
||||||
"mem": map[string]interface{}{"total": mem[0], "used": mem[1]},
|
"mem": map[string]interface{}{"total": mem[0], "used": mem[1]},
|
||||||
"swap": map[string]interface{}{"total": swap[0], "used": swap[1]},
|
"swap": map[string]interface{}{"total": swap[0], "used": swap[1]},
|
||||||
"disk": map[string]interface{}{"total": disk[0], "used": disk[1]},
|
"disk": map[string]interface{}{"total": disk[0], "used": disk[1]},
|
||||||
}
|
}
|
||||||
|
if netIn >= 0 && netOut >= 0 {
|
||||||
|
payload["net"] = map[string]interface{}{"in_speed": netIn, "out_speed": netOut}
|
||||||
|
}
|
||||||
return c.postJSON("/api/v2/server/machine/status", payload)
|
return c.postJSON("/api/v2/server/machine/status", payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/cedar2025/xboard-node/internal/cert"
|
"github.com/cedar2025/xboard-node/internal/cert"
|
||||||
|
"github.com/cedar2025/xboard-node/internal/cert/dnsproviders"
|
||||||
"github.com/cedar2025/xboard-node/internal/config"
|
"github.com/cedar2025/xboard-node/internal/config"
|
||||||
"github.com/cedar2025/xboard-node/internal/controlplane"
|
"github.com/cedar2025/xboard-node/internal/controlplane"
|
||||||
"github.com/cedar2025/xboard-node/internal/kernel"
|
"github.com/cedar2025/xboard-node/internal/kernel"
|
||||||
@@ -837,6 +838,18 @@ func (s *Service) applyUserDelta(ctx context.Context, action string, deltaUsers
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Force-close active connections for removed users before revoking
|
||||||
|
// credentials. Without this, existing long-lived connections
|
||||||
|
// (QUIC/TCP) persist until natural timeout — subscription resets
|
||||||
|
// and bans would not take immediate effect.
|
||||||
|
if s.kernel.Capabilities().ForceCloseUser {
|
||||||
|
for _, u := range deltaUsers {
|
||||||
|
if err := s.kernel.CloseUserConnections(ctx, u.UUID); err != nil {
|
||||||
|
nlog.Core().Debug(fmt.Sprintf("CloseUserConnections(%s): %v", u.UUID, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
prevUsers, prevHash := s.prepareUserState(filtered)
|
prevUsers, prevHash := s.prepareUserState(filtered)
|
||||||
removed, err := s.kernel.RemoveUsers(deltaUsers)
|
removed, err := s.kernel.RemoveUsers(deltaUsers)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1219,15 +1232,14 @@ func validateRuntimeCertConfig(spec *model.NodeSpec) error {
|
|||||||
if mode != "dns" {
|
if mode != "dns" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
provider := strings.ToLower(strings.TrimSpace(spec.CertConfig.DNSProvider))
|
provider := strings.TrimSpace(spec.CertConfig.DNSProvider)
|
||||||
switch provider {
|
if provider == "" {
|
||||||
case "cloudflare", "cf", "alidns", "aliyun":
|
|
||||||
return nil
|
|
||||||
case "":
|
|
||||||
return fmt.Errorf("dns cert mode requires cert_config.dns_provider")
|
return fmt.Errorf("dns cert mode requires cert_config.dns_provider")
|
||||||
default:
|
|
||||||
return fmt.Errorf("unsupported cert_config.dns_provider %q (supported: cloudflare, alidns)", spec.CertConfig.DNSProvider)
|
|
||||||
}
|
}
|
||||||
|
if _, ok := dnsproviders.Get(provider); !ok {
|
||||||
|
return fmt.Errorf("unsupported cert_config.dns_provider %q (supported: %s)", provider, strings.Join(dnsproviders.CanonicalNames(), ", "))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateRealityRequirements(spec *model.NodeSpec, _ string) error {
|
func validateRealityRequirements(spec *model.NodeSpec, _ string) error {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/cedar2025/xboard-node/internal/cert"
|
"github.com/cedar2025/xboard-node/internal/cert"
|
||||||
@@ -214,7 +215,7 @@ func TestValidateNodeRuntimeRejectsUnsupportedDNSProvider(t *testing.T) {
|
|||||||
if err.Error() == "" {
|
if err.Error() == "" {
|
||||||
t.Fatal("expected non-empty error")
|
t.Fatal("expected non-empty error")
|
||||||
}
|
}
|
||||||
if got := err.Error(); got != "unsupported cert_config.dns_provider \"3123123\" (supported: cloudflare, alidns)" {
|
if got := err.Error(); !strings.HasPrefix(got, `unsupported cert_config.dns_provider "3123123" (supported: `) {
|
||||||
t.Fatalf("unexpected error: %v", got)
|
t.Fatalf("unexpected error: %v", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user