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

210 lines
9.0 KiB
Go

//go:build linux
package scenario
import (
"context"
"encoding/json"
"errors"
"fmt"
"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/evidence"
processharness "github.com/nezhahq/nezha/integration/agentcompat/internal/process"
)
const (
terminalMarker = "compat-terminal"
terminalCommand = "printf 'compat-size='; stty size; printf 'compat-terminal\\n'; exit\n"
terminalShutdownContract = 2 * time.Second
terminalShutdownHarnessMargin = 500 * time.Millisecond
terminalAttachPATScope = "nezha:server:exec"
)
type TerminalInput struct {
Paths contract.Paths
Fault contract.Fault
}
type Terminal struct{}
type terminalCreateRequest struct {
Protocol string `json:"protocol"`
ServerID uint64 `json:"server_id"`
}
type terminalCreateResponse struct {
SessionID string `json:"session_id"`
ServerID uint64 `json:"server_id"`
}
type terminalUserRequest struct {
Role uint8 `json:"role"`
Username string `json:"username"`
Password string `json:"password"`
}
type terminalPATRequest struct {
Name string `json:"name"`
Scopes []string `json:"scopes"`
ServerIDs []uint64 `json:"server_ids,omitempty"`
}
type terminalPATResponse struct {
Token string `json:"token"`
}
func (Terminal) Run(ctx context.Context, input TerminalInput) (result Result, runErr error) {
assertions := NewAssertionSet()
dashboardInstance, err := dashboard.Start(ctx, dashboard.StartConfig{SourceDir: input.Paths.NezhaSource().String(), ReceiptGate: true})
if err != nil {
return terminalFinish(assertions, err)
}
result.CleanupOK = true
defer func() {
cleanupContext, cancel := context.WithTimeout(context.WithoutCancel(ctx), 15*time.Second)
defer cancel()
cleanupErr := dashboardInstance.Stop(cleanupContext)
receipt := dashboardInstance.CleanupReceipt()
cleanupPassed := cleanupErr == nil && receipt.Passed && !receipt.Forced
result.CleanupOK = result.CleanupOK && cleanupPassed
if !cleanupPassed {
cleanupErr = errors.Join(cleanupErr, errors.New("dashboard cleanup receipt failed"))
result, runErr = terminalFinish(assertions, errors.Join(runErr, cleanupErr))
result.CleanupOK = false
}
}()
agentInstance, err := agent.Start(ctx, agent.AgentStartConfig{SourceDir: input.Paths.AgentSource().String(), Endpoint: dashboardInstance.Endpoint(), Secret: dashboardInstance.AgentSecret(), UUID: "00000000-0000-0000-0000-000000000113"})
if err != nil {
return terminalFinish(assertions, err)
}
defer func() {
cleanupContext, cancel := context.WithTimeout(context.WithoutCancel(ctx), 15*time.Second)
defer cancel()
cleanupErr := agentInstance.Stop(cleanupContext)
receipt := agentInstance.CleanupReceipt()
cleanupPassed := cleanupErr == nil && receipt.Passed && !receipt.Forced
result.CleanupOK = result.CleanupOK && cleanupPassed
if !cleanupPassed {
cleanupErr = errors.Join(cleanupErr, errors.New("agent cleanup receipt failed"))
result, runErr = terminalFinish(assertions, errors.Join(runErr, cleanupErr))
result.CleanupOK = false
}
}()
if err := dashboardInstance.WaitForReceiptAccepted(ctx); err != nil {
return terminalFinish(assertions, err)
}
if err := dashboardInstance.ReleaseReceipt(ctx); err != nil {
return terminalFinish(assertions, err)
}
readiness, err := agentInstance.WaitReady(ctx, dashboardInstance)
if err != nil {
return terminalFinish(assertions, err)
}
serverID, err := terminalServerID(ctx, dashboardInstance.Clients().MCP, readiness.UUID)
if err != nil {
return terminalFinish(assertions, err)
}
baseline, err := processharness.SampleProcess(agentInstance.PID())
if err != nil {
return terminalFinish(assertions, err)
}
deniedClient, err := createTerminalPATClient(ctx, dashboardInstance, "terminal-denied", []string{"nezha:server:read"}, []uint64{serverID})
if err != nil {
return terminalFinish(assertions, err)
}
_, deniedErr := client.DoREST[terminalCreateRequest, terminalCreateResponse](ctx, deniedClient, client.RESTRequest[terminalCreateRequest]{Method: http.MethodPost, Path: "/api/v1/terminal", Body: &terminalCreateRequest{Protocol: "grpc", ServerID: serverID}})
assertions.Record("terminal denied PAT lacks exec scope", isForbidden(deniedErr), errorText(deniedErr))
terminal, err := client.DoREST[terminalCreateRequest, terminalCreateResponse](ctx, dashboardInstance.Clients().REST, client.RESTRequest[terminalCreateRequest]{Method: http.MethodPost, Path: "/api/v1/terminal", Body: &terminalCreateRequest{Protocol: "grpc", ServerID: serverID}})
if err != nil || terminal.SessionID == "" || terminal.ServerID != serverID {
return terminalFinish(assertions, errors.Join(err, errors.New("terminal creation returned incomplete session")))
}
foreignClient, cleanupForeignUser, err := createForeignTerminalPATClient(ctx, dashboardInstance)
if err != nil {
return terminalFinish(assertions, err)
}
hijackConnection, hijackErr := foreignClient.DialWebSocket(ctx, "/api/v1/ws/terminal/"+terminal.SessionID)
if hijackConnection != nil {
_ = hijackConnection.Close()
}
assertions.Record("foreign scoped PAT cannot hijack terminal session", isWebSocketDenied(hijackErr), fmt.Sprintf("scopes=[%s] denial=%s", terminalAttachPATScope, errorText(hijackErr)))
if err := cleanupForeignUser(); err != nil {
return terminalFinish(assertions, err)
}
connection, err := dashboardInstance.Clients().WebSocket.DialWebSocket(ctx, "/api/v1/ws/terminal/"+terminal.SessionID)
if err != nil {
return terminalFinish(assertions, err)
}
defer connection.Close()
if err := connection.WriteFrame(ctx, mustTerminalResizeFrame(132, 43)); err != nil {
return terminalFinish(assertions, err)
}
initialFrame, err := connection.ReadFrame(ctx)
if err != nil {
return terminalFinish(assertions, err)
}
active, err := processharness.SampleProcess(agentInstance.PID())
if err != nil {
return terminalFinish(assertions, err)
}
// Start the contract clock before sending exit so transport time is included.
exitSentAt := time.Now()
output, err := executeTerminalExit(ctx, terminalExitInput{InitialOutput: initialFrame.Payload, ExitSentAt: exitSentAt, Now: time.Now}, connection)
assertions.Record("terminal resize marker and bounded shell close observed", err == nil && output.MarkerObserved && output.SizeObserved && output.Rows == 43 && output.Cols == 132 && output.StreamClosed && terminalCloseWithinContract(output.CloseElapsed), terminalOutputDetails(output, err))
if err != nil {
return terminalFinish(assertions, err)
}
residue, err := processharness.SampleProcess(agentInstance.PID())
residueClean := err == nil && active.DescendantCount > baseline.DescendantCount && residue.DescendantCount == baseline.DescendantCount && residue.TCPListenerCount == baseline.TCPListenerCount && residue.TCP6ListenerCount == baseline.TCP6ListenerCount
assertions.Record("agent PTY child and listener residue cleared", residueClean, fmt.Sprintf("active_children=%d baseline_children=%d residue_children=%d baseline_listeners=%d/%d residue_listeners=%d/%d error=%s", active.DescendantCount, baseline.DescendantCount, residue.DescendantCount, baseline.TCPListenerCount, baseline.TCP6ListenerCount, residue.TCPListenerCount, residue.TCP6ListenerCount, errorText(err)))
_, staleErr := dashboardInstance.Clients().WebSocket.DialWebSocket(ctx, "/api/v1/ws/terminal/"+terminal.SessionID)
assertions.Record("terminal IOStream removed after shell exit", isWebSocketDenied(staleErr), errorText(staleErr))
_, invalidErr := dashboardInstance.Clients().WebSocket.DialWebSocket(ctx, "/api/v1/ws/terminal/invalid-session")
assertions.Record("invalid terminal session rejected", webSocketFailureContains(invalidErr, "permission denied"), errorText(invalidErr))
return terminalFinish(assertions, nil)
}
func terminalResizeFrame(cols, rows uint32) (client.Frame, error) {
payload, err := json.Marshal(struct {
Cols uint32
Rows uint32
}{Cols: cols, Rows: rows})
if err != nil {
return client.Frame{}, err
}
return client.Frame{Type: client.FrameBinary, Payload: append([]byte{1}, payload...)}, nil
}
func mustTerminalResizeFrame(cols, rows uint32) client.Frame {
frame, _ := terminalResizeFrame(cols, rows)
return frame
}
func terminalFinish(assertions *AssertionSet, runErr error) (Result, error) {
results := assertions.Results()
failedAssertion := false
for _, assertion := range results {
if !assertion.Passed && runErr == nil {
runErr = fmt.Errorf("%s: %s", assertion.Name, assertion.Details)
}
failedAssertion = failedAssertion || !assertion.Passed
}
if runErr != nil && !failedAssertion {
results = append(results, Assertion{Name: "terminal scenario completed", Passed: false, Details: evidence.Redact(runErr.Error())})
}
result := Result{Name: "terminal", Passed: runErr == nil, Assertions: results, CleanupOK: true}
if runErr != nil {
result.Error = evidence.Redact(runErr.Error())
}
return result, runErr
}