feat: Go agent source (stdlib-only rewrite of agent.js) + parity/install docs; binaries ship as release assets
This commit is contained in:
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user