194 lines
5.9 KiB
Go
194 lines
5.9 KiB
Go
package main
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"os"
|
||
"regexp"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
type agentJob struct {
|
||
ID string `json:"id"`
|
||
Action string `json:"action"`
|
||
Payload struct {
|
||
Resource json.RawMessage `json:"resource"`
|
||
ResourceID string `json:"resourceId"`
|
||
} `json:"payload"`
|
||
}
|
||
|
||
var domainRe = regexp.MustCompile(`^(?:[a-z0-9-]+\.)+[a-z]{2,63}$`)
|
||
|
||
func tlsCertMissingError(domain string) error {
|
||
return fmt.Errorf("TLS certificate for %s is missing; run ng-agent cert issue %s EMAIL or ng-agent cert import", domain, domain)
|
||
}
|
||
|
||
// applyResourceLocked validates and persists one materialized resource, then
|
||
// re-activates sing-box, restoring the previous resource on failure —
|
||
// agent.js applyResource(). Callers must hold stateMu.
|
||
func applyResourceLocked(rawResource json.RawMessage) (map[string]interface{}, error) {
|
||
if len(rawResource) == 0 {
|
||
return nil, fmt.Errorf("Missing resource payload")
|
||
}
|
||
decoded, err := decodeJSON(rawResource)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
resMap, ok := asMap(decoded)
|
||
if !ok || asString(resMap["id"]) == "" {
|
||
return nil, fmt.Errorf("Missing resource payload")
|
||
}
|
||
art := &artifacts{}
|
||
materialized, err := materialize(resMap, art)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
resource, _ := asMap(materialized)
|
||
if engine, present := resource["engine"]; present && jsTruthy(engine) && asString(engine) != "sing-box" {
|
||
return nil, fmt.Errorf("Unsupported engine: %v(Xray 已弃用,只支持 sing-box)", engine)
|
||
}
|
||
for _, inboundRaw := range asSlice(resource["inbounds"]) {
|
||
inbound, ok := asMap(inboundRaw)
|
||
if !ok {
|
||
continue
|
||
}
|
||
// Hysteria 2 refuses to run an unauthenticated entry.
|
||
if asString(inbound["type"]) == "hysteria2" {
|
||
users := asSlice(inbound["users"])
|
||
unprotected := len(users) == 0
|
||
for _, user := range users {
|
||
um, ok := asMap(user)
|
||
if !ok || asString(um["password"]) == "" {
|
||
unprotected = true
|
||
}
|
||
}
|
||
if unprotected {
|
||
return nil, fmt.Errorf("Hysteria 2 入口没有认证账户;拒绝启动未受保护的入口")
|
||
}
|
||
}
|
||
tls, ok := asMap(inbound["tls"])
|
||
if !ok {
|
||
continue
|
||
}
|
||
if reality, ok := asMap(tls["reality"]); ok && jsTruthy(reality["enabled"]) {
|
||
continue // Reality has no certificate to check
|
||
}
|
||
domain := asString(tls["server_name"])
|
||
if !domainRe.MatchString(domain) {
|
||
return nil, fmt.Errorf("TLS 域名无效")
|
||
}
|
||
expected := "/etc/nexusgate/tls/" + domain + "/"
|
||
certFile := asString(tls["certificate_path"])
|
||
keyFile := asString(tls["key_path"])
|
||
if certFile == "" || keyFile == "" {
|
||
return nil, tlsCertMissingError(domain)
|
||
}
|
||
if !strings.HasPrefix(certFile, expected) || !strings.HasPrefix(keyFile, expected) ||
|
||
!fileExists(certFile) || !fileExists(keyFile) {
|
||
return nil, tlsCertMissingError(domain)
|
||
}
|
||
if _, err := runThrow(30*time.Second, "openssl", "x509", "-in", certFile, "-noout", "-checkend", "86400"); err != nil {
|
||
return nil, err
|
||
}
|
||
nameCheck, err := runThrow(30*time.Second, "openssl", "x509", "-in", certFile, "-noout", "-checkhost", domain)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if !strings.Contains(nameCheck, "does match") {
|
||
return nil, fmt.Errorf("TLS certificate does not match %s", domain)
|
||
}
|
||
}
|
||
target, err := safeResourcePath(asString(resource["id"]))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var backup []byte
|
||
if data, err := os.ReadFile(target); err == nil {
|
||
backup = data
|
||
}
|
||
if err := flushUsageBeforeChangeLocked(); err != nil {
|
||
return nil, err
|
||
}
|
||
body, err := marshalIndentJSON(resource)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := os.WriteFile(target, []byte(body+"\n"), 0o600); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := activateSingBoxLocked(); err != nil {
|
||
if backup != nil {
|
||
_ = os.WriteFile(target, backup, 0o600)
|
||
} else {
|
||
_ = os.Remove(target)
|
||
}
|
||
_ = activateSingBoxLocked() // preserve original error
|
||
return nil, err
|
||
}
|
||
setLastEngineError("")
|
||
return map[string]interface{}{"artifacts": art.toMap()}, nil
|
||
}
|
||
|
||
// deleteResourceLocked removes one resource and re-activates — agent.js
|
||
// deleteResource(). Callers must hold stateMu.
|
||
func deleteResourceLocked(resourceID string) (map[string]interface{}, error) {
|
||
target, err := safeResourcePath(resourceID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if !fileExists(target) {
|
||
return map[string]interface{}{"alreadyAbsent": true}, nil
|
||
}
|
||
backup, err := os.ReadFile(target)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := flushUsageBeforeChangeLocked(); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := os.Remove(target); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := activateSingBoxLocked(); err != nil {
|
||
_ = os.WriteFile(target, backup, 0o600)
|
||
return nil, err
|
||
}
|
||
return map[string]interface{}{"removed": true}, nil
|
||
}
|
||
|
||
// execute runs one job and reports completion to the panel — agent.js execute().
|
||
func execute(job *agentJob) {
|
||
logf("Executing %s %s", job.ID, job.Action)
|
||
stateMu.Lock()
|
||
var result map[string]interface{}
|
||
var jobErr error
|
||
switch job.Action {
|
||
case "apply_resource":
|
||
result, jobErr = applyResourceLocked(job.Payload.Resource)
|
||
case "delete_resource":
|
||
result, jobErr = deleteResourceLocked(job.Payload.ResourceID)
|
||
default:
|
||
jobErr = fmt.Errorf("Unsupported job action: %s", job.Action)
|
||
}
|
||
stateMu.Unlock()
|
||
completeURL := "/api/agent/jobs/" + job.ID + "/complete"
|
||
// agent.js wraps the job AND the success report in one try/catch: if the
|
||
// completion POST fails after a successful apply, it still falls through
|
||
// to the failure report below. Faithfully replicated (see PARITY.md).
|
||
err := jobErr
|
||
if err == nil {
|
||
_, err = requestJSON(completeURL, map[string]interface{}{"success": true, "result": result})
|
||
if err == nil {
|
||
logf("Completed %s", job.ID)
|
||
return
|
||
}
|
||
}
|
||
logf("Failed %s %s", job.ID, safeError(err.Error()))
|
||
if _, reportErr := requestJSON(completeURL, map[string]interface{}{
|
||
"success": false, "error": safeError(err.Error()),
|
||
}); reportErr != nil {
|
||
logf("Unable to report failure %s", reportErr.Error())
|
||
}
|
||
}
|