Files
2026-07-20 04:51:20 +00:00

212 lines
10 KiB
Go

//go:build linux
package scenario
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"time"
"github.com/nezhahq/nezha/integration/agentcompat/internal/agent"
"github.com/nezhahq/nezha/integration/agentcompat/internal/client"
"github.com/nezhahq/nezha/integration/agentcompat/internal/contract"
"github.com/nezhahq/nezha/integration/agentcompat/internal/dashboard"
"github.com/nezhahq/nezha/integration/agentcompat/internal/fixture"
)
type NATInput struct {
Paths contract.Paths
Fault contract.Fault
}
type NAT struct{}
type natForm struct {
Name string `json:"name"`
Enabled bool `json:"enabled"`
ServerID uint64 `json:"server_id"`
Host string `json:"host"`
Domain string `json:"domain"`
}
type natServerListRequest struct {
OnlineOnly bool `json:"online_only"`
}
type natServerListResponse struct {
Servers []struct {
ID uint64 `json:"id"`
UUID string `json:"uuid"`
Online bool `json:"online"`
} `json:"servers"`
}
type natIDResponse uint64
const natTestDomain = "agentcompat-nat.invalid"
const natHalfCloseTestDomain = "half-close.agentcompat-nat.invalid"
func (NAT) Run(ctx context.Context, input NATInput) (result Result, runErr error) {
assertions := NewAssertionSet()
dashboardInstance, err := dashboard.Start(ctx, dashboard.StartConfig{SourceDir: input.Paths.NezhaSource().String(), ReceiptGate: true})
if err != nil {
return Result{Name: "nat", Assertions: assertions.Results(), Error: errorText(err)}, err
}
var ordinaryBackend *fixture.NATEchoBackend
var halfCloseBackend *fixture.NATEchoBackend
var agentInstance *agent.Agent
defer func() {
cleanupContext, cancelCleanup := context.WithTimeout(context.Background(), 30*time.Second)
defer cancelCleanup()
var cleanupErr error
if agentInstance != nil {
cleanupErr = errors.Join(cleanupErr, agentInstance.Stop(cleanupContext))
}
if ordinaryBackend != nil {
cleanupErr = errors.Join(cleanupErr, ordinaryBackend.Close())
}
if halfCloseBackend != nil {
cleanupErr = errors.Join(cleanupErr, halfCloseBackend.Close())
}
cleanupErr = errors.Join(cleanupErr, dashboardInstance.Stop(cleanupContext))
agentCleanupPassed := agentInstance == nil || agentInstance.CleanupReceipt().Passed
result.CleanupOK = cleanupErr == nil && agentCleanupPassed && dashboardInstance.CleanupReceipt().Passed
if cleanupErr != nil && runErr == nil {
runErr = cleanupErr
result.Passed = false
result.Error = errorText(cleanupErr)
}
}()
ordinaryBackend, err = fixture.StartNATEchoBackend()
if err != nil {
return finishNAT(assertions, err)
}
halfCloseBackend, err = fixture.StartNATResponseHalfCloseEchoBackend()
if err != nil {
return finishNAT(assertions, err)
}
agentInstance, err = agent.Start(ctx, agent.AgentStartConfig{SourceDir: input.Paths.AgentSource().String(), Endpoint: dashboardInstance.Endpoint(), Secret: dashboardInstance.AgentSecret(), UUID: "00000000-0000-0000-0000-000000000114"})
if err != nil {
return finishNAT(assertions, err)
}
if err := dashboardInstance.WaitForReceiptAccepted(ctx); err != nil {
return finishNAT(assertions, err)
}
if err := dashboardInstance.ReleaseReceipt(ctx); err != nil {
return finishNAT(assertions, err)
}
if _, err := agentInstance.WaitReady(ctx, dashboardInstance); err != nil {
return finishNAT(assertions, err)
}
serverID, err := natPrimaryServerID(ctx, dashboardInstance, agentInstance.UUID())
if err != nil {
return finishNAT(assertions, err)
}
admin := dashboardInstance.Clients().REST
created, err := client.DoREST[natForm, natIDResponse](ctx, admin, client.RESTRequest[natForm]{Method: http.MethodPost, Path: "/api/v1/nat", Body: &natForm{Name: "agentcompat-nat", Enabled: true, ServerID: serverID, Host: ordinaryBackend.Address(), Domain: natTestDomain}})
if err != nil {
return finishNAT(assertions, err)
}
profileID := uint64(created)
ordinaryRequest := natHTTPRequestSpec{Endpoint: dashboardInstance.Endpoint(), Host: natTestDomain, Method: "PATCH", Path: "/nat?case=ordinary", Body: "ordinary"}
response, responseRecord, err := natHTTPRoundTrip(ctx, ordinaryRequest)
connectionErr := ordinaryBackend.WaitConnection(ctx)
if err == nil {
err = connectionErr
}
record, recordErr := ordinaryBackend.WaitRequest(ctx)
if err == nil {
err = recordErr
}
assertions.Record("enabled profile traverses exact HTTP request and response", err == nil && response.Status == http.StatusOK && response.PeerWriteClosed && response.LegacyCloseMarker == io.EOF.Error() && response.Body == natExpectedBody(ordinaryRequest) && natExactRequestObserved(ordinaryRequest, responseRecord, record), errorText(err))
if err != nil {
return finishNAT(assertions, err)
}
halfCloseProfile, err := client.DoREST[natForm, natIDResponse](ctx, admin, client.RESTRequest[natForm]{Method: http.MethodPost, Path: "/api/v1/nat", Body: &natForm{Name: "agentcompat-nat-half-close", Enabled: true, ServerID: serverID, Host: halfCloseBackend.Address(), Domain: natHalfCloseTestDomain}})
if err != nil {
return finishNAT(assertions, err)
}
// The fixture half-closes its response after writing it. Requiring a client
// request half-close would deadlock HTTP handling because Dashboard keeps the
// request side open while waiting for the backend response.
halfCloseRequest := natHTTPRequestSpec{Endpoint: dashboardInstance.Endpoint(), Host: natHalfCloseTestDomain, Method: "POST", Path: "/nat?case=half-close", Body: "half-closed"}
halfCloseResponse, halfCloseResponseRecord, err := natHTTPRoundTrip(ctx, halfCloseRequest)
halfCloseConnectionErr := halfCloseBackend.WaitConnection(ctx)
if err == nil {
err = halfCloseConnectionErr
}
halfCloseRecord, halfCloseRecordErr := halfCloseBackend.WaitRequest(ctx)
if err == nil {
err = halfCloseRecordErr
}
assertions.Record("backend half-close traverses response and exact request", err == nil && halfCloseResponse.Status == http.StatusOK && halfCloseResponse.PeerWriteClosed && halfCloseResponse.LegacyCloseMarker == io.EOF.Error() && halfCloseResponse.Body == natExpectedBody(halfCloseRequest) && natExactRequestObserved(halfCloseRequest, halfCloseResponseRecord, halfCloseRecord) && halfCloseRecord.ResponseHalfClosed, errorText(err))
if err != nil {
return finishNAT(assertions, err)
}
if _, err := client.DoREST[[]uint64, struct{}](ctx, admin, client.RESTRequest[[]uint64]{Method: http.MethodPost, Path: "/api/v1/batch-delete/nat", Body: &[]uint64{uint64(halfCloseProfile)}}); err != nil {
return finishNAT(assertions, err)
}
limited, err := createScopedClient(ctx, dashboardInstance, []string{"nezha:server:read"})
if err != nil {
return finishNAT(assertions, err)
}
_, unauthorizedErr := client.DoREST[natForm, natIDResponse](ctx, limited, client.RESTRequest[natForm]{Method: http.MethodPost, Path: "/api/v1/nat", Body: &natForm{Name: "unauthorized", Enabled: true, ServerID: serverID, Host: ordinaryBackend.Address(), Domain: "unauthorized." + natTestDomain}})
assertions.Record("unauthorized NAT profile is rejected", isForbidden(unauthorizedErr), errorText(unauthorizedErr))
disabled, err := client.DoREST[natForm, natIDResponse](ctx, admin, client.RESTRequest[natForm]{Method: http.MethodPost, Path: "/api/v1/nat", Body: &natForm{Name: "disabled", Enabled: false, ServerID: serverID, Host: ordinaryBackend.Address(), Domain: "disabled." + natTestDomain}})
if err != nil {
return finishNAT(assertions, err)
}
disabledResponse, err := natHTTPStatus(ctx, dashboardInstance.Endpoint(), "disabled."+natTestDomain)
disabledRouteErr := natAssertNoBackendConnection(ctx, ordinaryBackend)
assertions.Record("disabled NAT profile is blocked", err == nil && disabledRouteErr == nil && disabledResponse.Status == http.StatusForbidden, errorText(errors.Join(err, disabledRouteErr)))
if _, err := client.DoREST[[]uint64, struct{}](ctx, admin, client.RESTRequest[[]uint64]{Method: http.MethodPost, Path: "/api/v1/batch-delete/nat", Body: &[]uint64{uint64(disabled)}}); err != nil {
return finishNAT(assertions, err)
}
if _, err := client.DoREST[[]uint64, struct{}](ctx, admin, client.RESTRequest[[]uint64]{Method: http.MethodPost, Path: "/api/v1/batch-delete/nat", Body: &[]uint64{profileID}}); err != nil {
return finishNAT(assertions, err)
}
deletedResponse, err := natHTTPStatus(ctx, dashboardInstance.Endpoint(), natTestDomain)
deletedRouteErr := natAssertNoBackendConnection(ctx, ordinaryBackend)
assertions.Record("deleted NAT profile no longer routes", err == nil && natDeletedRouteObserved(deletedResponse, deletedRouteErr, natHTTPRequestSpec{Host: natTestDomain, Method: http.MethodGet, Path: "/"}), errorText(errors.Join(err, deletedRouteErr)))
return finishNAT(assertions, nil)
}
func natExactRequestObserved(request natHTTPRequestSpec, responseRecord, backendRecord fixture.NATEchoRecord) bool {
for _, record := range []fixture.NATEchoRecord{responseRecord, backendRecord} {
if record.Method != request.Method || record.Path != request.Path || record.Host != request.Host || record.HeaderValue != natEchoHeaderValue || string(record.Body) != request.Body {
return false
}
}
return true
}
func natDeletedRouteObserved(response natRawResponse, routeErr error, request natHTTPRequestSpec) bool {
return routeErr == nil && response.Status == http.StatusOK && response.Body != "" && response.Body != natExpectedBody(request)
}
func finishNAT(assertions *AssertionSet, runErr error) (Result, error) {
for _, assertion := range assertions.Results() {
if !assertion.Passed && runErr == nil {
runErr = fmt.Errorf("%s: %s", assertion.Name, assertion.Details)
}
}
result := Result{Name: "nat", Passed: runErr == nil, Assertions: assertions.Results(), Error: errorText(runErr), CleanupOK: false}
return result, runErr
}
func natPrimaryServerID(ctx context.Context, dashboardInstance *dashboard.Dashboard, uuid string) (uint64, error) {
response, err := client.CallTool[natServerListRequest, natServerListResponse](ctx, dashboardInstance.Clients().MCP, client.ToolCall[natServerListRequest]{Name: "server.list", Arguments: natServerListRequest{OnlineOnly: true}})
if err != nil {
return 0, err
}
for _, server := range response.StructuredContent.Servers {
if server.UUID == uuid && server.Online {
return server.ID, nil
}
}
return 0, errors.New("primary agent is not online")
}