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()) } }