Files
NexusGate/agent-go/jobs.go
T

194 lines
5.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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())
}
}