mirror of
https://github.com/Buriburizaem0n/nezha_domains.git
synced 2026-09-19 09:40:12 +00:00
270 lines
10 KiB
Go
270 lines
10 KiB
Go
//go:build linux
|
|
|
|
package scenario
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
"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"
|
|
processharness "github.com/nezhahq/nezha/integration/agentcompat/internal/process"
|
|
)
|
|
|
|
type LegacyFMInput struct {
|
|
Paths contract.Paths
|
|
Fault contract.Fault
|
|
}
|
|
|
|
type LegacyFM struct{}
|
|
|
|
func (LegacyFM) Run(ctx context.Context, input LegacyFMInput) (result Result, runErr error) {
|
|
assertions := NewAssertionSet()
|
|
runID, err := newLegacyFMRunID()
|
|
if err != nil {
|
|
return Result{Name: "legacy-fm", Assertions: assertions.Results(), Error: err.Error()}, err
|
|
}
|
|
dashboardInstance, err := dashboard.Start(ctx, dashboard.StartConfig{SourceDir: input.Paths.NezhaSource().String(), ReceiptGate: true})
|
|
if err != nil {
|
|
return Result{Name: "legacy-fm", Assertions: assertions.Results(), Error: err.Error()}, err
|
|
}
|
|
result.CleanupOK = true
|
|
defer func() {
|
|
cleanupErr := dashboardInstance.Stop(context.Background())
|
|
result.CleanupOK = result.CleanupOK && cleanupErr == nil && dashboardInstance.CleanupReceipt().Passed
|
|
if cleanupErr != nil && runErr == nil {
|
|
runErr = cleanupErr
|
|
}
|
|
result.Passed = runErr == nil
|
|
result.Error = errorText(runErr)
|
|
}()
|
|
|
|
secret := dashboardInstance.AgentSecret()
|
|
agentInstance, err := agent.Start(ctx, agent.AgentStartConfig{
|
|
SourceDir: input.Paths.AgentSource().String(), Endpoint: dashboardInstance.Endpoint(),
|
|
Secret: secret, UUID: "00000000-0000-0000-0000-000000000115",
|
|
FMObserverRunID: runID,
|
|
})
|
|
if err != nil {
|
|
return Result{Name: "legacy-fm", Assertions: assertions.Results(), CleanupOK: true, Error: err.Error()}, err
|
|
}
|
|
defer func() {
|
|
cleanupErr := agentInstance.Stop(context.Background())
|
|
result.CleanupOK = result.CleanupOK && cleanupErr == nil && agentInstance.CleanupReceipt().Passed
|
|
if cleanupErr != nil && runErr == nil {
|
|
runErr = cleanupErr
|
|
}
|
|
}()
|
|
|
|
if input.Fault.String() == "agent-bad-secret" {
|
|
return finishLegacyFM(assertions, errors.New("fault injection agent-bad-secret"))
|
|
}
|
|
if err := dashboardInstance.WaitForReceiptAccepted(ctx); err != nil {
|
|
return finishLegacyFM(assertions, err)
|
|
}
|
|
if err := dashboardInstance.ReleaseReceipt(ctx); err != nil {
|
|
return finishLegacyFM(assertions, err)
|
|
}
|
|
if _, err := agentInstance.WaitReady(ctx, dashboardInstance); err != nil {
|
|
return finishLegacyFM(assertions, err)
|
|
}
|
|
serverID, err := findLegacyFMServerID(ctx, dashboardInstance, agentInstance.UUID())
|
|
if err != nil {
|
|
return finishLegacyFM(assertions, err)
|
|
}
|
|
|
|
root, err := fixture.NewAgentRoot(agentInstance.WorkspaceRoot(), "fm-files")
|
|
if err != nil {
|
|
return finishLegacyFM(assertions, err)
|
|
}
|
|
listPath, err := root.Path("legacy/list")
|
|
if err != nil {
|
|
return finishLegacyFM(assertions, err)
|
|
}
|
|
uploadPath, err := root.Path("legacy/upload.bin")
|
|
if err != nil {
|
|
return finishLegacyFM(assertions, err)
|
|
}
|
|
downloadPath, err := root.Path("legacy/download.bin")
|
|
if err != nil {
|
|
return finishLegacyFM(assertions, err)
|
|
}
|
|
payloadPattern := []byte("legacy-fm-exact-payload\x00\xff")
|
|
payload := bytes.Repeat(payloadPattern, (1<<20+257)/len(payloadPattern)+1)
|
|
payload = payload[:1<<20+257]
|
|
sentinel := []byte("outside-fm-root-sentinel")
|
|
workspaceRoot, err := os.OpenRoot(agentInstance.WorkspaceRoot())
|
|
if err != nil {
|
|
return finishLegacyFM(assertions, err)
|
|
}
|
|
defer workspaceRoot.Close()
|
|
sentinelNames := []string{"outside-fm-root-a.txt", "outside-fm-root-b.txt"}
|
|
for _, name := range sentinelNames {
|
|
if err := workspaceRoot.WriteFile(name, sentinel, 0o600); err != nil {
|
|
return finishLegacyFM(assertions, err)
|
|
}
|
|
}
|
|
if err := os.WriteFile(downloadPath.String(), payload, 0o600); err != nil {
|
|
return finishLegacyFM(assertions, err)
|
|
}
|
|
if err := os.Mkdir(listPath.String(), 0o700); err != nil {
|
|
return finishLegacyFM(assertions, err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(listPath.String(), "entry.txt"), []byte("entry"), 0o600); err != nil {
|
|
return finishLegacyFM(assertions, err)
|
|
}
|
|
pathRejectionDispatches, err := probeLegacyFMRejectedPathDispatches(ctx, root)
|
|
assertions.Record("rejected paths dispatch zero FM frames", err == nil && pathRejectionDispatches == 0, fmt.Sprintf("path_rejections_dispatched: %d", pathRejectionDispatches))
|
|
if err != nil || pathRejectionDispatches != 0 {
|
|
return finishLegacyFM(assertions, errors.Join(err, errors.New("rejected FM path dispatched a frame")))
|
|
}
|
|
baselineSample, err := processharness.SampleProcess(agentInstance.PID())
|
|
if err != nil {
|
|
return finishLegacyFM(assertions, err)
|
|
}
|
|
|
|
admin := dashboardInstance.Clients().REST
|
|
session, err := createLegacyFMSession(ctx, admin, serverID)
|
|
if err != nil {
|
|
return finishLegacyFM(assertions, err)
|
|
}
|
|
ws, err := dashboardInstance.Clients().WebSocket.DialWebSocket(ctx, "/api/v1/ws/file/"+session)
|
|
if err != nil {
|
|
return finishLegacyFM(assertions, err)
|
|
}
|
|
defer ws.Close()
|
|
|
|
dispatcher := legacyFMCommandDispatcher{writer: ws, root: root}
|
|
if err := dispatcher.list(ctx, "legacy/list"); err != nil {
|
|
return finishLegacyFM(assertions, err)
|
|
}
|
|
listFrame, err := readBinaryFrame(ctx, ws)
|
|
if err != nil {
|
|
return finishLegacyFM(assertions, err)
|
|
}
|
|
parsedList, err := parseLegacyFMList(listFrame)
|
|
assertions.Record("list uses NZFN and exact entry", err == nil && parsedList.Path == listPath.String() && len(parsedList.Entries) == 1 && parsedList.Entries[0].Name == "entry.txt" && !parsedList.Entries[0].Dir, errorText(err))
|
|
if err != nil {
|
|
return finishLegacyFM(assertions, err)
|
|
}
|
|
|
|
if err := dispatcher.upload(ctx, "legacy/upload.bin", uint64(len(payload))); err != nil {
|
|
return finishLegacyFM(assertions, err)
|
|
}
|
|
if err := ws.WriteFrame(ctx, client.Frame{Type: client.FrameBinary, Payload: payload[:1<<20]}); err != nil {
|
|
return finishLegacyFM(assertions, err)
|
|
}
|
|
if err := ws.WriteFrame(ctx, client.Frame{Type: client.FrameBinary, Payload: payload[1<<20:]}); err != nil {
|
|
return finishLegacyFM(assertions, err)
|
|
}
|
|
completion, err := readBinaryFrame(ctx, ws)
|
|
if err != nil {
|
|
return finishLegacyFM(assertions, err)
|
|
}
|
|
if err := requireLegacyFMMarker(completion, "NZUP"); err != nil {
|
|
return finishLegacyFM(assertions, err)
|
|
}
|
|
written, err := os.ReadFile(uploadPath.String())
|
|
assertions.Record("upload returns NZUP and exact bytes", err == nil && bytes.Equal(written, payload), errorText(err))
|
|
if err != nil || !bytes.Equal(written, payload) {
|
|
return finishLegacyFM(assertions, errors.New("uploaded content mismatch"))
|
|
}
|
|
|
|
if err := dispatcher.download(ctx, "legacy/download.bin"); err != nil {
|
|
return finishLegacyFM(assertions, err)
|
|
}
|
|
producerAwaiter := newLegacyFMProducerAwaiter(agentInstance.FMProducerObserver(), runID, agentInstance.UUID(), session)
|
|
activeProducerSample, err := producerAwaiter.await(ctx, "active")
|
|
if err != nil {
|
|
return finishLegacyFM(assertions, err)
|
|
}
|
|
header, err := readBinaryFrame(ctx, ws)
|
|
if err != nil {
|
|
return finishLegacyFM(assertions, err)
|
|
}
|
|
downloadHeader, err := parseLegacyFMDownload(header)
|
|
if err != nil {
|
|
return finishLegacyFM(assertions, err)
|
|
}
|
|
if downloadHeader.Size != uint64(len(payload)) {
|
|
return finishLegacyFM(assertions, errors.New("download header size mismatch"))
|
|
}
|
|
downloaded, downloadFrameCount, err := readLegacyFMDownload(ctx, ws, downloadHeader.Size)
|
|
if err != nil {
|
|
return finishLegacyFM(assertions, err)
|
|
}
|
|
digest := sha256.Sum256(downloaded)
|
|
wantDigest := sha256.Sum256(payload)
|
|
assertions.Record("download returns NZTD across frames with exact hash/content", downloadFrameCount >= 2 && bytes.Equal(downloaded, payload) && digest == wantDigest, fmt.Sprintf("size=%d frames=%d", downloadHeader.Size, downloadFrameCount))
|
|
if downloadFrameCount < 2 || !bytes.Equal(downloaded, payload) {
|
|
return finishLegacyFM(assertions, errors.New("download framing or content mismatch"))
|
|
}
|
|
if err := dispatcher.download(ctx, "legacy/missing.bin"); err != nil {
|
|
return finishLegacyFM(assertions, err)
|
|
}
|
|
errorFrame, err := readBinaryFrame(ctx, ws)
|
|
if err != nil {
|
|
return finishLegacyFM(assertions, err)
|
|
}
|
|
_, nerr := parseLegacyFMDownload(errorFrame)
|
|
assertions.Record("missing download returns NERR", errors.Is(nerr, errLegacyFMRemote), errorText(nerr))
|
|
|
|
scopeChecksErr := verifyLegacyFMMissingScopes(ctx, dashboardInstance, serverID, session)
|
|
assertions.Record("each missing scope rejects FM creation and WebSocket", scopeChecksErr == nil, errorText(scopeChecksErr))
|
|
if scopeChecksErr != nil {
|
|
return finishLegacyFM(assertions, scopeChecksErr)
|
|
}
|
|
|
|
foreign, removeForeignUser, err := createForeignLegacyFMClient(ctx, dashboardInstance)
|
|
if err != nil {
|
|
return finishLegacyFM(assertions, err)
|
|
}
|
|
// Temporary users are scenario resources, so deletion failures must fail cleanup evidence.
|
|
defer func() {
|
|
cleanupErr := removeForeignUser()
|
|
result.CleanupOK = result.CleanupOK && cleanupErr == nil
|
|
if cleanupErr != nil && runErr == nil {
|
|
runErr = cleanupErr
|
|
}
|
|
}()
|
|
_, hijackErr := foreign.DialWebSocket(ctx, "/api/v1/ws/file/"+session)
|
|
assertions.Record("foreign PAT cannot hijack FM session", isLegacyFMSessionRejected(hijackErr), errorText(hijackErr))
|
|
|
|
if err := ws.Close(); err != nil {
|
|
return finishLegacyFM(assertions, err)
|
|
}
|
|
closedProducerSample, err := producerAwaiter.await(ctx, "closed")
|
|
if err != nil {
|
|
return finishLegacyFM(assertions, err)
|
|
}
|
|
residueProbe := legacyFMResidueProbe{
|
|
assertions: assertions, agentPID: agentInstance.PID(),
|
|
session: session, root: root, baseline: baselineSample, sessionClient: dashboardInstance.Clients().WebSocket,
|
|
producer: producerAwaiter.observation(activeProducerSample, closedProducerSample),
|
|
}
|
|
if err := residueProbe.run(ctx); err != nil {
|
|
return finishLegacyFM(assertions, err)
|
|
}
|
|
|
|
sentinelErr := verifyLegacyFMSentinels(workspaceRoot, sentinelNames, sentinel)
|
|
assertions.Record("outside-root sentinels remain unchanged", sentinelErr == nil, errorText(sentinelErr))
|
|
if sentinelErr != nil {
|
|
return finishLegacyFM(assertions, sentinelErr)
|
|
}
|
|
filesystem := newMCPFilesystemClient(dashboardInstance.Clients().MCP, serverID, root)
|
|
fixtureCleanupErr := cleanupLegacyFMFixtures(ctx, filesystem)
|
|
assertions.Record("MCP cleanup removes FM fixture residue", fixtureCleanupErr == nil, errorText(fixtureCleanupErr))
|
|
if fixtureCleanupErr != nil {
|
|
return finishLegacyFM(assertions, fixtureCleanupErr)
|
|
}
|
|
return finishLegacyFM(assertions, nil)
|
|
}
|