feat: Go agent source (stdlib-only rewrite of agent.js) + parity/install docs; binaries ship as release assets

This commit is contained in:
aaaamoooo
2026-10-20 06:55:29 +00:00
parent c6615f0ab5
commit ddfc27efba
13 changed files with 2626 additions and 0 deletions
+193
View File
@@ -0,0 +1,193 @@
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())
}
}