mirror of
https://github.com/Buriburizaem0n/nezha_domains.git
synced 2026-09-19 17:50:12 +00:00
212 lines
10 KiB
Go
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")
|
|
}
|