mirror of
https://github.com/Buriburizaem0n/nezha_domains.git
synced 2026-09-19 09:40:12 +00:00
210 lines
9.0 KiB
Go
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
|
|
}
|