Files
NexusGate/agent-go/jobs.go
T

207 lines
6.5 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"`
KeyMaterial map[string]realityKeyPair `json:"keyMaterial"`
} `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.
//
// Panel-hosted Reality keys: payload keyMaterial (if any) is written to
// keys.json before materializing so the hosted pair is what gets deployed;
// keypairs used but not provided are reported back in artifacts.keyMaterial
// for the panel to store. Payloads from older panels carry no keyMaterial
// and behave exactly as before.
func applyResourceLocked(rawResource json.RawMessage, keyMaterial map[string]realityKeyPair) (map[string]interface{}, error) {
if len(rawResource) == 0 {
return nil, fmt.Errorf("Missing resource payload")
}
if err := persistKeyMaterial(keyMaterial); err != nil {
return nil, err
}
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
}
if backfill := backfillKeyMaterial(art.used, keyMaterial); len(backfill) > 0 {
art.KeyMaterial = backfill
}
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, job.Payload.KeyMaterial)
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())
}
}