Merge main and preserve task worker overrides with model pools

This commit is contained in:
ji ruo committed 2026-10-26 09:36:03 +08:00
commit 71e933c6a3
37 files changed
+1998 -143

No files matched your search

+1 -1
View File
@@ -6,9 +6,9 @@ import (
"path/filepath"
"testing"
actool "github.com/Autumn-27/norma/tool"
"github.com/Autumn-27/artex/agent"
"github.com/Autumn-27/artex/db"
actool "github.com/Autumn-27/norma/tool"
)
// hasSkillTool reports whether the packed tool set contains the Skill meta-tool.
+13 -3
View File
@@ -462,9 +462,16 @@ func (s *Server) httpProxyTransport(spec httpExec) *http.Transport {
proxyStr := strings.TrimSpace(spec.Proxy)
var caFile string
if spec.UseRecordingProxy {
if addr := s.m.ProxyAddr(); addr != "" {
proxyStr = "http://" + addr
caFile = s.m.ProxyCACert()
// ProxyAddr is already a complete URL while capture is enabled, and
// returns the configured global proxy URL when capture is off. Keep both
// forms intact (including socks5:// and credentials).
if s.m.TrafficEnabled() {
if addr := s.m.ProxyAddr(); addr != "" {
proxyStr = addr
caFile = s.m.ProxyCACert()
}
} else if global := strings.TrimSpace(s.m.GlobalProxy()); global != "" {
proxyStr = global
}
}
if proxyStr == "" {
@@ -474,6 +481,9 @@ func (s *Server) httpProxyTransport(spec httpExec) *http.Transport {
if err != nil {
return nil
}
// Go 1.26+ supports http/https/socks5/socks5h in ProxyURL directly,
// including proxy userinfo. Keep one code path so existing HTTP tool
// behavior and proxy authentication remain unchanged.
tr := &http.Transport{Proxy: http.ProxyURL(pu)}
if caFile != "" {
if pem, err := os.ReadFile(caFile); err == nil {
+2
View File
@@ -64,6 +64,7 @@ type TaskDTO struct {
ArchiveBlockedBy string `json:"archive_blocked_by_task_id,omitempty"`
CompanyIDs []int64 `json:"company_ids"`
CoverageEnabled bool `json:"coverage_enabled"` // 资产覆盖度功能开关(创建时定)
WorkerCount int `json:"worker_count"` // 本任务 Worker 数;0=用全局默认
}
// FindingSeverityDTO 是任务列表里按严重度分档的漏洞计数(严重/高/中/低)。
@@ -138,6 +139,7 @@ func taskDTO(t *Task, status string) TaskDTO {
SourceTaskIDs: sourceIDs,
CompanyIDs: lifecycle.CompanyIDs,
CoverageEnabled: t.CoverageEnabled,
WorkerCount: t.WorkerCount,
}
}
+23 -2
View File
@@ -793,10 +793,10 @@ func (e *Engine) ReadyFor(t *Task) bool {
// but no-op until an LLM is configured (so a task created while idle picks up
// automatically once LLM is set from the UI).
func (e *Engine) Run(ctx context.Context, t *Task) {
slots, err := ExpandWorkerSlots(e.m.WorkerPools(), e.m.Workers())
slots, err := workerSlotsForTask(t, e.m.WorkerPools(), e.m.Workers())
if err != nil {
log.Printf("[engine] task %s worker pool invalid, using legacy workers: %v", t.ID, err)
slots, _ = ExpandWorkerSlots(nil, e.m.Workers())
slots, _ = ExpandWorkerSlots(nil, workerCountForTask(t, e.m.Workers()))
}
e.deleteMu.RLock()
if e.IsDeleting(t.ID) {
@@ -832,6 +832,27 @@ func (e *Engine) Run(ctx context.Context, t *Task) {
}
}
// workerSlotsForTask preserves an explicit task concurrency override. Tasks
// using the global default inherit its model pools and their pinned profiles.
func workerSlotsForTask(t *Task, pools []WorkerPool, global int) ([]WorkerSlot, error) {
if t != nil && t.WorkerCount > 0 {
return ExpandWorkerSlots(nil, workerCountForTask(t, global))
}
return ExpandWorkerSlots(pools, global)
}
func workerCountForTask(t *Task, global int) int {
if t != nil && t.WorkerCount > 0 {
// Direct database edits can bypass validation; cap those values so the
// engine never starts more than the supported per-task worker count.
if t.WorkerCount > db.MaxTaskWorkerCount {
return db.MaxTaskWorkerCount
}
return t.WorkerCount
}
return global
}
// plannerHeartbeatInterval 解析任务的 planner 心跳间隔。db.CreateTask 已归一
// (低于 600 一律抬到 600);这里再兜一次底,防内存态异常值。
func plannerHeartbeatInterval(t *Task) time.Duration {
+59
View File
@@ -0,0 +1,59 @@
package server
import (
"fmt"
"testing"
"github.com/Autumn-27/artex/db"
)
func TestWorkerCountForTaskUsesOverrideOrGlobalDefault(t *testing.T) {
if got := workerCountForTask(&Task{WorkerCount: 5}, 3); got != 5 {
t.Fatalf("override = %d, want 5", got)
}
if got := workerCountForTask(&Task{WorkerCount: 0}, 3); got != 3 {
t.Fatalf("zero override = %d, want global 3", got)
}
if got := workerCountForTask(&Task{WorkerCount: -1}, 3); got != 3 {
t.Fatalf("negative override = %d, want global 3", got)
}
if got := workerCountForTask(&Task{WorkerCount: db.MaxTaskWorkerCount + 1}, 3); got != db.MaxTaskWorkerCount {
t.Fatalf("oversized override = %d, want capped %d", got, db.MaxTaskWorkerCount)
}
}
func TestWorkerSlotsForTaskCombinesOverridesAndModelPools(t *testing.T) {
pools := []WorkerPool{{ProfileID: 7, Workers: 2}, {ProfileID: 12, Workers: 1}}
for _, tc := range []struct {
name string
task *Task
pools []WorkerPool
global int
profiles []int64
}{
{"task inherits model pools", &Task{}, pools, 9, []int64{7, 7, 12}},
{"task override replaces global pools", &Task{WorkerCount: 2}, pools, 9, []int64{0, 0}},
{"task inherits legacy count", &Task{}, nil, 4, []int64{0, 0, 0, 0}},
{"task override without pools", &Task{WorkerCount: 1}, nil, 4, []int64{0}},
{"invalid task value uses pools", &Task{WorkerCount: -1}, pools, 9, []int64{7, 7, 12}},
} {
t.Run(tc.name, func(t *testing.T) {
slots, err := workerSlotsForTask(tc.task, tc.pools, tc.global)
if err != nil || len(slots) != len(tc.profiles) {
t.Fatalf("slots=%+v err=%v, want profiles %v", slots, err, tc.profiles)
}
for i, profile := range tc.profiles {
if slots[i].ProfileID != profile || slots[i].Name != fmt.Sprintf("work#%d", i+1) {
t.Fatalf("slot[%d]=%+v, want profile %d", i, slots[i], profile)
}
}
})
}
slots, err := workerSlotsForTask(&Task{WorkerCount: db.MaxTaskWorkerCount + 1}, pools, 3)
if err != nil || len(slots) != db.MaxTaskWorkerCount {
t.Fatalf("oversized task override: slots=%d err=%v", len(slots), err)
}
if _, err := workerSlotsForTask(&Task{}, []WorkerPool{{ProfileID: 0, Workers: 1}}, 3); err == nil {
t.Fatal("invalid inherited pool accepted")
}
}
+3 -2
View File
@@ -60,6 +60,7 @@ type Task struct {
TimeoutSeconds int `json:"timeout_seconds"`
PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"` // planner 心跳触发间隔(秒)
CoverageEnabled bool `json:"coverage_enabled"` // 资产覆盖度功能开关(创建时定,默认开)
WorkerCount int `json:"worker_count"` // 本任务 Worker 数;0=用全局默认
FirstRunAt int64 `json:"first_run_at,omitempty"`
DeadlineAt int64 `json:"deadline_at,omitempty"`
Store *pgdb.ExplorationStore `json:"-"`
@@ -1005,8 +1006,8 @@ func taskFromPG(pt *pgdb.Task, store *pgdb.ExplorationStore, ic *intercept.Inter
SourceTaskIDs: append([]int64(nil), pt.SourceTaskIDs...),
CompanyIDs: append([]int64(nil), pt.CompanyIDs...),
TimeoutSeconds: pt.TimeoutSeconds, PlanHeartbeatSeconds: pt.PlanHeartbeatSeconds,
CoverageEnabled: pt.CoverageEnabled,
FirstRunAt: unixOrZero(pt.FirstRunAt), DeadlineAt: unixOrZero(pt.DeadlineAt),
CoverageEnabled: pt.CoverageEnabled, WorkerCount: pt.WorkerCount,
FirstRunAt: unixOrZero(pt.FirstRunAt), DeadlineAt: unixOrZero(pt.DeadlineAt),
Store: store, Guard: guard.NewWithInterceptor(ic), notify: make(chan struct{}, 1),
}
}
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"github.com/Autumn-27/artex/db"
)
const maxRemarkRunes = 2000
const maxRemarkRunes = db.MaxTaskRemarkRunes
func normalizeRemark(value string) (string, error) {
value = strings.TrimSpace(value)
+416
View File
@@ -0,0 +1,416 @@
package server
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"syscall"
"time"
)
const (
repeaterMaxBody = 2 << 20 // 2 MiB, for both the JSON request and response preview
repeaterDefaultTimeout = 30_000
repeaterMinTimeout = 1_000
repeaterMaxTimeout = 120_000
repeaterAllowLocalEnv = "ARTEX_REPEATER_ALLOW_LOCAL"
)
var repeaterHopByHop = map[string]struct{}{
"Connection": {}, "Keep-Alive": {}, "Proxy-Connection": {}, "Transfer-Encoding": {},
"Te": {}, "Trailer": {}, "Upgrade": {}, "Content-Length": {}, "Proxy-Authorization": {},
}
type repeaterSendRequest struct {
Method *string `json:"method"`
URL string `json:"url"`
Headers map[string]string `json:"headers"`
Body string `json:"body"`
TimeoutMs int `json:"timeout_ms"`
InsecureSkipVerify bool `json:"insecure_skip_verify"`
UseRecordingProxy *bool `json:"use_recording_proxy"`
}
type repeaterSendResponse struct {
Status int `json:"status"`
StatusText string `json:"status_text"`
Proto string `json:"proto"`
Headers map[string][]string `json:"headers"`
Body string `json:"body"`
BodyTruncated bool `json:"body_truncated"`
BodyBytes int `json:"body_bytes"`
ElapsedMs int64 `json:"elapsed_ms"`
URL string `json:"url"`
Error string `json:"error,omitempty"`
}
func (s *Server) registerRepeater(mux *http.ServeMux) {
mux.HandleFunc("POST /api/repeater/send", s.sendRepeater)
}
func (s *Server) sendRepeater(w http.ResponseWriter, r *http.Request) {
var in repeaterSendRequest
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, repeaterMaxBody))
if err := dec.Decode(&in); err != nil {
writeErr(w, http.StatusBadRequest, "请求体无效")
return
}
// Reject a second JSON value (or oversized trailing data) instead of silently
// accepting bytes beyond the documented 2 MiB request limit.
var extra any
if err := dec.Decode(&extra); err != io.EOF {
writeErr(w, http.StatusBadRequest, "请求体无效")
return
}
method := http.MethodGet
if in.Method != nil {
method = strings.ToUpper(strings.TrimSpace(*in.Method))
}
if method == "" {
writeErr(w, http.StatusBadRequest, "HTTP 方法不能为空")
return
}
switch method {
case http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch,
http.MethodDelete, http.MethodHead, http.MethodOptions:
default:
writeErr(w, http.StatusBadRequest, "不支持的 HTTP 方法")
return
}
rawURL := strings.TrimSpace(in.URL)
if rawURL == "" {
writeErr(w, http.StatusBadRequest, "url 必填")
return
}
u, err := url.Parse(rawURL)
if err != nil || u.Host == "" || u.Hostname() == "" ||
!strings.EqualFold(u.Scheme, "http") && !strings.EqualFold(u.Scheme, "https") {
writeErr(w, http.StatusBadRequest, "仅支持绝对 http/https URL")
return
}
// This check is needed even when a proxy is used: the proxy must remain usable
// for normal targets, while obvious literal metadata/loopback URLs stay blocked.
if err := repeaterCheckURL(u); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
timeoutMs := in.TimeoutMs
if timeoutMs <= 0 {
timeoutMs = repeaterDefaultTimeout
}
if timeoutMs < repeaterMinTimeout {
timeoutMs = repeaterMinTimeout
}
if timeoutMs > repeaterMaxTimeout {
timeoutMs = repeaterMaxTimeout
}
useRecordingProxy := true
if in.UseRecordingProxy != nil {
useRecordingProxy = *in.UseRecordingProxy
}
var body io.Reader
if in.Body != "" && method != http.MethodHead {
body = strings.NewReader(in.Body)
}
ctx, cancel := context.WithTimeout(r.Context(), time.Duration(timeoutMs)*time.Millisecond)
defer cancel()
req, err := http.NewRequestWithContext(ctx, method, u.String(), body)
if err != nil {
writeErr(w, http.StatusBadRequest, "构造请求失败")
return
}
for key, value := range in.Headers {
name := strings.TrimSpace(key)
if name == "" {
continue
}
if _, blocked := repeaterHopByHop[http.CanonicalHeaderKey(name)]; blocked {
continue
}
if strings.EqualFold(name, "Host") {
// Host is a special Request field, but retaining it in Header too makes
// the editor semantics explicit; net/http uses req.Host on the wire.
req.Host = strings.TrimSpace(value)
req.Header.Set("Host", strings.TrimSpace(value))
continue
}
req.Header.Set(name, value)
}
client := &http.Client{
Timeout: time.Duration(timeoutMs) * time.Millisecond,
Transport: s.repeaterTransport(useRecordingProxy, in.InsecureSkipVerify),
CheckRedirect: func(next *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return errors.New("重定向次数过多")
}
if err := repeaterCheckURL(next.URL); err != nil {
return err
}
if len(via) > 0 && !strings.EqualFold(next.URL.Host, via[0].URL.Host) {
return fmt.Errorf("拒绝跨主机重定向(%s → %s)", via[0].URL.Host, next.URL.Host)
}
return nil
},
}
// Each send owns its transport. Registered before Body.Close so idle
// connections are released after the response body, including on errors.
defer client.CloseIdleConnections()
started := time.Now()
resp, err := client.Do(req)
if err != nil {
writeJSON(w, http.StatusOK, repeaterSendResponse{
Headers: map[string][]string{}, URL: u.String(),
ElapsedMs: time.Since(started).Milliseconds(), Error: repeaterRedactError(err),
})
return
}
defer resp.Body.Close()
out := repeaterSendResponse{
Status: resp.StatusCode, StatusText: resp.Status, Proto: resp.Proto,
Headers: resp.Header.Clone(), URL: resp.Request.URL.String(),
}
if method != http.MethodHead {
// Read one byte beyond the cap so truncation is exact. The MVP keeps the
// preview as UTF-8 text; invalid bytes are replaced instead of base64.
data, readErr := io.ReadAll(io.LimitReader(resp.Body, repeaterMaxBody+1))
if len(data) > repeaterMaxBody {
out.BodyTruncated = true
data = data[:repeaterMaxBody]
}
out.Body = repeaterBodyText(data)
out.BodyBytes = len(data)
if readErr != nil && !errors.Is(readErr, io.EOF) {
out.Error = "读取响应失败: " + readErr.Error()
}
}
out.ElapsedMs = time.Since(started).Milliseconds()
writeJSON(w, http.StatusOK, out)
}
// repeaterBodyText keeps the response preview as text for the MVP. Invalid
// UTF-8 and NUL bytes are replaced so a binary response cannot inject control
// characters into the JSON/UI raw view; the original bytes are not base64
// encoded and the preview remains bounded by repeaterMaxBody.
func repeaterBodyText(data []byte) string {
text := string(bytes.ToValidUTF8(data, []byte("�")))
return strings.ReplaceAll(text, "\x00", "�")
}
func (s *Server) repeaterTransport(useRecordingProxy, insecure bool) http.RoundTripper {
var transport *http.Transport
proxied := false
if useRecordingProxy && s != nil && s.m != nil {
transport = s.httpProxyTransport(httpExec{UseRecordingProxy: true})
proxied = transport != nil
}
if transport == nil && s != nil && s.m != nil && strings.TrimSpace(s.m.GlobalProxy()) != "" {
transport = s.httpProxyTransport(httpExec{Proxy: s.m.GlobalProxy()})
proxied = transport != nil
}
if transport == nil {
if base, ok := http.DefaultTransport.(*http.Transport); ok {
transport = base.Clone()
} else {
transport = &http.Transport{}
}
// "Direct" means direct even if the process inherited HTTP_PROXY. The
// configured recording/global proxy paths are handled above explicitly.
transport.Proxy = nil
}
transport = transport.Clone()
// Keep response bytes faithful and make body_bytes match what was received;
// transparent gzip decompression would otherwise obscure both values.
transport.DisableCompression = true
// Use the request's timeout_ms budget instead of inherited phase deadlines.
transport.TLSHandshakeTimeout = 0
transport.ExpectContinueTimeout = 0
if !proxied {
// Clear a custom TLS dialer before installing the guarded dialer; when
// DialTLSContext is set, net/http bypasses DialContext entirely.
transport.DialTLSContext = nil
transport.DialTLS = nil
transport.Dial = nil
// Let the request context enforce timeout_ms for the entire dial.
transport.DialContext = (&net.Dialer{
KeepAlive: 30 * time.Second,
Control: repeaterBlockSensitiveDial,
}).DialContext
}
if insecure {
cfg := &tls.Config{InsecureSkipVerify: true} //nolint:gosec // explicit user option for pentest targets
if transport.TLSClientConfig != nil {
cfg = transport.TLSClientConfig.Clone()
cfg.InsecureSkipVerify = true
}
transport.TLSClientConfig = cfg
}
return transport
}
func repeaterAllowLocal() bool {
v := strings.TrimSpace(os.Getenv(repeaterAllowLocalEnv))
return v == "1" || strings.EqualFold(v, "true")
}
func repeaterCheckURL(u *url.URL) error {
if repeaterAllowLocal() {
return nil
}
host := strings.TrimSuffix(strings.TrimSpace(u.Hostname()), ".")
// URL.Hostname may retain an IPv6 zone (for example fe80::1%eth0); the
// zone is not part of the address classification.
if zone := strings.IndexByte(host, '%'); zone >= 0 {
host = host[:zone]
}
ip := net.ParseIP(host)
if ip == nil && repeaterLooksNumericHost(host) {
var ok bool
ip, ok = repeaterParseLegacyIPv4(host)
if !ok {
return fmt.Errorf("拒绝无法解析的数字主机地址 %q", host)
}
}
if strings.EqualFold(host, "localhost") || (ip != nil && repeaterIsBlockedDialIP(ip)) {
return fmt.Errorf("拒绝访问本机/链路本地地址(如确需访问本机服务,设置 %s=1)", repeaterAllowLocalEnv)
}
return nil
}
// repeaterLooksNumericHost identifies the legacy numeric spellings accepted by
// common proxy/resolver implementations (decimal, octal, hexadecimal and
// dotted inet_aton forms). A numeric-looking value that fails to parse is
// rejected instead of being handed to a proxy for its own interpretation.
func repeaterLooksNumericHost(host string) bool {
if host == "" || host[0] < '0' || host[0] > '9' {
return false
}
for i := 0; i < len(host); i++ {
c := host[i]
switch {
case c == '.':
case c >= '0' && c <= '9':
case c >= 'a' && c <= 'f', c >= 'A' && c <= 'F':
case c == 'x' || c == 'X':
default:
return false
}
}
return true
}
// repeaterParseLegacyIPv4 follows inet_aton's 1-, 2-, 3- and 4-part IPv4
// notation. Parts with a leading zero are octal and parts with 0x are hex.
func repeaterParseLegacyIPv4(host string) (net.IP, bool) {
parts := strings.Split(host, ".")
if len(parts) < 1 || len(parts) > 4 {
return nil, false
}
values := make([]uint64, len(parts))
for i, part := range parts {
if part == "" {
return nil, false
}
base := 10
digits := part
if len(digits) > 2 && digits[0] == '0' && (digits[1] == 'x' || digits[1] == 'X') {
base, digits = 16, digits[2:]
} else if len(digits) > 1 && digits[0] == '0' {
base = 8
}
if digits == "" {
return nil, false
}
value, err := strconv.ParseUint(digits, base, 32)
if err != nil {
return nil, false
}
values[i] = value
}
var value uint64
switch len(values) {
case 1:
value = values[0]
case 2:
if values[0] > 0xff || values[1] > 0xffffff {
return nil, false
}
value = values[0]<<24 | values[1]
case 3:
if values[0] > 0xff || values[1] > 0xff || values[2] > 0xffff {
return nil, false
}
value = values[0]<<24 | values[1]<<16 | values[2]
case 4:
for _, part := range values {
if part > 0xff {
return nil, false
}
}
value = values[0]<<24 | values[1]<<16 | values[2]<<8 | values[3]
}
if value > 0xffffffff {
return nil, false
}
return net.IPv4(byte(value>>24), byte(value>>16), byte(value>>8), byte(value)), true
}
func repeaterIsBlockedDialIP(ip net.IP) bool {
if ip == nil {
return true
}
if v4 := ip.To4(); v4 != nil {
ip = v4
}
return ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
ip.IsInterfaceLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast()
}
func repeaterBlockSensitiveDial(_, address string, _ syscall.RawConn) error {
if repeaterAllowLocal() {
return nil
}
host, _, err := net.SplitHostPort(address)
if err != nil {
return err
}
ip := net.ParseIP(host)
if ip == nil {
return fmt.Errorf("无法解析目标地址 %q", host)
}
if repeaterIsBlockedDialIP(ip) {
return fmt.Errorf("拒绝访问本机/链路本地地址 %s(如确需访问本机服务,设置 %s=1)", ip, repeaterAllowLocalEnv)
}
return nil
}
func repeaterRedactError(err error) string {
var urlErr *url.Error
if errors.As(err, &urlErr) {
host := ""
if parsed, parseErr := url.Parse(urlErr.URL); parseErr == nil {
host = parsed.Host
}
if urlErr.Err != nil {
return fmt.Sprintf("%s %s: %s", urlErr.Op, host, urlErr.Err)
}
return fmt.Sprintf("%s %s: 未知错误", urlErr.Op, host)
}
return err.Error()
}
+404
View File
@@ -0,0 +1,404 @@
package server
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
"time"
"github.com/Autumn-27/artex/traffic"
)
func repeaterTestSend(t *testing.T, s *Server, payload any) (int, repeaterSendResponse, string) {
t.Helper()
raw, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
r := httptest.NewRequest(http.MethodPost, "/api/repeater/send", bytes.NewReader(raw))
w := httptest.NewRecorder()
s.sendRepeater(w, r)
var out repeaterSendResponse
_ = json.Unmarshal(w.Body.Bytes(), &out)
return w.Code, out, w.Body.String()
}
func TestRepeaterRejectsInvalidInput(t *testing.T) {
t.Setenv(repeaterAllowLocalEnv, "")
s := &Server{}
for _, tc := range []struct {
name string
body string
}{
{"missing url", `{"method":"GET"}`},
{"empty method", `{"method":" ","url":"https://example.test/"}`},
{"bad scheme", `{"method":"GET","url":"file:///etc/passwd"}`},
{"gopher", `{"method":"GET","url":"gopher://example.test/1"}`},
{"unknown method", `{"method":"TRACE","url":"http://example.test/"}`},
} {
t.Run(tc.name, func(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "/api/repeater/send", strings.NewReader(tc.body))
w := httptest.NewRecorder()
s.sendRepeater(w, r)
if w.Code != http.StatusBadRequest {
t.Fatalf("status=%d want 400 body=%s", w.Code, w.Body.String())
}
})
}
}
func TestRepeaterBlockedIPs(t *testing.T) {
for _, raw := range []string{"127.0.0.1", "::1", "169.254.169.254", "0.0.0.0", "224.0.0.1"} {
if !repeaterIsBlockedDialIP(net.ParseIP(raw)) {
t.Errorf("expected %s blocked", raw)
}
}
for _, raw := range []string{"8.8.8.8", "10.0.0.1", "192.168.1.20", "172.16.0.5"} {
if repeaterIsBlockedDialIP(net.ParseIP(raw)) {
t.Errorf("expected %s allowed", raw)
}
}
t.Setenv(repeaterAllowLocalEnv, "")
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
t.Fatal("loopback handler should not be reached")
}))
defer srv.Close()
code, _, body := repeaterTestSend(t, &Server{}, map[string]any{"url": srv.URL, "use_recording_proxy": false})
if code != http.StatusBadRequest || !strings.Contains(body, "拒绝") {
t.Fatalf("loopback status=%d body=%s", code, body)
}
}
func TestRepeaterRejectsLegacyNumericHostsIncludingProxyPath(t *testing.T) {
t.Setenv(repeaterAllowLocalEnv, "")
for _, raw := range []string{
"http://2130706433/", // decimal 127.0.0.1
"http://0177.0.0.1/", // octal 127.0.0.1
"http://127.1/", // two-part inet_aton form
"http://0x7f000001/", // hexadecimal 127.0.0.1
"http://9999999999/", // numeric-looking but invalid
} {
u, err := url.Parse(raw)
if err != nil {
t.Fatal(err)
}
if err := repeaterCheckURL(u); err == nil {
t.Errorf("repeaterCheckURL(%q) allowed a legacy numeric host", raw)
}
}
// The check must run before a configured proxy gets a chance to interpret
// the legacy spelling and connect to loopback remotely.
proxy := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
t.Error("proxy received a blocked numeric target")
}))
defer proxy.Close()
s := &Server{m: &Manager{globalProxy: proxy.URL}}
code, _, body := repeaterTestSend(t, s, map[string]any{"url": "http://2130706433/"})
if code != http.StatusBadRequest || !strings.Contains(body, "拒绝") {
t.Fatalf("proxied legacy host status=%d body=%s", code, body)
}
}
func TestRepeaterBlockSensitiveDial(t *testing.T) {
t.Setenv(repeaterAllowLocalEnv, "")
for _, address := range []string{"127.0.0.1:80", "169.254.169.254:80"} {
if err := repeaterBlockSensitiveDial("tcp", address, nil); err == nil {
t.Errorf("repeaterBlockSensitiveDial(%q) allowed blocked address", address)
}
}
}
func TestRepeaterTransportBlocksSensitiveDial(t *testing.T) {
t.Setenv(repeaterAllowLocalEnv, "")
tr := (&Server{}).repeaterTransport(false, false).(*http.Transport)
defer tr.CloseIdleConnections()
if tr.DialContext == nil {
t.Fatal("direct transport has no guarded DialContext")
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
conn, err := tr.DialContext(ctx, "tcp", "127.0.0.1:1")
if conn != nil {
conn.Close()
}
if err == nil || !strings.Contains(err.Error(), "拒绝访问本机/链路本地地址 127.0.0.1") {
t.Fatalf("dial should fail at the sensitive-address guard, got %v", err)
}
}
func TestRepeaterDirectHTTP2(t *testing.T) {
t.Setenv(repeaterAllowLocalEnv, "1")
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.ProtoMajor != 2 {
t.Errorf("request protocol=%s, want HTTP/2", r.Proto)
}
_, _ = io.WriteString(w, "h2 response")
}))
srv.EnableHTTP2 = true
srv.TLS = &tls.Config{NextProtos: []string{"h2"}}
srv.StartTLS()
defer srv.Close()
code, out, raw := repeaterTestSend(t, &Server{}, map[string]any{
"url": srv.URL, "use_recording_proxy": false, "insecure_skip_verify": true,
})
if code != http.StatusOK || out.Error != "" || out.Status != http.StatusOK || out.Proto != "HTTP/2.0" || out.Body != "h2 response" {
t.Fatalf("HTTP/2 response status=%d raw=%s", code, raw)
}
}
func TestRepeaterTLSHandshakeUsesRequestTimeout(t *testing.T) {
t.Setenv(repeaterAllowLocalEnv, "1")
// Scale down the inherited timeout so a slow handshake can be tested
// without waiting for the default transport's ten-second deadline.
base := http.DefaultTransport
short := base.(*http.Transport).Clone()
short.TLSHandshakeTimeout = 10 * time.Millisecond
http.DefaultTransport = short
defer func() { http.DefaultTransport = base }()
defer short.CloseIdleConnections()
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, "handshake completed")
}))
srv.TLS = &tls.Config{GetConfigForClient: func(*tls.ClientHelloInfo) (*tls.Config, error) {
time.Sleep(100 * time.Millisecond)
return nil, nil
}}
srv.StartTLS()
defer srv.Close()
code, out, raw := repeaterTestSend(t, &Server{}, map[string]any{
"url": srv.URL, "use_recording_proxy": false, "insecure_skip_verify": true, "timeout_ms": 1000,
})
if code != http.StatusOK || out.Error != "" || out.Status != http.StatusOK || out.Body != "handshake completed" {
t.Fatalf("handshake within request budget failed: status=%d raw=%s", code, raw)
}
}
func TestRepeaterClosesIdleConnections(t *testing.T) {
t.Setenv(repeaterAllowLocalEnv, "1")
for _, mode := range []string{"direct", "http2", "global proxy", "recording proxy fallback"} {
t.Run(mode, func(t *testing.T) {
closed := make(chan struct{}, 1)
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, "response")
}))
srv.Config.ConnState = func(_ net.Conn, state http.ConnState) {
if state == http.StateClosed {
closed <- struct{}{}
}
}
if mode == "http2" {
srv.EnableHTTP2 = true
srv.StartTLS()
} else {
srv.Start()
}
defer srv.Close()
s := &Server{}
payload := map[string]any{"url": srv.URL, "use_recording_proxy": false, "insecure_skip_verify": true}
if mode == "global proxy" || mode == "recording proxy fallback" {
s.m = &Manager{globalProxy: srv.URL}
payload["url"] = "http://target.example/"
if mode == "recording proxy fallback" {
delete(payload, "use_recording_proxy")
}
}
code, out, raw := repeaterTestSend(t, s, payload)
if code != http.StatusOK || out.Error != "" || out.Status != http.StatusOK || out.Body != "response" {
t.Fatalf("status=%d raw=%s", code, raw)
}
select {
case <-closed:
case <-time.After(time.Second):
t.Fatal("connection still open after sendRepeater returned")
}
})
}
}
func TestRepeaterRoundTripHeadersAndBody(t *testing.T) {
t.Setenv(repeaterAllowLocalEnv, "1")
var got http.Request
var gotBody string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = *r
b, _ := io.ReadAll(r.Body)
gotBody = string(b)
w.Header().Set("X-Echo", "ok")
w.WriteHeader(http.StatusCreated)
_, _ = io.WriteString(w, "hello")
}))
defer srv.Close()
code, out, body := repeaterTestSend(t, &Server{}, map[string]any{
"method": "POST", "url": srv.URL, "headers": map[string]string{
"X-Test": "yes", "Host": "custom.example", "Connection": "keep-alive",
"Keep-Alive": "timeout=5", "Transfer-Encoding": "chunked", "Content-Length": "99",
}, "body": "payload", "use_recording_proxy": false,
})
if code != http.StatusOK || out.Error != "" {
t.Fatalf("status=%d out=%+v raw=%s", code, out, body)
}
if out.Status != http.StatusCreated || out.Body != "hello" || out.BodyBytes != 5 {
t.Fatalf("response=%+v", out)
}
if out.Headers["X-Echo"][0] != "ok" || got.Method != http.MethodPost || gotBody != "payload" || got.Host != "custom.example" {
t.Fatalf("request method=%s host=%s body=%q headers=%v", got.Method, got.Host, gotBody, got.Header)
}
for _, name := range []string{"Connection", "Keep-Alive", "Transfer-Encoding"} {
if got.Header.Get(name) != "" {
t.Errorf("hop-by-hop header %s was forwarded: %q", name, got.Header.Get(name))
}
}
if got.Header.Get("Content-Length") != "7" {
t.Errorf("content length should match body, got %q", got.Header.Get("Content-Length"))
}
}
func TestRepeaterTruncatesAndHonoursHEAD(t *testing.T) {
t.Setenv(repeaterAllowLocalEnv, "1")
large := strings.Repeat("x", repeaterMaxBody+1)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodHead {
_, _ = io.WriteString(w, "should-not-be-read")
return
}
_, _ = io.WriteString(w, large)
}))
defer srv.Close()
_, out, _ := repeaterTestSend(t, &Server{}, map[string]any{"url": srv.URL, "use_recording_proxy": false})
if !out.BodyTruncated || out.BodyBytes != repeaterMaxBody || len(out.Body) != repeaterMaxBody {
t.Fatalf("truncation=%+v", out)
}
_, head, _ := repeaterTestSend(t, &Server{}, map[string]any{"method": "HEAD", "url": srv.URL, "use_recording_proxy": false})
if head.Body != "" || head.BodyBytes != 0 {
t.Fatalf("HEAD body=%q bytes=%d", head.Body, head.BodyBytes)
}
}
func TestRepeaterBodyTextReplacesBinaryControls(t *testing.T) {
if got := repeaterBodyText([]byte{'a', 0, 0xff, 'b'}); got != "a��b" {
t.Fatalf("body text=%q", got)
}
}
func TestRepeaterRejectsCrossHostRedirect(t *testing.T) {
t.Setenv(repeaterAllowLocalEnv, "1")
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = io.WriteString(w, "target") }))
defer target.Close()
redirect := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, target.URL, http.StatusFound)
}))
defer redirect.Close()
_, out, _ := repeaterTestSend(t, &Server{}, map[string]any{"url": redirect.URL, "use_recording_proxy": false})
if out.Status != 0 || !strings.Contains(out.Error, "跨主机") {
t.Fatalf("redirect response=%+v", out)
}
}
func TestRepeaterProxyTransportConstruction(t *testing.T) {
s := &Server{m: &Manager{}}
for _, scheme := range []string{"http", "https", "socks5", "socks5h"} {
tr := s.httpProxyTransport(httpExec{Proxy: scheme + "://user:[email protected]:1080"})
if tr == nil || tr.Proxy == nil {
t.Fatalf("%s proxy transport not configured: %#v", scheme, tr)
}
proxyURL, err := tr.Proxy(httptest.NewRequest(http.MethodGet, "https://target.example/", nil))
if err != nil || proxyURL == nil || proxyURL.Scheme != scheme || proxyURL.Host != "127.0.0.1:1080" {
t.Fatalf("%s proxy URL = %#v, err=%v", scheme, proxyURL, err)
}
if user := proxyURL.User; user == nil || user.Username() != "user" {
t.Fatalf("%s proxy credentials missing: %v", scheme, proxyURL.User)
}
password, ok := proxyURL.User.Password()
if !ok || password != "pass" {
t.Fatalf("%s proxy password missing: %q (ok=%v)", scheme, password, ok)
}
}
// With capture disabled, the recording-proxy option must preserve the
// configured global proxy URL instead of treating it as a host:port.
s.m.globalProxy = "socks5://user:[email protected]:1080"
tr := s.httpProxyTransport(httpExec{UseRecordingProxy: true})
if tr == nil || tr.Proxy == nil {
t.Fatal("global SOCKS5 proxy was not configured")
}
proxyURL, err := tr.Proxy(httptest.NewRequest(http.MethodGet, "https://target.example/", nil))
if err != nil || proxyURL == nil || proxyURL.Scheme != "socks5" {
t.Fatalf("global proxy URL = %#v, err=%v", proxyURL, err)
}
// Capture mode supplies a complete HTTP URL from Traffic.ProxyAddr. The
// helper must preserve that scheme and host rather than prefixing http:// a
// second time.
captured, err := traffic.Open(t.TempDir(), "127.0.0.1:18788")
if err != nil {
t.Fatalf("traffic.Open: %v", err)
}
defer captured.Close()
s.m.traffic = captured
s.m.trafficOn = true
tr = s.httpProxyTransport(httpExec{UseRecordingProxy: true})
if tr == nil || tr.Proxy == nil {
t.Fatal("recording proxy was not configured")
}
proxyURL, err = tr.Proxy(httptest.NewRequest(http.MethodGet, "https://target.example/", nil))
if err != nil || proxyURL == nil || proxyURL.Scheme != "http" || proxyURL.Host != "127.0.0.1:18788" {
t.Fatalf("recording proxy URL = %#v, err=%v", proxyURL, err)
}
caFile := s.m.ProxyCACert()
if caFile == "" {
t.Fatal("recording proxy CA path is empty")
}
caPEM, err := os.ReadFile(caFile)
if err != nil {
t.Fatalf("read recording proxy CA: %v", err)
}
wantRoots := x509.NewCertPool()
if !wantRoots.AppendCertsFromPEM(caPEM) {
t.Fatal("recording proxy CA is not valid PEM")
}
if tr.TLSClientConfig == nil || tr.TLSClientConfig.RootCAs == nil || !tr.TLSClientConfig.RootCAs.Equal(wantRoots) {
t.Fatal("recording proxy CA was not loaded into transport RootCAs")
}
if s.repeaterTransport(true, false).(*http.Transport).ForceAttemptHTTP2 {
t.Fatal("recording proxy transport must not force HTTP/2")
}
}
func TestRepeaterRedactProxyCredentials(t *testing.T) {
for _, scheme := range []string{"http", "https", "socks5", "socks5h"} {
t.Run(scheme, func(t *testing.T) {
proxyErr := &url.Error{
Op: "proxyconnect", URL: scheme + "://proxy-user:[email protected]:1080",
Err: errors.New("connection refused"),
}
for _, err := range []error{proxyErr, fmt.Errorf("send request: %w", proxyErr)} {
got := repeaterRedactError(err)
if strings.Contains(got, "proxy-secret") || strings.Contains(got, "proxy-user") {
t.Fatalf("proxy credentials leaked: %q", got)
}
if got != "proxyconnect 127.0.0.1:1080: connection refused" {
t.Fatalf("unexpected redacted error: %q", got)
}
}
})
}
}
+18
View File
@@ -828,6 +828,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/exploration/findings/stats", s.findingStats)
mux.HandleFunc("GET /api/exploration/findings/export", s.findingsExport)
s.registerFindingTraffic(mux)
s.registerRepeater(mux)
mux.HandleFunc("GET /api/exploration/findings/{id}", s.getFinding)
mux.HandleFunc("GET /api/exploration/findings/{id}/lineage", s.findingLineage)
mux.HandleFunc("POST /api/exploration/findings/{id}/deepen", s.deepenFinding)
@@ -1544,6 +1545,8 @@ type createTaskReq struct {
PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"` // planner 心跳触发间隔(秒);0/省略=默认600(10min);下限=默认=600,低于自动抬到600
SeedFirstIntent *bool `json:"seed_first_intent,omitempty"` // 创建时直接下发一条种子意图(内容=描述+目标),让 worker 免等首轮 planner 直接开跑;省略/null=默认关闭,走标准先规划再执行。显式传 true 才开(CTF 常一 work 解决时可省掉开跑前的 planner 轮)。
CoverageEnabled *bool `json:"coverage_enabled,omitempty"` // 资产覆盖度功能;省略/null=默认开(true)。false=关闭覆盖度计算/展示/自动累积范围+隐藏 add_task_scope/list_untested_assets。company 关联不受影响。
Remark string `json:"remark,omitempty"` // 任务备注(最多2000字);空=无
WorkerCount int `json:"worker_count,omitempty"` // 本任务 Worker 数;0/省略=用全局默认;上限50
// InterceptRules 任务级资产拦截/允许规则(创建时录入,存 task_intercept_rules,不进全局表)。
InterceptRules []taskInterceptRuleReq `json:"intercept_rules,omitempty"`
}
@@ -1607,12 +1610,27 @@ func (s *Server) createTask(w http.ResponseWriter, r *http.Request) {
}
schedule = &input
}
remark, err := normalizeRemark(req.Remark)
if err != nil {
writeErr(w, 400, err.Error())
return
}
if req.WorkerCount < 0 {
writeErr(w, 400, "Worker 运行数量不能为负数")
return
}
if req.WorkerCount > db.MaxTaskWorkerCount {
writeErr(w, 400, fmt.Sprintf("Worker 运行数量最多 %d", db.MaxTaskWorkerCount))
return
}
t, err := s.m.CreateTaskWithOptions(req.Description, req.Goal, db.TaskCreateOptions{
Schedule: schedule,
Name: strings.TrimSpace(req.Name), CategoryID: req.CategoryID,
SourceTaskIDs: sourceIDs, CompanyIDs: req.CompanyIDs, LLMProfileIDs: req.LLMProfileIDs,
TimeoutSeconds: req.TimeoutSeconds, PlanHeartbeatSeconds: req.PlanHeartbeatSeconds,
CoverageEnabled: req.CoverageEnabled,
Remark: remark,
WorkerCount: req.WorkerCount,
InterceptRules: interceptRules,
})
if err != nil {
+39 -2
View File
@@ -22,8 +22,10 @@ type taskTemplateRequest struct {
Name *string `json:"name"`
Description *string `json:"description"`
Goal *string `json:"goal"`
Remark *string `json:"remark"`
CategoryID *int64 `json:"category_id"`
InterceptRules []taskInterceptRuleReq `json:"intercept_rules"`
WorkerCount *int `json:"worker_count"`
}
// decodeTaskTemplateRequest decodes the body into req and returns the set of
@@ -65,6 +67,7 @@ func validateTaskTemplateRequest(req taskTemplateRequest) error {
{name: "name", value: req.Name, limit: db.MaxTaskTemplateNameRunes},
{name: "description", value: req.Description, limit: db.MaxTaskTemplateTextRunes},
{name: "goal", value: req.Goal, limit: db.MaxTaskTemplateTextRunes},
{name: "remark", value: req.Remark, limit: db.MaxTaskRemarkRunes}, // 与任务备注 normalizeRemark 上限一致
}
for _, check := range checks {
if check.value == nil {
@@ -75,9 +78,20 @@ func validateTaskTemplateRequest(req taskTemplateRequest) error {
value = strings.Join(strings.Fields(value), " ")
}
if utf8.RuneCountInString(value) > check.limit {
if check.name == "remark" {
return fmt.Errorf("备注最多 %d 个字符", check.limit)
}
return fmt.Errorf("%s 最多 %d 个字符", check.name, check.limit)
}
}
if req.WorkerCount != nil {
if *req.WorkerCount < 0 {
return fmt.Errorf("Worker 运行数量不能为负数")
}
if *req.WorkerCount > db.MaxTaskWorkerCount {
return fmt.Errorf("Worker 运行数量最多 %d", db.MaxTaskWorkerCount)
}
}
return nil
}
@@ -125,12 +139,18 @@ func (s *Server) pgCreateTaskTemplate(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusBadRequest, "拦截/允许规则无效:"+err.Error())
return
}
workerCount := 0
if req.WorkerCount != nil {
workerCount = *req.WorkerCount
}
template, err := pg.CreateTaskTemplate(db.TaskTemplateInput{
Name: stringValue(req.Name),
Description: stringValue(req.Description),
Goal: stringValue(req.Goal),
Remark: stringValue(req.Remark),
CategoryID: req.CategoryID,
InterceptRules: rules,
WorkerCount: workerCount,
})
if err != nil {
writeTaskTemplateErr(w, err)
@@ -160,11 +180,21 @@ func (s *Server) pgUpdateTaskTemplate(w http.ResponseWriter, r *http.Request) {
}
_, catPresent := present["category_id"]
_, rulesPresent := present["intercept_rules"]
if req.Name == nil && req.Description == nil && req.Goal == nil && !catPresent && !rulesPresent {
writeErr(w, http.StatusBadRequest, "至少需要提供 name、description、goal、category_id 或 intercept_rules")
_, remarkPresent := present["remark"]
_, workerPresent := present["worker_count"]
if req.Name == nil && req.Description == nil && req.Goal == nil && !catPresent && !rulesPresent && !remarkPresent && !workerPresent {
writeErr(w, http.StatusBadRequest, "至少需要提供 name、description、goal、remark、category_id、intercept_rules 或 worker_count")
return
}
patch := db.TaskTemplatePatch{Name: req.Name, Description: req.Description, Goal: req.Goal}
if remarkPresent {
if req.Remark == nil {
empty := ""
patch.Remark = &empty
} else {
patch.Remark = req.Remark
}
}
if catPresent {
patch.SetCategoryID = true
patch.CategoryID = req.CategoryID
@@ -178,6 +208,13 @@ func (s *Server) pgUpdateTaskTemplate(w http.ResponseWriter, r *http.Request) {
patch.SetInterceptRules = true
patch.InterceptRules = rules
}
if workerPresent {
n := 0
if req.WorkerCount != nil {
n = *req.WorkerCount
}
patch.WorkerCount = &n
}
template, err := pg.PatchTaskTemplate(id, patch)
if err != nil {
writeTaskTemplateErr(w, err)
+1 -1
View File
@@ -5,9 +5,9 @@ import (
"encoding/json"
"testing"
actool "github.com/Autumn-27/norma/tool"
"github.com/Autumn-27/artex/agent"
"github.com/Autumn-27/artex/db"
actool "github.com/Autumn-27/norma/tool"
)
func names(tools []actool.CoreTool) map[string]actool.CoreTool {