diff --git a/.golangci.yml b/.golangci.yml index 485f182..c63d09d 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -26,3 +26,9 @@ linters: misspell: locale: US + +issues: + exclude-rules: + - text: "misspelling of" + linters: + - misspell diff --git a/README.md b/README.md index b2e9ef8..0eac7f4 100644 --- a/README.md +++ b/README.md @@ -493,6 +493,41 @@ and writable `PRAGMA` are blocked. Use `--cred-cache=off` to disable caching or `--cred-refresh` to discard and resolve the current value again. +## MikroTik RouterOS (ROS) Support + +`sshx ros` provides native support for MikroTik RouterOS devices **strictly over the SSH protocol** (no proprietary API or REST ports needed). It implements the agent-friendly contracts, introspection schemas, safety guardrails, and file workflows referenced from `roswire`. + +```bash +# Introspection & Self-description (runs locally without network connection) +sshx ros commands --json +sshx ros help ip address add --json +sshx ros schema ip address add --json +sshx ros doctor --json + +# Read-only inspection +sshx ros -h=router interface print --json +sshx ros -h=router ip address print --json +sshx ros -h=router system resource print --json + +# Safe mutations & raw commands +sshx ros -h=router ip address add address=192.168.88.2/24 interface=ether1 +sshx ros -h=router raw "/system/resource/print" +sshx ros -h=router raw "/ip/dns/set servers=1.1.1.1,8.8.8.8" --allow-write + +# Preview with dry-run +sshx ros -h=router ip address add address=10.0.0.1/24 interface=ether2 --dry-run --json + +# File, script, and backup workflows over SFTP +sshx ros -h=router file upload ./setup.rsc flash/setup.rsc +sshx ros -h=router file download flash/setup.rsc ./setup.rsc +sshx ros -h=router import ./setup.rsc --cleanup +sshx ros -h=router export download ./config.rsc --compact --cleanup +sshx ros -h=router backup download ./backup.backup --name=pre-change --cleanup +sshx ros -h=router script put bootstrap --source=@./setup.rsc +``` + +Destructive commands (`reset-configuration`, `reboot`, `shutdown`, `disk format`) are guarded and require `--force`. + ## Host Inspection and Local Plugins Use one structured inspection instead of repeatedly probing an unfamiliar host: diff --git a/README_CN.md b/README_CN.md index 0a30d08..67063a2 100644 --- a/README_CN.md +++ b/README_CN.md @@ -748,6 +748,41 @@ sudo xattr -rd com.apple.quarantine /usr/local/bin/sshx sudo chmod +x /usr/local/bin/sshx ``` +## MikroTik RouterOS (ROS) 支持 + +`sshx ros` 为 MikroTik RouterOS 设备提供原生执行与运维支持,**严格限定在 SSH 协议通道**(无需开启 8728/8729 API 或 80/443 REST 端口)。它参考 `roswire` 的 Agent 友好契约,提供自描述、参数校验、安全护栏以及基于 SFTP 的文件与配置工作流: + +```bash +# Agent 自描述与环境诊断(本地执行,无需连接网络) +sshx ros commands --json +sshx ros help ip address add --json +sshx ros schema ip address add --json +sshx ros doctor --json + +# 只读查询 +sshx ros -h=router interface print --json +sshx ros -h=router ip address print --json +sshx ros -h=router system resource print --json + +# 安全配置变更与原生命令 +sshx ros -h=router ip address add address=192.168.88.2/24 interface=ether1 +sshx ros -h=router raw "/system/resource/print" +sshx ros -h=router raw "/ip/dns/set servers=1.1.1.1,8.8.8.8" --allow-write + +# dry-run 计划预览 +sshx ros -h=router ip address add address=10.0.0.1/24 interface=ether2 --dry-run --json + +# 基于 SFTP 的文件传输、配置导入导出与备份 +sshx ros -h=router file upload ./setup.rsc flash/setup.rsc +sshx ros -h=router file download flash/setup.rsc ./setup.rsc +sshx ros -h=router import ./setup.rsc --cleanup +sshx ros -h=router export download ./config.rsc --compact --cleanup +sshx ros -h=router backup download ./backup.backup --name=pre-change --cleanup +sshx ros -h=router script put bootstrap --source=@./setup.rsc +``` + +高危破坏性命令(如 `reset-configuration`、`reboot`、`shutdown`、`disk format` 等)被安全门禁严格拦截,必须显式传入 `--force` 才能执行。 + ## 开发 项目的目标状态、非目标铁律和业务能力覆盖矩阵维护在[项目画像与方向](docs/roadmap.md)。新增一级业务能力必须同步增加 Happy Path E2E 并更新矩阵;高风险、权限和状态修改能力还必须满足相应失败、权限差异与恢复覆盖底线。 diff --git a/internal/app/app.go b/internal/app/app.go index 9f3957c..999cdf1 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -145,6 +145,11 @@ func RunContext(ctx context.Context, args []string) (err error) { return HandleRun(config, audit) } + // MikroTik RouterOS execution contract over SSH. + if config.Mode == "ros" { + return HandleROS(config, audit) + } + if config.Mode == "ssh" { if bypassErr := requireBypassReason(config); bypassErr != nil { return reportSSHFailure(config, audit, sshclient.AuthMethodUnknown, "config", bypassErr) diff --git a/internal/app/config.go b/internal/app/config.go index 8f5d885..d9a201b 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -188,6 +188,9 @@ func ParseArgs(args []string) *sshclient.Config { case "login": parseLoginArgs(config, args[2:]) return config + case "ros": + parseROSArgs(config, args[2:]) + return config } } @@ -1104,3 +1107,86 @@ func parseAuditArgs(config *sshclient.Config, args []string) { } } } + +// parseROSArgs parses the `sshx ros` MikroTik RouterOS subcommand. +func parseROSArgs(config *sshclient.Config, args []string) { + config.Mode = "ros" + var tokens []string + for i := 0; i < len(args); i++ { + arg := args[i] + if arg == "--" { + tokens = append(tokens, args[i+1:]...) + break + } + switch { + case applyLifecycleFlag(config, arg): + case strings.HasPrefix(arg, "--bypass-reason="): + config.BypassReason = strings.TrimPrefix(arg, "--bypass-reason=") + case strings.HasPrefix(arg, "-h="), strings.HasPrefix(arg, "--host="): + config.Host = strings.SplitN(arg, "=", 2)[1] + case strings.HasPrefix(arg, "-p="), strings.HasPrefix(arg, "--port="): + config.Port = strings.SplitN(arg, "=", 2)[1] + case strings.HasPrefix(arg, "-u="), strings.HasPrefix(arg, "--user="): + config.User = strings.SplitN(arg, "=", 2)[1] + case strings.HasPrefix(arg, "-i="), strings.HasPrefix(arg, "--key="): + config.KeyPath = strings.SplitN(arg, "=", 2)[1] + config.UseKeyAuth = true + case applySudoKeyFlag(config, arg): + case strings.HasPrefix(arg, "--ssh-password-key="): + config.SSHPasswordKey = strings.SplitN(arg, "=", 2)[1] + case arg == "--no-key", arg == "--password-only": + config.UseKeyAuth = false + config.KeyPath = "" + case arg == "--key-auth": + config.UseKeyAuth = true + case arg == "--accept-unknown-host": + config.AcceptUnknownHost = true + case arg == "--insecure-hostkey": + config.AllowInsecureHostKey = true + case arg == "--strict-host-key": + config.AllowInsecureHostKey = false + case strings.HasPrefix(arg, "--known-hosts="): + config.KnownHostsPath = strings.SplitN(arg, "=", 2)[1] + case applyBindFlag(config, arg): + case strings.HasPrefix(arg, "--timeout="): + raw := strings.SplitN(arg, "=", 2)[1] + if d, err := parseTimeout(raw); err == nil { + config.Timeout = d + } else { + config.Timeout = -1 + } + case arg == "--dry-run": + config.DryRun = true + case arg == "--json": + config.JSONOutput = true + case arg == "--allow-write": + config.ROSAllowWrite = true + case arg == "--force", arg == "-f": + config.Force = true + case arg == "--raw": + config.ROSRaw = true + case strings.HasPrefix(arg, "--routeros-version="), strings.HasPrefix(arg, "--ros-version="): //nolint:misspell // domain name for MikroTik RouterOS + config.ROSRouterOSVersion = strings.SplitN(arg, "=", 2)[1] + case strings.HasPrefix(arg, "--source="): + config.ROSSource = strings.TrimPrefix(strings.SplitN(arg, "=", 2)[1], "@") + case arg == "--cleanup": + config.ROSCleanup = true + case arg == "--compact": + config.ROSCompact = true + case strings.HasPrefix(arg, "--name="): + config.ROSBackupName = strings.SplitN(arg, "=", 2)[1] + case arg == "--include-remote": + config.ROSIncludeRemote = true + case strings.HasPrefix(arg, "--audit-output="): + config.AuditOutput = strings.SplitN(arg, "=", 2)[1] + case arg == "--no-audit": + config.AuditEnabled = false + default: + tokens = append(tokens, arg) + } + } + config.ROSTokens = tokens + if config.User == "" { + config.User = "admin" + } +} diff --git a/internal/app/mcp.go b/internal/app/mcp.go index 850e63e..3ab884f 100644 --- a/internal/app/mcp.go +++ b/internal/app/mcp.go @@ -745,6 +745,21 @@ type mcpSQLInput struct { Bind string `json:"bind,omitempty" jsonschema:"Local source address: literal IP or network interface name."` } +type mcpROSInput struct { + ExpectPlan string `json:"expect_plan,omitempty" jsonschema:"Expected sha256 execution plan hash; mismatch is rejected before connecting."` + HostTimeoutSecs int `json:"host_timeout_seconds,omitempty" jsonschema:"Optional whole-target budget in seconds."` + GlobalTimeoutSecs int `json:"global_timeout_seconds,omitempty" jsonschema:"Optional whole-operation budget in seconds."` + Target string `json:"target,omitempty" jsonschema:"Configured host name or IP address of the RouterOS device to reach over SSH."` + Port int `json:"port,omitempty" jsonschema:"SSH port (default 22)."` + User string `json:"user,omitempty" jsonschema:"SSH user (default admin)."` + Command string `json:"command" jsonschema:"RouterOS command or workflow (e.g. 'ip address print', 'commands', 'raw /system/resource/print')."` + AllowWrite bool `json:"allow_write,omitempty" jsonschema:"Permit raw commands or mutations to modify router state."` + Force bool `json:"force,omitempty" jsonschema:"Bypass safety guardrails for destructive commands."` + DryRun bool `json:"dry_run,omitempty" jsonschema:"Preview the execution plan without connecting."` + RouterOSVersion string `json:"routeros_version,omitempty" jsonschema:"Optional RouterOS major version hint (v6, v7, auto)."` //nolint:misspell // domain name for MikroTik RouterOS + Bind string `json:"bind,omitempty" jsonschema:"Local source address: literal IP or network interface name."` +} + type mcpApplyInput struct { ExpectPlan string `json:"expect_plan,omitempty" jsonschema:"Expected sha256 execution plan hash; mismatch is rejected before connecting."` HostTimeoutSecs int `json:"host_timeout_seconds,omitempty" jsonschema:"Optional whole-target budget in seconds."` @@ -998,6 +1013,48 @@ func buildSQLArgs(in mcpSQLInput) ([]string, error) { return args, nil } +func buildROSArgs(in mcpROSInput) ([]string, error) { + cmdTrimmed := strings.TrimSpace(in.Command) + if cmdTrimmed == "" { + return nil, fmt.Errorf("command is required") + } + args := []string{"ros", "--json"} + if in.Target != "" { + args = append(args, "-h="+in.Target) + } + if in.Port > 0 { + args = append(args, fmt.Sprintf("-p=%d", in.Port)) + } + if in.User != "" { + args = append(args, "-u="+in.User) + } + if in.AllowWrite { + args = append(args, "--allow-write") + } + if in.Force { + args = append(args, "--force") + } + if in.DryRun { + args = append(args, "--dry-run") + } + if in.RouterOSVersion != "" { + args = append(args, "--routeros-version="+in.RouterOSVersion) //nolint:misspell // domain name for MikroTik RouterOS + } + if in.ExpectPlan != "" { + args = append(args, "--expect-plan="+in.ExpectPlan) + } + if in.HostTimeoutSecs > 0 { + args = append(args, fmt.Sprintf("--host-timeout=%ds", in.HostTimeoutSecs)) + } + if in.GlobalTimeoutSecs > 0 { + args = append(args, fmt.Sprintf("--timeout=%ds", in.GlobalTimeoutSecs)) + } + args = appendBindArg(args, in.Bind) + tokens := strings.Fields(cmdTrimmed) + args = append(args, tokens...) + return args, nil +} + func buildApplyArgs(in mcpApplyInput, fromPath string) ([]string, error) { if strings.TrimSpace(in.Target) == "" { return nil, fmt.Errorf("target is required") @@ -1268,6 +1325,19 @@ func registerMCPTools(server *mcp.Server) { return runMCPTool(ctx, args, "", timeoutSeconds(in.GlobalTimeoutSecs)) }) + mcp.AddTool(server, &mcp.Tool{ + Name: "sshx_ros", + Description: "Execute MikroTik RouterOS (ROS) commands, workflows, or introspection strictly over SSH: " + + "print queries, safe mutations, raw CLI passthrough, file/script workflows (upload/download/backup/export/import), " + + "and dry-run preview with structured JSON output and safety guardrails.", + }, func(ctx context.Context, _ *mcp.CallToolRequest, in mcpROSInput) (*mcp.CallToolResult, any, error) { + args, err := buildROSArgs(in) + if err != nil { + return nil, nil, err + } + return runMCPTool(ctx, args, "", timeoutSeconds(in.GlobalTimeoutSecs)) + }) + mcp.AddTool(server, &mcp.Tool{ Name: "sshx_apply", Description: "Replace exactly one remote regular file with a guarded pipeline: optional expect_sha256 precondition, " + diff --git a/internal/app/ros.go b/internal/app/ros.go new file mode 100644 index 0000000..ee6d39c --- /dev/null +++ b/internal/app/ros.go @@ -0,0 +1,544 @@ +package app + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/talkincode/sshx/internal/execution" + "github.com/talkincode/sshx/internal/ros" + "github.com/talkincode/sshx/internal/sshclient" + "github.com/talkincode/sshx/pkg/errutil" + "github.com/talkincode/sshx/pkg/logger" +) + +// rosJSONResult is the machine-readable result emitted in --json mode for RouterOS commands. +type rosJSONResult struct { + SchemaVersion string `json:"schema_version,omitempty"` + Status string `json:"status"` // "ok" or "error" + Host string `json:"host,omitempty"` + Port string `json:"port,omitempty"` + User string `json:"user,omitempty"` + Command string `json:"command"` + Action string `json:"action,omitempty"` + RouterOSPath string `json:"routeros_path,omitempty"` //nolint:misspell // RouterOS domain term + Stdout string `json:"stdout,omitempty"` + Stderr string `json:"stderr,omitempty"` + Data any `json:"data,omitempty"` + ExitCode int `json:"exit_code"` + Success bool `json:"success"` + DurationMs int64 `json:"duration_ms"` + AuthMethod string `json:"auth_method,omitempty"` + ErrorKind string `json:"error_kind,omitempty"` + Error string `json:"error,omitempty"` + Phase string `json:"phase"` + Completion string `json:"completion"` +} + +// HandleROS manages RouterOS command execution and workflows strictly over SSH. +func HandleROS(config *sshclient.Config, audit *auditRecorder) (err error) { + start := time.Now() + + if config.ArgumentError != "" { + return reportROSFailure(config, audit, "config", fmt.Errorf("%s", config.ArgumentError), start) + } + + tokens := config.ROSTokens + if len(tokens) == 0 { + return reportROSFailure(config, audit, "usage", ros.NewUsageError("missing RouterOS command", "run `sshx ros help` or `sshx ros commands` to list commands"), start) + } + + // 1. Introspection commands (can run entirely locally without host) + switch tokens[0] { + case "commands": + payload, rErr := ros.RenderCommandsJSON() + if rErr != nil { + return reportROSFailure(config, audit, "internal", rErr, start) + } + if config.JSONOutput { + fmt.Println(payload) + return nil + } + // Human output + cmds := ros.AllCommands() + fmt.Printf("Supported RouterOS Commands (%d):\n\n", len(cmds)) + for _, c := range cmds { + fmt.Printf(" %-35s %s\n", strings.Join(c.CLIPath, " ")+" "+c.Action, c.Summary) + } + return nil + + case "help": + payload, rErr := ros.RenderHelpJSON(tokens[1:]) + if rErr != nil { + return reportROSFailure(config, audit, "usage", rErr, start) + } + fmt.Println(payload) + return nil + + case "schema": + payload, rErr := ros.RenderSchemaJSON(tokens[1:]) + if rErr != nil { + return reportROSFailure(config, audit, "usage", rErr, start) + } + fmt.Println(payload) + return nil + + case "explain-error": + code := "" + if len(tokens) > 1 { + code = tokens[1] + } + payload, exErr := ros.RenderExplainErrorJSON(code) + if exErr != nil { + return reportROSFailure(config, audit, "usage", exErr, start) + } + fmt.Println(payload) + return nil + + case "doctor": + return handleDoctor(config, audit, start) + } + + // 2. File and script workflows + if wfSpec, isWf := ros.ParseWorkflowCommand(tokens, config.ROSSource); isWf { + if config.DryRun { + planJSON, pErr := ros.RenderWorkflowPlanJSON(wfSpec) + if pErr != nil { + return reportROSFailure(config, audit, "internal", pErr, start) + } + fmt.Println(planJSON) + return nil + } + return handleWorkflow(config, audit, wfSpec, start) + } + + // 3. Command execution + req, parseErr := ros.ParseInvocation(tokens) + if parseErr != nil { + return reportROSFailure(config, audit, "usage", parseErr, start) + } + + // Check dry-run + if config.DryRun { + plan := &ros.ROSPlan{ + SchemaVersion: "roswire.command.plan.v1", + DryRun: true, + Command: strings.Join(tokens, " "), + RouterOSPath: req.Mapping.RouterOSPath, + Action: req.Action, + ResolvedArgs: req.Args, + Flags: req.Flags, + SideEffects: req.Mapping.SideEffects, + Idempotency: req.Mapping.Idempotency, + WillConnect: false, + WillModifyRouterOS: req.Mapping.ActionKind != ros.ActionPrint, + } + b, mErr := json.MarshalIndent(plan, "", " ") + if mErr != nil { + return reportROSFailure(config, audit, "internal", mErr, start) + } + fmt.Println(string(b)) + return nil + } + + // Check safety guardrails + if safeErr := ros.ValidateSafety(req, config.ROSAllowWrite, config.Force); safeErr != nil { + return reportROSFailure(config, audit, "blocked", safeErr, start) + } + + // Require host + if config.Host == "" { + return reportROSFailure(config, audit, "config", ros.NewConfigError("host is required (use -h=)", "specify -h="), start) + } + + // Resolve named host + if !isIPAddress(config.Host) { + if resolveErr := resolveHostFromSettings(config); resolveErr != nil { + logger.GetLogger().Info("Note: Could not find host '%s' in settings, using as hostname directly", config.Host) + } + } + + client, cliErr := sshclient.NewSSHClient(config) + if cliErr != nil { + return reportROSFailure(config, audit, "config", fmt.Errorf("failed to create SSH client: %w", cliErr), start) + } + defer errutil.HandleCloseError(&err, client) + + if connErr := client.ConnectDirect(); connErr != nil { + audit.recordPeer(client) + return reportROSFailure(config, audit, classifyError(connErr), fmt.Errorf("failed to connect over SSH: %w", connErr), start) + } + audit.recordPeer(client) + recordConnectedHops(config, client) + + rosCmd := ros.BuildRouterOSCommand(req) + logger.GetLogger().Debug("executing RouterOS command over SSH: %s", rosCmd) + + res, execErr := client.RunCommandWithInput(rosCmd, nil) + if execErr != nil { + return reportROSFailure(config, audit, classifyError(execErr), execErr, start) + } + + if res.ExitCode != 0 { + return reportROSFailureWithExit(config, audit, "ros_exit_error", res, fmt.Errorf("RouterOS command failed with exit code %d: %s", res.ExitCode, firstNonEmpty(res.Stderr, res.Stdout)), start) + } + + // Success reporting + durationMs := time.Since(start).Milliseconds() + if audit != nil { + audit.recordCommandResult(config, client.AuthMethodUsed(), res, time.Since(start), "", nil) + } + + var data any + if !config.ROSRaw { + if parsed, ok := ros.ParseRouterOSOutput(res.Stdout); ok { + data = parsed + } + } + + result := rosJSONResult{ + SchemaVersion: "roswire.write.v1", + Status: "ok", + Host: config.Host, + Port: config.Port, + User: config.User, + Command: strings.Join(tokens, " "), + Action: req.Action, + RouterOSPath: req.Mapping.RouterOSPath, + Stdout: res.Stdout, + Stderr: res.Stderr, + Data: data, + ExitCode: 0, + Success: true, + DurationMs: durationMs, + AuthMethod: string(client.AuthMethodUsed()), + Phase: "complete", + Completion: execution.CompletionCompleted, + } + + if config.JSONOutput { + return emitROSJSON(config, result) + } + + // Human output: print stdout cleanly + if strings.TrimSpace(res.Stdout) != "" { + fmt.Print(res.Stdout) + if !strings.HasSuffix(res.Stdout, "\n") { + fmt.Println() + } + } else if req.Mapping.ActionKind != ros.ActionPrint { + logger.GetLogger().Success("RouterOS command %s completed successfully", strings.Join(tokens, " ")) + } + return nil +} + +func handleWorkflow(config *sshclient.Config, audit *auditRecorder, spec *ros.WorkflowSpec, start time.Time) (err error) { + if config.Host == "" { + return reportROSFailure(config, audit, "config", ros.NewConfigError("host is required for workflow (use -h=)", "specify -h="), start) + } + if !isIPAddress(config.Host) { + _ = resolveHostFromSettings(config) //nolint:errcheck // best-effort settings lookup + } + + client, cliErr := sshclient.NewSSHClient(config) + if cliErr != nil { + return reportROSFailure(config, audit, "config", fmt.Errorf("failed to create SSH client: %w", cliErr), start) + } + defer errutil.HandleCloseError(&err, client) + + if connErr := client.ConnectDirect(); connErr != nil { + audit.recordPeer(client) + return reportROSFailure(config, audit, classifyError(connErr), connErr, start) + } + audit.recordPeer(client) + recordConnectedHops(config, client) + + sftpClient, sftpErr := client.NewSFTPClient() + if sftpErr != nil { + return reportROSFailure(config, audit, "sftp_error", fmt.Errorf("failed to open SFTP session on RouterOS: %w", sftpErr), start) + } + defer func() { _ = sftpClient.Close() }() //nolint:errcheck // best-effort close + + var outputMsg string + var resultData any + + switch spec.Type { + case ros.WorkflowFileUpload: + bytesCopied, uploadErr := ros.ExecuteSFTPUpload(sftpClient, spec.LocalPath, spec.RemotePath) + if uploadErr != nil { + return reportROSFailure(config, audit, "file_transfer_failed", uploadErr, start) + } + outputMsg = fmt.Sprintf("Uploaded %s to %s (%d bytes)", spec.LocalPath, spec.RemotePath, bytesCopied) + resultData = map[string]any{"bytes": bytesCopied, "local": spec.LocalPath, "remote": spec.RemotePath} + + case ros.WorkflowFileDownload: + bytesCopied, downloadErr := ros.ExecuteSFTPDownload(sftpClient, spec.RemotePath, spec.LocalPath) + if downloadErr != nil { + return reportROSFailure(config, audit, "file_transfer_failed", downloadErr, start) + } + outputMsg = fmt.Sprintf("Downloaded %s to %s (%d bytes)", spec.RemotePath, spec.LocalPath, bytesCopied) + resultData = map[string]any{"bytes": bytesCopied, "remote": spec.RemotePath, "local": spec.LocalPath} + + case ros.WorkflowFileList: + entries, listErr := sftpClient.ReadDir(spec.RemotePath) + if listErr != nil { + return reportROSFailure(config, audit, "file_transfer_failed", listErr, start) + } + var fileList []map[string]any + for _, e := range entries { + fileList = append(fileList, map[string]any{ + "name": e.Name(), + "size": e.Size(), + "is_dir": e.IsDir(), + "mod_time": e.ModTime().Format(time.RFC3339), + }) + } + resultData = fileList + outputMsg = fmt.Sprintf("Found %d file(s) in %s", len(fileList), spec.RemotePath) + + case ros.WorkflowScriptPut: + content, readErr := os.ReadFile(spec.LocalPath) + if readErr != nil { + return reportROSFailure(config, audit, "usage", fmt.Errorf("read script file %s: %w", spec.LocalPath, readErr), start) + } + if valErr := ros.ValidateScriptSource(content, spec.LocalPath); valErr != nil { + return reportROSFailure(config, audit, "usage", valErr, start) + } + // Escape script source for RouterOS CLI + escapedSource := strings.ReplaceAll(string(content), `"`, `\"`) + cmd := fmt.Sprintf(`/system script add name=%q source="%s"`, spec.ScriptName, escapedSource) + res, execErr := client.RunCommandWithInput(cmd, nil) + if execErr != nil || res.ExitCode != 0 { + // If already exists, try set + cmdSet := fmt.Sprintf(`/system script set [find name=%q] source="%s"`, spec.ScriptName, escapedSource) + resSet, execSetErr := client.RunCommandWithInput(cmdSet, nil) + if execSetErr != nil || resSet.ExitCode != 0 { + return reportROSFailure(config, audit, "ros_exec_error", fmt.Errorf("failed to put script: %s", firstNonEmpty(res.Stderr, resSet.Stderr)), start) + } + } + outputMsg = fmt.Sprintf("Installed script %q (%d bytes)", spec.ScriptName, len(content)) + resultData = map[string]any{"name": spec.ScriptName, "bytes": len(content)} + + case ros.WorkflowImport: + // 1. Upload script + _, uploadErr := ros.ExecuteSFTPUpload(sftpClient, spec.LocalPath, spec.RemotePath) + if uploadErr != nil { + return reportROSFailure(config, audit, "file_transfer_failed", uploadErr, start) + } + // 2. Run /import + importCmd := fmt.Sprintf("/import file-name=%q", spec.RemotePath) + res, execErr := client.RunCommandWithInput(importCmd, nil) + // 3. Cleanup if requested + if config.ROSCleanup { + _ = sftpClient.Remove(spec.RemotePath) //nolint:errcheck // best-effort remote file cleanup + } + if execErr != nil || res.ExitCode != 0 { + return reportROSFailure(config, audit, "ros_exec_error", fmt.Errorf("import failed: %s", firstNonEmpty(res.Stderr, res.Stdout)), start) + } + outputMsg = fmt.Sprintf("Imported %s into RouterOS", spec.LocalPath) + resultData = map[string]any{"imported": spec.LocalPath, "output": res.Stdout} + + case ros.WorkflowExportDownload: + tempFile := "sshx_export_temp.rsc" + compactFlag := "" + if config.ROSCompact { + compactFlag = "compact" + } + exportCmd := fmt.Sprintf("/export %s file=%s", compactFlag, tempFile) + res, execErr := client.RunCommandWithInput(exportCmd, nil) + if execErr != nil || res.ExitCode != 0 { + return reportROSFailure(config, audit, "ros_exec_error", fmt.Errorf("export failed: %s", firstNonEmpty(res.Stderr, res.Stdout)), start) + } + // Wait for file + if waitErr := ros.WaitForRemoteFile(sftpClient, tempFile, 15*time.Second); waitErr != nil { + return reportROSFailure(config, audit, "file_transfer_failed", waitErr, start) + } + // Download file + n, dlErr := ros.ExecuteSFTPDownload(sftpClient, tempFile, spec.LocalPath) + if config.ROSCleanup { + _ = sftpClient.Remove(tempFile) //nolint:errcheck // best-effort remote file cleanup + } + if dlErr != nil { + return reportROSFailure(config, audit, "file_transfer_failed", dlErr, start) + } + outputMsg = fmt.Sprintf("Exported configuration downloaded to %s (%d bytes)", spec.LocalPath, n) + resultData = map[string]any{"exported_to": spec.LocalPath, "bytes": n} + + case ros.WorkflowBackupDownload: + backupName := config.ROSBackupName + if backupName == "" { + backupName = "sshx_backup_temp" + } + backupFile := backupName + ".backup" + backupCmd := fmt.Sprintf("/system backup save name=%s", backupName) + res, execErr := client.RunCommandWithInput(backupCmd, nil) + if execErr != nil || res.ExitCode != 0 { + return reportROSFailure(config, audit, "ros_exec_error", fmt.Errorf("backup failed: %s", firstNonEmpty(res.Stderr, res.Stdout)), start) + } + // Wait for file + if waitErr := ros.WaitForRemoteFile(sftpClient, backupFile, 20*time.Second); waitErr != nil { + return reportROSFailure(config, audit, "file_transfer_failed", waitErr, start) + } + // Download file + n, dlErr := ros.ExecuteSFTPDownload(sftpClient, backupFile, spec.LocalPath) + if config.ROSCleanup { + _ = sftpClient.Remove(backupFile) //nolint:errcheck // best-effort remote file cleanup + } + if dlErr != nil { + return reportROSFailure(config, audit, "file_transfer_failed", dlErr, start) + } + outputMsg = fmt.Sprintf("Backup downloaded to %s (%d bytes)", spec.LocalPath, n) + resultData = map[string]any{"backup_file": spec.LocalPath, "bytes": n} + } + + durationMs := time.Since(start).Milliseconds() + if audit != nil { + audit.recordCommandResult(config, client.AuthMethodUsed(), sshclient.ExecResult{ExitCode: 0}, time.Since(start), "", nil) + } + + result := rosJSONResult{ + SchemaVersion: "roswire.write.v1", + Status: "ok", + Host: config.Host, + Port: config.Port, + User: config.User, + Command: string(spec.Type), + Data: resultData, + ExitCode: 0, + Success: true, + DurationMs: durationMs, + AuthMethod: string(client.AuthMethodUsed()), + Phase: "complete", + Completion: execution.CompletionCompleted, + } + + if config.JSONOutput { + return emitROSJSON(config, result) + } + + logger.GetLogger().Success("%s", outputMsg) + return nil +} + +func handleDoctor(config *sshclient.Config, audit *auditRecorder, start time.Time) error { + home, _ := os.UserHomeDir() //nolint:errcheck // fallback to relative if unavailable + sshDir := filepath.Join(home, ".ssh") + _, errSSH := os.Stat(sshDir) + + settingsPath := filepath.Join(home, ".sshx", "settings.json") + _, errSettings := os.Stat(settingsPath) + + var keysFound []string + commonKeys := []string{"id_rsa", "id_ed25519", "id_ecdsa"} + for _, k := range commonKeys { + if _, err := os.Stat(filepath.Join(sshDir, k)); err == nil { + keysFound = append(keysFound, k) + } + } + + localDoc := ros.LocalDoctor{ + HomeExists: true, + ConfigExists: errSettings == nil, + PermissionsOK: true, + SSHKeysFound: keysFound, + Dependencies: map[string]string{ + "transport": "ssh (built-in crypto/ssh)", + "file": "sftp (built-in pkg/sftp)", + }, + Warnings: nil, + } + if errSSH != nil { + localDoc.Warnings = append(localDoc.Warnings, "local ~/.ssh directory not found") + } + + docPayload := ros.DoctorPayload{ + SchemaVersion: "roswire.doctor.v1", + Local: localDoc, + SelectedProtocol: "ssh", + } + + if config.ROSIncludeRemote && config.Host != "" { + if !isIPAddress(config.Host) { + _ = resolveHostFromSettings(config) //nolint:errcheck // best-effort settings lookup + } + client, cliErr := sshclient.NewSSHClient(config) + if cliErr == nil { + defer func() { _ = client.Close() }() //nolint:errcheck // best-effort close + if connErr := client.ConnectDirect(); connErr == nil { + res, _ := client.RunCommandWithInput("/system resource print", nil) //nolint:errcheck // doctor probe ignores exec failure + remoteDoc := &ros.RemoteDoctor{ + Status: "connected", + } + if parsed, ok := ros.ParseRouterOSOutput(res.Stdout); ok { + if rec, isRec := parsed.(map[string]any); isRec { + if v, ok := rec["version"].(string); ok { + remoteDoc.RouterOSVersion = v + docPayload.RouterOSVersion = v + } + if a, ok := rec["architecture-name"].(string); ok { + remoteDoc.Architecture = a + } + if b, ok := rec["board-name"].(string); ok { + remoteDoc.BoardName = b + } + if u, ok := rec["uptime"].(string); ok { + remoteDoc.Uptime = u + } + } + } + docPayload.Remote = remoteDoc + } else { + docPayload.Remote = &ros.RemoteDoctor{ + Status: "connection_failed", + Warnings: []string{connErr.Error()}, + } + } + } + } + + b, err := json.MarshalIndent(docPayload, "", " ") + if err != nil { + return err + } + fmt.Println(string(b)) + return nil +} + +func emitROSJSON(config *sshclient.Config, result rosJSONResult) error { + return emitLifecycleJSON(config, result) +} + +func reportROSFailure(config *sshclient.Config, audit *auditRecorder, kind string, err error, start time.Time) error { + return reportROSFailureWithExit(config, audit, kind, sshclient.ExecResult{ExitCode: -1}, err, start) +} + +func reportROSFailureWithExit(config *sshclient.Config, audit *auditRecorder, kind string, res sshclient.ExecResult, err error, start time.Time) error { + if audit != nil { + audit.recordFailure(config, sshclient.AuthMethodUnknown, kind, err) + } + + result := rosJSONResult{ + Status: "error", + Host: config.Host, + Port: config.Port, + User: config.User, + Command: strings.Join(config.ROSTokens, " "), + ExitCode: res.ExitCode, + Success: false, + DurationMs: time.Since(start).Milliseconds(), + ErrorKind: kind, + Error: redactError(err), + Phase: "failed", + Completion: execution.CompletionUnknown, + } + + if config.JSONOutput { + _ = emitROSJSON(config, result) //nolint:errcheck // best-effort error result JSON emission + return ErrReported + } + + return err +} diff --git a/internal/app/ros_test.go b/internal/app/ros_test.go new file mode 100644 index 0000000..bad0024 --- /dev/null +++ b/internal/app/ros_test.go @@ -0,0 +1,360 @@ +package app + +import ( + "bytes" + "encoding/json" + "io" + "os" + "strings" + "testing" + + "github.com/talkincode/sshx/internal/sshclient" +) + +func rosCaptureStdout(f func() error) (string, error) { + oldStdout := os.Stdout + r, w, pipeErr := os.Pipe() + if pipeErr != nil { + return "", pipeErr + } + os.Stdout = w + + outC := make(chan string) + go func() { + var buf bytes.Buffer + _, _ = io.Copy(&buf, r) //nolint:errcheck // fixture buffer copy + outC <- buf.String() + }() + + err := f() + _ = w.Close() //nolint:errcheck // fixture pipe close + os.Stdout = oldStdout + out := <-outC + return out, err +} + +func TestParseROSArgs(t *testing.T) { + config := &sshclient.Config{} + args := []string{ + "-h=192.168.88.1", + "-p=2222", + "-u=custom_admin", + "--json", + "--dry-run", + "--allow-write", + "--force", + "--raw", + "--cleanup", + "--compact", + "--source=@./setup.rsc", + "ip", "address", "print", "detail", + } + + parseROSArgs(config, args) + + if config.Mode != "ros" { + t.Errorf("expected Mode=ros, got %s", config.Mode) + } + if config.Host != "192.168.88.1" { + t.Errorf("expected Host 192.168.88.1, got %s", config.Host) + } + if config.Port != "2222" { + t.Errorf("expected Port 2222, got %s", config.Port) + } + if config.User != "custom_admin" { + t.Errorf("expected User custom_admin, got %s", config.User) + } + if !config.JSONOutput { + t.Errorf("expected JSONOutput=true") + } + if !config.DryRun { + t.Errorf("expected DryRun=true") + } + if !config.ROSAllowWrite { + t.Errorf("expected ROSAllowWrite=true") + } + if !config.Force { + t.Errorf("expected Force=true") + } + if !config.ROSRaw { + t.Errorf("expected ROSRaw=true") + } + if !config.ROSCleanup { + t.Errorf("expected ROSCleanup=true") + } + if !config.ROSCompact { + t.Errorf("expected ROSCompact=true") + } + if config.ROSSource != "./setup.rsc" { + t.Errorf("expected ROSSource ./setup.rsc, got %s", config.ROSSource) + } + expectedTokens := []string{"ip", "address", "print", "detail"} + if len(config.ROSTokens) != len(expectedTokens) { + t.Fatalf("expected tokens %v, got %v", expectedTokens, config.ROSTokens) + } + for i := range expectedTokens { + if config.ROSTokens[i] != expectedTokens[i] { + t.Errorf("token %d: expected %s, got %s", i, expectedTokens[i], config.ROSTokens[i]) + } + } +} + +func TestHandleROS_Introspection(t *testing.T) { + // 1. commands --json + out, err := rosCaptureStdout(func() error { + cfg := ParseArgs([]string{"sshx", "ros", "commands", "--json"}) + return HandleROS(cfg, nil) + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var cmdsPayload map[string]any + if uErr := json.Unmarshal([]byte(out), &cmdsPayload); uErr != nil { + t.Fatalf("invalid JSON for commands: %v\nOutput was: %s", uErr, out) + } + if cmdsPayload["schema_version"] != "roswire.commands.v1" { + t.Errorf("expected schema_version roswire.commands.v1, got %v", cmdsPayload["schema_version"]) + } + + // 2. help --json + outHelp, err := rosCaptureStdout(func() error { + cfg := ParseArgs([]string{"sshx", "ros", "help", "--json"}) + return HandleROS(cfg, nil) + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var helpPayload map[string]any + if uErr := json.Unmarshal([]byte(outHelp), &helpPayload); uErr != nil { + t.Fatalf("invalid JSON for help: %v\nOutput: %s", uErr, outHelp) + } + if helpPayload["schema_version"] != "roswire.help.index.v1" { + t.Errorf("expected schema_version roswire.help.index.v1, got %v", helpPayload["schema_version"]) + } + + // 3. help ip address add --json + outHelpCmd, err := rosCaptureStdout(func() error { + cfg := ParseArgs([]string{"sshx", "ros", "help", "ip", "address", "add", "--json"}) + return HandleROS(cfg, nil) + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var helpCmdPayload map[string]any + if uErr := json.Unmarshal([]byte(outHelpCmd), &helpCmdPayload); uErr != nil { + t.Fatalf("invalid JSON for help command: %v", uErr) + } + if helpCmdPayload["schema_version"] != "roswire.help.command.v1" { + t.Errorf("expected schema_version roswire.help.command.v1, got %v", helpCmdPayload["schema_version"]) + } + + // 4. schema ip address add --json + outSchema, err := rosCaptureStdout(func() error { + cfg := ParseArgs([]string{"sshx", "ros", "schema", "ip", "address", "add", "--json"}) + return HandleROS(cfg, nil) + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var schemaPayload map[string]any + if uErr := json.Unmarshal([]byte(outSchema), &schemaPayload); uErr != nil { + t.Fatalf("invalid JSON for schema: %v", uErr) + } + if schemaPayload["schema_version"] != "roswire.schema.v1" { + t.Errorf("expected schema_version roswire.schema.v1, got %v", schemaPayload["schema_version"]) + } + + // 5. explain-error USAGE_ERROR --json + outExplain, err := rosCaptureStdout(func() error { + cfg := ParseArgs([]string{"sshx", "ros", "explain-error", "USAGE_ERROR", "--json"}) + return HandleROS(cfg, nil) + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var explainPayload map[string]any + if uErr := json.Unmarshal([]byte(outExplain), &explainPayload); uErr != nil { + t.Fatalf("invalid JSON for explain-error: %v", uErr) + } + if explainPayload["schema_version"] != "roswire.explain_error.v1" { + t.Errorf("expected schema_version roswire.explain_error.v1, got %v", explainPayload["schema_version"]) + } + + // 6. doctor --json + outDoctor, err := rosCaptureStdout(func() error { + cfg := ParseArgs([]string{"sshx", "ros", "doctor", "--json"}) + return HandleROS(cfg, nil) + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var doctorPayload map[string]any + if uErr := json.Unmarshal([]byte(outDoctor), &doctorPayload); uErr != nil { + t.Fatalf("invalid JSON for doctor: %v", uErr) + } + if doctorPayload["schema_version"] != "roswire.doctor.v1" { + t.Errorf("expected schema_version roswire.doctor.v1, got %v", doctorPayload["schema_version"]) + } + + // 7. help raw --json + outHelpRaw, err := rosCaptureStdout(func() error { + cfg := ParseArgs([]string{"sshx", "ros", "help", "raw", "--json"}) + return HandleROS(cfg, nil) + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var helpRawPayload map[string]any + if uErr := json.Unmarshal([]byte(outHelpRaw), &helpRawPayload); uErr != nil { + t.Fatalf("invalid JSON for help raw: %v", uErr) + } + if helpRawPayload["schema_version"] != "roswire.help.command.v1" { + t.Errorf("expected schema_version roswire.help.command.v1, got %v", helpRawPayload["schema_version"]) + } + + // 8. schema command raw --json + outSchemaRaw, err := rosCaptureStdout(func() error { + cfg := ParseArgs([]string{"sshx", "ros", "schema", "command", "raw", "--json"}) + return HandleROS(cfg, nil) + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var schemaRawPayload map[string]any + if err := json.Unmarshal([]byte(outSchemaRaw), &schemaRawPayload); err != nil { + t.Fatalf("invalid JSON for schema raw: %v", err) + } + if schemaRawPayload["command"] != "raw" { + t.Errorf("expected command 'raw', got %v", schemaRawPayload["command"]) + } +} + +func TestHandleROS_DryRun(t *testing.T) { + // 1. Read command dry-run + out, err := rosCaptureStdout(func() error { + cfg := ParseArgs([]string{"sshx", "ros", "-h=192.168.88.1", "ip", "address", "print", "--dry-run", "--json"}) + return HandleROS(cfg, nil) + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var plan map[string]any + if uErr := json.Unmarshal([]byte(out), &plan); uErr != nil { + t.Fatalf("invalid JSON plan: %v\nOutput was: %s", uErr, out) + } + if plan["schema_version"] != "roswire.command.plan.v1" { + t.Errorf("expected schema_version roswire.command.plan.v1, got %v", plan["schema_version"]) + } + modifyKey := "will_modify_routeros" //nolint:misspell // RouterOS domain key + if plan[modifyKey] != false { + t.Errorf("expected %s=false for print, got %v", modifyKey, plan[modifyKey]) + } + + // 2. Write command dry-run + outWrite, err := rosCaptureStdout(func() error { + cfg := ParseArgs([]string{"sshx", "ros", "-h=192.168.88.1", "ip", "address", "add", "address=192.168.88.1/24", "interface=ether1", "--dry-run", "--json"}) + return HandleROS(cfg, nil) + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var planWrite map[string]any + if uErr := json.Unmarshal([]byte(outWrite), &planWrite); uErr != nil { + t.Fatalf("invalid JSON plan: %v", uErr) + } + if planWrite[modifyKey] != true { + t.Errorf("expected %s=true for add, got %v", modifyKey, planWrite[modifyKey]) + } + + // 3. Workflow dry-run + outWf, err := rosCaptureStdout(func() error { + cfg := ParseArgs([]string{"sshx", "ros", "-h=192.168.88.1", "file", "upload", "local.rsc", "flash/local.rsc", "--dry-run", "--json"}) + return HandleROS(cfg, nil) + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var planWf map[string]any + if uErr := json.Unmarshal([]byte(outWf), &planWf); uErr != nil { + t.Fatalf("invalid JSON plan: %v", uErr) + } + if planWf["operation"] != "file_upload" { + t.Errorf("expected operation file_upload, got %v", planWf["operation"]) + } + if planWf["transfer_backend"] != "ssh" { + t.Errorf("expected transfer_backend ssh, got %v", planWf["transfer_backend"]) + } + + // 4. Raw command dry-run + outRaw, err := rosCaptureStdout(func() error { + cfg := ParseArgs([]string{"sshx", "ros", "-h=192.168.88.1", "raw", "/system/resource/print", "--dry-run", "--json"}) + return HandleROS(cfg, nil) + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var planRaw map[string]any + if err := json.Unmarshal([]byte(outRaw), &planRaw); err != nil { + t.Fatalf("invalid JSON plan: %v", err) + } + if planRaw["action"] != "raw" || planRaw["routeros_path"] != "/system/resource/print" { //nolint:misspell // RouterOS domain key + t.Errorf("expected raw plan, got %+v", planRaw) + } +} + +func TestHandleROS_Safety(t *testing.T) { + // Dangerous command blocked without force + cfg := ParseArgs([]string{"sshx", "ros", "-h=192.168.88.1", "raw", "/system/reset-configuration", "--json"}) + err := HandleROS(cfg, nil) + if err == nil { + t.Fatalf("expected dangerous command to be blocked") + } + // With JSON output, ErrReported is returned + if !strings.Contains(err.Error(), "result already reported") && !strings.Contains(err.Error(), "dangerous RouterOS command blocked") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestBuildROSArgs(t *testing.T) { + in := mcpROSInput{ + Target: "192.168.88.1", + Port: 2222, + User: "admin", + Command: "ip address print detail", + AllowWrite: true, + DryRun: true, + RouterOSVersion: "v7", + GlobalTimeoutSecs: 30, + } + + args, err := buildROSArgs(in) + if err != nil { + t.Fatalf("unexpected error building ROS args: %v", err) + } + + expectedPrefix := []string{ + "ros", + "--json", + "-h=192.168.88.1", + "-p=2222", + "-u=admin", + "--allow-write", + "--dry-run", + "--routeros-version=v7", //nolint:misspell // RouterOS CLI flag + "--timeout=30s", + "ip", + "address", + "print", + "detail", + } + + if len(args) != len(expectedPrefix) { + t.Fatalf("expected args length %d, got %d (%v)", len(expectedPrefix), len(args), args) + } + for i := range expectedPrefix { + if args[i] != expectedPrefix[i] { + t.Errorf("arg %d: expected %s, got %s", i, expectedPrefix[i], args[i]) + } + } +} diff --git a/internal/app/usage.go b/internal/app/usage.go index a6d5341..1c63be6 100644 --- a/internal/app/usage.go +++ b/internal/app/usage.go @@ -32,6 +32,7 @@ Usage: sshx plugin list [--json] # List built-in and local capabilities sshx inspect -h= [options] # Run one structured host inspection sshx sql -h= --db= [options] "SQL" # Guarded SQL via remote psql/sqlite3 + sshx ros -h= [options] # MikroTik RouterOS over SSH sshx apply -h= --path= --from= # Guarded remote file apply sshx text -h= --path= [options] # Bounded remote text/log dissection sshx login [--sudo] # Human interactive login (TTY required) @@ -386,6 +387,34 @@ Guarded SQL Execution: sshx sql -h=app --engine=sqlite --db-file=/var/lib/app/app.db --sudo --json \ "UPDATE users SET active=0 WHERE id=42" +MikroTik RouterOS (ROS) over SSH: + sshx ros -h= [options] [key=value ...] + sshx ros -h= interface print [--json] + sshx ros -h= ip address add address=192.168.88.1/24 interface=ether1 + sshx ros -h= raw "/system/resource/print" + sshx ros -h= file upload + sshx ros -h= file download + sshx ros -h= backup download [--name=] [--cleanup] + sshx ros -h= export download [--compact] [--cleanup] + sshx ros -h= import [--cleanup] + sshx ros -h= script put --source=@ + sshx ros commands [--json] # List supported RouterOS commands + sshx ros help [command] [--json] # Show command help and arguments + sshx ros schema [command] [--json] # Emit JSON schema for command + sshx ros doctor [-h=] [--include-remote] # Health & environment check + sshx ros explain-error # Explain error code and remediation + + ROS Options: + --dry-run Emit execution plan JSON without modifying router + --allow-write Permit raw commands or mutations to alter state + --force, -f Bypass safety guardrails for destructive commands + --raw Execute without CLI response parsing + --ros-version=VER RouterOS major version hint (v6, v7, auto) + --source=@PATH Local .rsc script file for script put + --cleanup Delete temporary remote files after workflow + --compact Use compact export for export download + --name=NAME Custom backup file name + Guarded File Apply: sshx apply -h= --path=/abs/remote.conf --from=./local.conf [options] sshx apply --target= --path=/abs/remote.conf --from=./local.conf --json diff --git a/internal/ros/catalog.go b/internal/ros/catalog.go new file mode 100644 index 0000000..a68dd4f --- /dev/null +++ b/internal/ros/catalog.go @@ -0,0 +1,711 @@ +package ros + +import ( + "fmt" + "sort" + "strings" +) + +var staticCommands = []CommandMapping{ + { + CLIPath: []string{"raw"}, + Action: "raw", + ActionKind: ActionRaw, + RouterOSPath: "/", //nolint:misspell // RouterOS domain placeholder + SideEffects: []string{"raw-ros-command"}, + Idempotency: "unknown", + Summary: "Explicitly pass a RouterOS API path plus read-only print options or key=value words for advanced unsupported commands", + Arguments: []ArgumentSpec{ + { + Name: "routeros-path", //nolint:misspell // RouterOS domain argument name + Style: "positional", + Required: true, + Type: "classic-api-path", + Description: "RouterOS classic API path beginning with `/`, e.g. /system/resource/print", + Example: "/system/resource/print", + }, + { + Name: "key=value", + Style: "key-value", + Required: false, + Type: "string", + Description: "Additional RouterOS API word arguments", + Example: "detail=yes", + }, + { + Name: "--allow-write", + Style: "flag", + Required: false, + Type: "bool", + Description: "Explicitly permit raw commands that mutate RouterOS state (e.g. /ip/address/add)", + Example: "--allow-write", + }, + }, + Examples: []string{ + "sshx ros -h=router raw /system/resource/print --json", + "sshx ros -h=router raw /interface/print detail --json", + "sshx ros -h=router raw /ip/address/add address=192.168.88.1/24 interface=ether1 --allow-write", + }, + }, + { + CLIPath: []string{"ip", "dhcp-client"}, + Action: "print", + ActionKind: ActionPrint, + RouterOSPath: "/ip/dhcp-client/print", + SideEffects: nil, + Idempotency: "read-only", + Summary: "Print DHCP client leases and status", + }, + { + CLIPath: []string{"interface"}, + Action: "print", + ActionKind: ActionPrint, + RouterOSPath: "/interface/print", + SideEffects: nil, + Idempotency: "read-only", + Summary: "Print network interfaces and properties", + }, + { + CLIPath: []string{"interface", "wireguard"}, + Action: "print", + ActionKind: ActionPrint, + RouterOSPath: "/interface/wireguard/print", + SideEffects: nil, + Idempotency: "read-only", + Summary: "Print WireGuard interfaces and status", + }, + { + CLIPath: []string{"interface", "wireguard", "peers"}, + Action: "print", + ActionKind: ActionPrint, + RouterOSPath: "/interface/wireguard/peers/print", + SideEffects: nil, + Idempotency: "read-only", + Summary: "Print configured WireGuard peers", + }, + { + CLIPath: []string{"ip", "address"}, + Action: "print", + ActionKind: ActionPrint, + RouterOSPath: "/ip/address/print", + SideEffects: nil, + Idempotency: "read-only", + Summary: "Print IPv4 address assignments", + }, + { + CLIPath: []string{"ip", "address"}, + Action: "add", + ActionKind: ActionAdd, + RouterOSPath: "/ip/address/add", + SideEffects: []string{"creates-ros-record"}, + Idempotency: "not-idempotent", + Summary: "Add an IPv4 address assignment to an interface", + Arguments: []ArgumentSpec{ + {Name: "address", Style: "key-value", Required: true, Type: "cidr", Description: "IPv4 address and netmask (e.g. 192.168.88.1/24)", Example: "192.168.88.1/24"}, + {Name: "interface", Style: "key-value", Required: true, Type: "string", Description: "Name of target interface", Example: "ether1"}, + {Name: "comment", Style: "key-value", Required: false, Type: "string", Description: "Descriptive comment for address record"}, + {Name: "disabled", Style: "key-value", Required: false, Type: "bool", Description: "Whether address assignment is initially disabled"}, + {Name: "network", Style: "key-value", Required: false, Type: "ip", Description: "Network address (calculated automatically if omitted)"}, + }, + Examples: []string{ + "sshx ros -h=router ip address add address=192.168.88.1/24 interface=ether1", + }, + }, + { + CLIPath: []string{"ip", "address"}, + Action: "set", + ActionKind: ActionSet, + RouterOSPath: "/ip/address/set", + SideEffects: []string{"updates-ros-record"}, + Idempotency: "idempotent", + Summary: "Modify an existing IPv4 address assignment", + Arguments: []ArgumentSpec{ + {Name: "numbers", Style: "key-value", Required: true, Type: "string", Description: "Target item index or ID (.id)", Example: "*1 or 0"}, + {Name: "address", Style: "key-value", Required: false, Type: "cidr", Description: "Updated IPv4 address and netmask"}, + {Name: "interface", Style: "key-value", Required: false, Type: "string", Description: "Updated target interface"}, + {Name: "disabled", Style: "key-value", Required: false, Type: "bool", Description: "Enable or disable address assignment"}, + {Name: "comment", Style: "key-value", Required: false, Type: "string", Description: "Updated comment"}, + }, + }, + { + CLIPath: []string{"ip", "address"}, + Action: "remove", + ActionKind: ActionRemove, + RouterOSPath: "/ip/address/remove", + SideEffects: []string{"deletes-ros-record"}, + Idempotency: "not-idempotent", + Summary: "Remove an IPv4 address assignment", + Arguments: []ArgumentSpec{ + {Name: "numbers", Style: "key-value", Required: true, Type: "string", Description: "Target item index or ID (.id)", Example: "*1 or 0"}, + }, + }, + { + CLIPath: []string{"ip", "firewall", "address-list"}, + Action: "print", + ActionKind: ActionPrint, + RouterOSPath: "/ip/firewall/address-list/print", + SideEffects: nil, + Idempotency: "read-only", + Summary: "Print firewall address-list entries", + }, + { + CLIPath: []string{"ip", "firewall", "filter"}, + Action: "print", + ActionKind: ActionPrint, + RouterOSPath: "/ip/firewall/filter/print", + SideEffects: nil, + Idempotency: "read-only", + Summary: "Print firewall filter rules", + }, + { + CLIPath: []string{"ip", "firewall", "filter"}, + Action: "add", + ActionKind: ActionAdd, + RouterOSPath: "/ip/firewall/filter/add", + SideEffects: []string{"creates-ros-record"}, + Idempotency: "not-idempotent", + Summary: "Add a firewall filter rule", + Arguments: []ArgumentSpec{ + {Name: "chain", Style: "key-value", Required: true, Type: "string", Description: "Firewall chain (e.g. input, forward, output)", Example: "input"}, + {Name: "action", Style: "key-value", Required: true, Type: "string", Description: "Filter action (accept, drop, reject, etc.)", Example: "accept"}, + {Name: "src-address", Style: "key-value", Required: false, Type: "string", Description: "Source IP address or CIDR"}, + {Name: "dst-address", Style: "key-value", Required: false, Type: "string", Description: "Destination IP address or CIDR"}, + {Name: "protocol", Style: "key-value", Required: false, Type: "string", Description: "IP protocol (tcp, udp, icmp, etc.)"}, + {Name: "dst-port", Style: "key-value", Required: false, Type: "string", Description: "Destination port"}, + {Name: "comment", Style: "key-value", Required: false, Type: "string", Description: "Descriptive comment"}, + }, + }, + { + CLIPath: []string{"ip", "firewall", "filter"}, + Action: "set", + ActionKind: ActionSet, + RouterOSPath: "/ip/firewall/filter/set", + SideEffects: []string{"updates-ros-record"}, + Idempotency: "idempotent", + Summary: "Modify an existing firewall filter rule", + }, + { + CLIPath: []string{"ip", "firewall", "filter"}, + Action: "remove", + ActionKind: ActionRemove, + RouterOSPath: "/ip/firewall/filter/remove", + SideEffects: []string{"deletes-ros-record"}, + Idempotency: "not-idempotent", + Summary: "Remove a firewall filter rule", + }, + { + CLIPath: []string{"ip", "firewall", "nat"}, + Action: "print", + ActionKind: ActionPrint, + RouterOSPath: "/ip/firewall/nat/print", + SideEffects: nil, + Idempotency: "read-only", + Summary: "Print firewall NAT rules", + }, + { + CLIPath: []string{"ip", "firewall", "nat"}, + Action: "add", + ActionKind: ActionAdd, + RouterOSPath: "/ip/firewall/nat/add", + SideEffects: []string{"creates-ros-record"}, + Idempotency: "not-idempotent", + Summary: "Add a firewall NAT rule", + }, + { + CLIPath: []string{"ip", "firewall", "nat"}, + Action: "set", + ActionKind: ActionSet, + RouterOSPath: "/ip/firewall/nat/set", + SideEffects: []string{"updates-ros-record"}, + Idempotency: "idempotent", + Summary: "Modify a firewall NAT rule", + }, + { + CLIPath: []string{"ip", "firewall", "nat"}, + Action: "remove", + ActionKind: ActionRemove, + RouterOSPath: "/ip/firewall/nat/remove", + SideEffects: []string{"deletes-ros-record"}, + Idempotency: "not-idempotent", + Summary: "Remove a firewall NAT rule", + }, + { + CLIPath: []string{"ip", "firewall", "mangle"}, + Action: "print", + ActionKind: ActionPrint, + RouterOSPath: "/ip/firewall/mangle/print", + SideEffects: nil, + Idempotency: "read-only", + Summary: "Print firewall packet mangle rules", + }, + { + CLIPath: []string{"ip", "firewall", "connection"}, + Action: "print", + ActionKind: ActionPrint, + RouterOSPath: "/ip/firewall/connection/print", + SideEffects: nil, + Idempotency: "read-only", + Summary: "Print active firewall connections tracking table", + }, + { + CLIPath: []string{"ip", "route"}, + Action: "print", + ActionKind: ActionPrint, + RouterOSPath: "/ip/route/print", + SideEffects: nil, + Idempotency: "read-only", + Summary: "Print IPv4 routing table entries", + }, + { + CLIPath: []string{"ip", "route"}, + Action: "add", + ActionKind: ActionAdd, + RouterOSPath: "/ip/route/add", + SideEffects: []string{"creates-ros-record"}, + Idempotency: "not-idempotent", + Summary: "Add a route to the routing table", + Arguments: []ArgumentSpec{ + {Name: "dst-address", Style: "key-value", Required: true, Type: "cidr", Description: "Destination network or host", Example: "0.0.0.0/0"}, + {Name: "gateway", Style: "key-value", Required: true, Type: "string", Description: "Gateway IP or interface", Example: "192.168.88.254"}, + {Name: "distance", Style: "key-value", Required: false, Type: "int", Description: "Administrative distance"}, + {Name: "comment", Style: "key-value", Required: false, Type: "string", Description: "Descriptive comment"}, + }, + }, + { + CLIPath: []string{"ip", "route"}, + Action: "set", + ActionKind: ActionSet, + RouterOSPath: "/ip/route/set", + SideEffects: []string{"updates-ros-record"}, + Idempotency: "idempotent", + Summary: "Modify an existing route", + }, + { + CLIPath: []string{"ip", "route"}, + Action: "remove", + ActionKind: ActionRemove, + RouterOSPath: "/ip/route/remove", + SideEffects: []string{"deletes-ros-record"}, + Idempotency: "not-idempotent", + Summary: "Remove an entry from the routing table", + }, + { + CLIPath: []string{"system", "resource"}, + Action: "print", + ActionKind: ActionPrint, + RouterOSPath: "/system/resource/print", + SideEffects: nil, + Idempotency: "read-only", + Summary: "Print system resource usage (CPU, memory, uptime, version)", + }, + { + CLIPath: []string{"system", "routerboard"}, + Action: "print", + ActionKind: ActionPrint, + RouterOSPath: "/system/routerboard/print", + SideEffects: nil, + Idempotency: "read-only", + Summary: "Print RouterBOARD hardware model and firmware information", + }, + { + CLIPath: []string{"system", "package"}, + Action: "print", + ActionKind: ActionPrint, + RouterOSPath: "/system/package/print", + SideEffects: nil, + Idempotency: "read-only", + Summary: "Print installed RouterOS package bundles and versions", + }, + { + CLIPath: []string{"system", "script"}, + Action: "print", + ActionKind: ActionPrint, + RouterOSPath: "/system/script/print", + SideEffects: nil, + Idempotency: "read-only", + Summary: "Print installed RouterOS scripts", + }, + { + CLIPath: []string{"system", "script"}, + Action: "add", + ActionKind: ActionAdd, + RouterOSPath: "/system/script/add", + SideEffects: []string{"creates-ros-script"}, + Idempotency: "not-idempotent", + Summary: "Add or install a script in /system/script", + Arguments: []ArgumentSpec{ + {Name: "name", Style: "key-value", Required: true, Type: "string", Description: "Unique name of the script"}, + {Name: "source", Style: "key-value", Required: true, Type: "string", Description: "Script source text"}, + {Name: "owner", Style: "key-value", Required: false, Type: "string", Description: "Script owner"}, + }, + }, + { + CLIPath: []string{"tool", "mac-server"}, + Action: "print", + ActionKind: ActionPrint, + RouterOSPath: "/tool/mac-server/print", + SideEffects: nil, + Idempotency: "read-only", + Summary: "Print MAC server interface configuration", + }, + { + CLIPath: []string{"tool", "netwatch"}, + Action: "print", + ActionKind: ActionPrint, + RouterOSPath: "/tool/netwatch/print", + SideEffects: nil, + Idempotency: "read-only", + Summary: "Print netwatch host monitoring entries", + }, + { + CLIPath: []string{"user"}, + Action: "print", + ActionKind: ActionPrint, + RouterOSPath: "/user/print", + SideEffects: nil, + Idempotency: "read-only", + Summary: "Print RouterOS user accounts", + }, +} + +// AllCommands returns the complete slice of statically registered commands. +func AllCommands() []CommandMapping { + res := make([]CommandMapping, len(staticCommands)) + copy(res, staticCommands) + return res +} + +// LookupCommand searches for a matching static command mapping. +func LookupCommand(path []string, action string) (*CommandMapping, bool) { + // Handle raw special cases (e.g. `raw`, `help raw`, `schema raw`, `schema command raw`) + if (len(path) == 0 && strings.EqualFold(action, "raw")) || + (len(path) == 1 && strings.EqualFold(path[0], "raw")) || + (len(path) == 1 && strings.EqualFold(path[0], "command") && strings.EqualFold(action, "raw")) { + for i := range staticCommands { + if staticCommands[i].IsRaw() { + return &staticCommands[i], true + } + } + } + + for i := range staticCommands { + cmd := &staticCommands[i] + if slicesEqual(cmd.CLIPath, path) && strings.EqualFold(cmd.Action, action) { + return cmd, true + } + } + return nil, false +} + +func slicesEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if !strings.EqualFold(a[i], b[i]) { + return false + } + } + return true +} + +// ParseInvocation processes raw CLI tokens into a ROSRequest. +func ParseInvocation(tokens []string) (*ROSRequest, error) { + if len(tokens) == 0 { + return nil, NewUsageError("missing RouterOS command", "run `sshx ros help` or `sshx ros commands` to list available commands") + } + + // Handle `raw` command or direct RouterOS CLI path starting with '/' or ':' + isRaw := tokens[0] == "raw" || strings.HasPrefix(tokens[0], "/") || strings.HasPrefix(tokens[0], ":") + if isRaw { + rawTokenIndex := 0 + if tokens[0] == "raw" { + if len(tokens) < 2 { + return nil, NewUsageError( + "raw command requires a RouterOS API path, e.g. sshx ros raw /system/resource/print --json", + "pass a RouterOS path starting with `/`", + ) + } + rawTokenIndex = 1 + } + + rawPath := strings.TrimSpace(tokens[rawTokenIndex]) + var restTokens []string + if strings.Contains(rawPath, " ") { + fields := strings.Fields(rawPath) + rawPath = fields[0] + restTokens = append(fields[1:], tokens[rawTokenIndex+1:]...) + } else { + restTokens = tokens[rawTokenIndex+1:] + } + + if !strings.HasPrefix(rawPath, "/") && !strings.HasPrefix(rawPath, ":") { + rawPath = "/" + rawPath + } + + args := make(map[string]string) + var flags []string + for _, tok := range restTokens { + if strings.Contains(tok, "=") { + kv := strings.SplitN(tok, "=", 2) + args[kv[0]] = kv[1] + } else { + flags = append(flags, tok) + } + } + + // Determine action kind from rawPath + actionKind := ActionRaw + parts := strings.Split(strings.Trim(rawPath, "/"), "/") + if len(parts) > 0 { + last := strings.ToLower(parts[len(parts)-1]) + switch last { + case "print": + actionKind = ActionPrint + case "add": + actionKind = ActionAdd + case "set": + actionKind = ActionSet + case "remove", "del", "delete": + actionKind = ActionRemove + } + } + + isPrint := actionKind == ActionPrint + idempotency := "unknown" + var sideEffects []string + if isPrint { + idempotency = "read-only" + } else { + sideEffects = []string{"raw-ros-command"} + } + + return &ROSRequest{ + Path: []string{"raw"}, + Action: "raw", + RawCommand: rawPath, + Args: args, + Flags: flags, + Mapping: &CommandMapping{ + CLIPath: []string{"raw"}, + Action: "raw", + ActionKind: actionKind, + RouterOSPath: rawPath, + SideEffects: sideEffects, + Idempotency: idempotency, + Summary: "Raw RouterOS command: " + rawPath, + }, + }, nil + } + + // Parse tokens: collect path words, then action, then arguments and flags. + // Common action verbs: print, add, set, remove, get, export, monitor + knownActions := map[string]ActionKind{ + "print": ActionPrint, + "add": ActionAdd, + "set": ActionSet, + "remove": ActionRemove, + "del": ActionRemove, + "delete": ActionRemove, + "get": ActionPrint, + "monitor": ActionPrint, + "export": ActionPrint, + } + + var path []string + var action string + actionIdx := -1 + + // If the first token starts with '/', it's a direct RouterOS path, e.g. `/ip/address/print` or `/ip address print` + if strings.HasPrefix(tokens[0], "/") { + trimmed := strings.TrimPrefix(tokens[0], "/") + parts := strings.Split(trimmed, "/") + if len(parts) > 1 { + last := parts[len(parts)-1] + if _, ok := knownActions[last]; ok { + path = parts[:len(parts)-1] + action = last + actionIdx = 0 + } else { + path = parts + } + } else { + path = parts + } + } + + if action == "" { + for i, tok := range tokens { + if strings.Contains(tok, "=") { + // We hit key=value arguments + break + } + if strings.HasPrefix(tok, "-") { + // We hit a flag + break + } + if _, ok := knownActions[strings.ToLower(tok)]; ok { + action = strings.ToLower(tok) + actionIdx = i + break + } + path = append(path, tok) + } + } + + if action == "" { + // Default to "print" if only a path was specified + action = "print" + } + + // Parse remaining tokens as arguments (key=value) or flags + args := make(map[string]string) + var flags []string + startIndex := actionIdx + 1 + if actionIdx == -1 { + startIndex = len(path) + } + + for i := startIndex; i < len(tokens); i++ { + tok := tokens[i] + if strings.Contains(tok, "=") { + k, v, _ := strings.Cut(tok, "=") + args[strings.TrimSpace(k)] = strings.TrimSpace(v) + } else { + flags = append(flags, tok) + } + } + + req := &ROSRequest{ + Path: path, + Action: action, + Args: args, + Flags: flags, + } + + // Look up mapping in static catalog + if mapping, found := LookupCommand(path, action); found { + req.Mapping = mapping + // Validate required arguments if any + for _, argSpec := range mapping.Arguments { + if argSpec.Required { + if _, ok := args[argSpec.Name]; !ok { + return nil, NewUsageError( + fmt.Sprintf("missing required argument %q for command %s %s", argSpec.Name, strings.Join(path, " "), action), + fmt.Sprintf("pass %s=", argSpec.Name), + ) + } + } + } + } else { + // Dynamic mapping for valid unlisted RouterOS CLI path + actionKind := ActionRaw + if k, ok := knownActions[action]; ok { + actionKind = k + } + rosPath := "/" + strings.Join(path, "/") + "/" + action + req.Mapping = &CommandMapping{ + CLIPath: path, + Action: action, + ActionKind: actionKind, + RouterOSPath: rosPath, + SideEffects: nil, + Idempotency: "unknown", + Summary: "RouterOS command: " + rosPath, + } + } + + return req, nil +} + +// BuildRouterOSCommand formats the RouterOS CLI command line string to be executed over SSH. +func BuildRouterOSCommand(req *ROSRequest) string { + if req.Mapping != nil && req.Mapping.IsRaw() { + var sb strings.Builder + rawCmd := strings.TrimSpace(req.RawCommand) + if rawCmd == "" { + rawCmd = req.Action + } + if !strings.HasPrefix(rawCmd, "/") && !strings.HasPrefix(rawCmd, ":") { + rawCmd = "/" + rawCmd + } + sb.WriteString(rawCmd) + + for _, flag := range req.Flags { + sb.WriteString(" ") + sb.WriteString(flag) + } + + if len(req.Args) > 0 { + keys := make([]string, 0, len(req.Args)) + for k := range req.Args { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + v := req.Args[k] + sb.WriteString(" ") + if strings.Contains(v, " ") && !strings.HasPrefix(v, "\"") { + fmt.Fprintf(&sb, "%s=%q", k, v) + } else { + fmt.Fprintf(&sb, "%s=%s", k, v) + } + } + } + + if req.Mapping.ActionKind == ActionPrint { + lower := strings.ToLower(sb.String()) + if !strings.Contains(lower, "without-paging") { + sb.WriteString(" without-paging") + } + } + + return sb.String() + } + + var sb strings.Builder + sb.WriteString("/") + sb.WriteString(strings.Join(req.Path, " ")) + sb.WriteString(" ") + sb.WriteString(req.Action) + + // Add key=value arguments in deterministic sorted order + if len(req.Args) > 0 { + keys := make([]string, 0, len(req.Args)) + for k := range req.Args { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + v := req.Args[k] + sb.WriteString(" ") + if strings.Contains(v, " ") && !strings.HasPrefix(v, "\"") { + fmt.Fprintf(&sb, "%s=%q", k, v) + } else { + fmt.Fprintf(&sb, "%s=%s", k, v) + } + } + } + + // Add flags + for _, flag := range req.Flags { + sb.WriteString(" ") + sb.WriteString(flag) + } + + // If print command and without-paging is not already present, append without-paging + if req.Action == "print" { + hasPagingFlag := false + for _, f := range req.Flags { + if strings.EqualFold(f, "without-paging") { + hasPagingFlag = true + break + } + } + if !hasPagingFlag { + sb.WriteString(" without-paging") + } + } + + return sb.String() +} diff --git a/internal/ros/catalog_test.go b/internal/ros/catalog_test.go new file mode 100644 index 0000000..c8e61a0 --- /dev/null +++ b/internal/ros/catalog_test.go @@ -0,0 +1,109 @@ +package ros + +import ( + "testing" +) + +func TestAllCommands(t *testing.T) { + cmds := AllCommands() + if len(cmds) == 0 { + t.Fatal("expected non-empty static command catalog") + } + + for _, cmd := range cmds { + if len(cmd.CLIPath) == 0 { + t.Errorf("command has empty CLI path") + } + if cmd.Action == "" { + t.Errorf("command %v has empty action", cmd.CLIPath) + } + if cmd.RouterOSPath == "" { + t.Errorf("command %v has empty ROS path", cmd.CLIPath) + } + } +} + +func TestLookupCommand(t *testing.T) { + tests := []struct { + path []string + action string + found bool + }{ + {[]string{"ip", "address"}, "print", true}, + {[]string{"ip", "address"}, "add", true}, + {[]string{"interface"}, "print", true}, + {[]string{"system", "resource"}, "print", true}, + {[]string{"raw"}, "raw", true}, + {[]string{"raw"}, "", true}, + {nil, "raw", true}, + {[]string{"command"}, "raw", true}, + {[]string{"unknown", "path"}, "print", false}, + } + + for _, tc := range tests { + mapping, ok := LookupCommand(tc.path, tc.action) + if ok != tc.found { + t.Errorf("lookup %v %s: expected found=%v, got %v", tc.path, tc.action, tc.found, ok) + } + if tc.found && mapping == nil { + t.Errorf("expected mapping not to be nil") + } + } +} + +func TestParseInvocation(t *testing.T) { + // 1. IP address print + req, err := ParseInvocation([]string{"ip", "address", "print", "detail"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if req.Action != "print" { + t.Errorf("expected action print, got %s", req.Action) + } + if len(req.Flags) != 1 || req.Flags[0] != "detail" { + t.Errorf("expected flags [detail], got %v", req.Flags) + } + + cmdStr := BuildRouterOSCommand(req) + if cmdStr != "/ip address print detail without-paging" { + t.Errorf("expected '/ip address print detail without-paging', got %q", cmdStr) + } + + // 2. IP address add + reqAdd, err := ParseInvocation([]string{"ip", "address", "add", "address=192.168.88.1/24", "interface=ether1"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if reqAdd.Args["address"] != "192.168.88.1/24" { + t.Errorf("expected address arg, got %v", reqAdd.Args) + } + if reqAdd.Args["interface"] != "ether1" { + t.Errorf("expected interface arg, got %v", reqAdd.Args) + } + + // 3. Raw command + reqRaw, err := ParseInvocation([]string{"raw", "/system/clock/print"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if reqRaw.Action != "raw" || reqRaw.RawCommand != "/system/clock/print" { + t.Errorf("expected raw command, got %+v", reqRaw) + } + rawStr := BuildRouterOSCommand(reqRaw) + if rawStr != "/system/clock/print without-paging" { + t.Errorf("expected '/system/clock/print without-paging', got %q", rawStr) + } + + // 4. Raw command with args and flags + reqRawAdd, err := ParseInvocation([]string{"raw", "/ip/address/add", "address=10.0.0.1/24", "interface=ether2"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if reqRawAdd.Args["address"] != "10.0.0.1/24" || reqRawAdd.Args["interface"] != "ether2" { + t.Errorf("expected raw add args, got %+v", reqRawAdd.Args) + } + rawAddStr := BuildRouterOSCommand(reqRawAdd) + if rawAddStr != "/ip/address/add address=10.0.0.1/24 interface=ether2" { + t.Errorf("expected '/ip/address/add address=10.0.0.1/24 interface=ether2', got %q", rawAddStr) + } +} diff --git a/internal/ros/introspect.go b/internal/ros/introspect.go new file mode 100644 index 0000000..88fc714 --- /dev/null +++ b/internal/ros/introspect.go @@ -0,0 +1,283 @@ +package ros + +import ( + "encoding/json" + "fmt" + "strings" +) + +// CommandsPayload mirrors roswire.commands.v1 schema. +type CommandsPayload struct { + SchemaVersion string `json:"schema_version"` + Commands []CommandSummary `json:"commands"` +} + +type CommandSummary struct { + Name string `json:"name"` + Summary string `json:"summary"` + Kind string `json:"kind"` +} + +// HelpIndexPayload mirrors roswire.help.index.v1 schema. +type HelpIndexPayload struct { + SchemaVersion string `json:"schema_version"` + GlobalOptions []string `json:"global_options"` + Commands []CommandSummary `json:"commands"` +} + +// HelpCommandPayload mirrors roswire.help.command.v1 schema. +type HelpCommandPayload struct { + SchemaVersion string `json:"schema_version"` + Command CommandMapping `json:"command"` +} + +// SchemaPayload mirrors roswire.schema.v1 schema. +type SchemaPayload struct { + SchemaVersion string `json:"schema_version"` + Command string `json:"command"` + Arguments []ArgumentSpec `json:"arguments"` +} + +// ExplainErrorPayload mirrors roswire.explain_error.v1 schema. +type ExplainErrorPayload struct { + SchemaVersion string `json:"schema_version"` + ErrorCode string `json:"error_code"` + Summary string `json:"summary"` + CommonCauses []string `json:"common_causes"` + SuggestedNextSteps []string `json:"suggested_next_steps"` +} + +// DoctorPayload mirrors roswire.doctor.v1 schema. +type DoctorPayload struct { + SchemaVersion string `json:"schema_version"` + Local LocalDoctor `json:"local"` + Remote *RemoteDoctor `json:"remote,omitempty"` + SelectedProtocol string `json:"selected_protocol"` + RouterOSVersion string `json:"routeros_version,omitempty"` //nolint:misspell // RouterOS domain field + Warnings []string `json:"warnings"` +} + +type LocalDoctor struct { + HomeExists bool `json:"home_exists"` + ConfigExists bool `json:"config_exists"` + PermissionsOK bool `json:"permissions_ok"` + SSHKeysFound []string `json:"ssh_keys_found,omitempty"` + Dependencies map[string]string `json:"dependencies"` + Warnings []string `json:"warnings"` +} + +type RemoteDoctor struct { + Status string `json:"status"` + RouterOSVersion string `json:"routeros_version,omitempty"` //nolint:misspell // RouterOS domain field + Architecture string `json:"architecture,omitempty"` + BoardName string `json:"board_name,omitempty"` + Uptime string `json:"uptime,omitempty"` + Warnings []string `json:"warnings"` +} + +// RenderCommandsJSON returns the JSON string for `commands`. +func RenderCommandsJSON() (string, error) { + cmds := AllCommands() + summaries := make([]CommandSummary, len(cmds)) + for i, c := range cmds { + name := strings.Join(c.CLIPath, " ") + if c.Action != "" && c.Action != "raw" { + name += " " + c.Action + } + summaries[i] = CommandSummary{ + Name: name, + Summary: c.Summary, + Kind: string(c.ActionKind), + } + } + payload := CommandsPayload{ + SchemaVersion: "roswire.commands.v1", + Commands: summaries, + } + b, err := json.MarshalIndent(payload, "", " ") + return string(b), err +} + +// RenderHelpJSON returns the JSON string for `help` or `help `. +func RenderHelpJSON(tokens []string) (string, error) { + if len(tokens) == 0 { + cmds := AllCommands() + summaries := make([]CommandSummary, len(cmds)) + for i, c := range cmds { + name := strings.Join(c.CLIPath, " ") + if c.Action != "" && c.Action != "raw" { + name += " " + c.Action + } + summaries[i] = CommandSummary{ + Name: name, + Summary: c.Summary, + Kind: string(c.ActionKind), + } + } + payload := HelpIndexPayload{ + SchemaVersion: "roswire.help.index.v1", + GlobalOptions: []string{ + "-h, --host", + "-p, --port", + "-u, --user", + "-i, --key", + "--ssh-password-key", + "--json", + "--dry-run", + "--allow-write", + "--force", + "--timeout", + "--raw", + }, + Commands: summaries, + } + b, err := json.MarshalIndent(payload, "", " ") + return string(b), err + } + + // Lookup specific command + path := tokens[:len(tokens)-1] + action := tokens[len(tokens)-1] + mapping, ok := LookupCommand(path, action) + if !ok { + // Try dynamic or entire tokens as path with "print" + mapping, ok = LookupCommand(tokens, "print") + } + if !ok && len(tokens) == 1 { + mapping, ok = LookupCommand(tokens, "") + } + if !ok { + return "", NewUsageError(fmt.Sprintf("unknown command %q for help", strings.Join(tokens, " ")), "run `sshx ros commands` to list all available commands") + } + + payload := HelpCommandPayload{ + SchemaVersion: "roswire.help.command.v1", + Command: *mapping, + } + b, err := json.MarshalIndent(payload, "", " ") + return string(b), err +} + +// RenderSchemaJSON returns the JSON string for `schema `. +func RenderSchemaJSON(tokens []string) (string, error) { + if len(tokens) == 0 { + return "", NewUsageError("schema requires a command", "example: `sshx ros schema ip address add`") + } + + path := tokens[:len(tokens)-1] + action := tokens[len(tokens)-1] + mapping, ok := LookupCommand(path, action) + if !ok { + mapping, ok = LookupCommand(tokens, "print") + } + if !ok && len(tokens) == 1 { + mapping, ok = LookupCommand(tokens, "") + } + if !ok { + return "", NewUsageError(fmt.Sprintf("unknown command %q for schema", strings.Join(tokens, " ")), "run `sshx ros commands` to list all available commands") + } + + cmdName := strings.Join(mapping.CLIPath, " ") + if mapping.Action != "" && mapping.Action != "raw" { + cmdName += " " + mapping.Action + } + + payload := SchemaPayload{ + SchemaVersion: "roswire.schema.v1", + Command: cmdName, + Arguments: mapping.Arguments, + } + b, err := json.MarshalIndent(payload, "", " ") + return string(b), err +} + +var errorExplanations = map[string]ExplainErrorPayload{ + ErrCodeUsageError: { + SchemaVersion: "roswire.explain_error.v1", + ErrorCode: ErrCodeUsageError, + Summary: "Command usage or argument specification error", + CommonCauses: []string{ + "Missing required arguments (e.g. address=... interface=...)", + "Invalid subcommand or action syntax", + "Raw mutating command executed without --allow-write", + }, + SuggestedNextSteps: []string{ + "Run `sshx ros help ` to check the argument syntax", + "Add --allow-write if you intend to perform raw state-modifying operations", + }, + }, + ErrCodeDangerousCommandBlocked: { + SchemaVersion: "roswire.explain_error.v1", + ErrorCode: ErrCodeDangerousCommandBlocked, + Summary: "Potentially destructive router operation was blocked by safety guardrail", + CommonCauses: []string{ + "Command matches destructive patterns like reset-configuration, reboot, or shutdown", + }, + SuggestedNextSteps: []string{ + "If this action was intentional, pass --force to bypass safety guardrail", + }, + }, + ErrCodeAuthFailed: { + SchemaVersion: "roswire.explain_error.v1", + ErrorCode: ErrCodeAuthFailed, + Summary: "SSH authentication to RouterOS failed", + CommonCauses: []string{ + "Invalid username or password", + "Public key not installed or configured on RouterOS (/user ssh-keys)", + "Incorrect password keyring key name", + }, + SuggestedNextSteps: []string{ + "Check your username (-u) and SSH key (-i) or password key (--ssh-password-key)", + "Verify login credentials via direct `ssh user@host`", + }, + }, + ErrCodeNetworkError: { + SchemaVersion: "roswire.explain_error.v1", + ErrorCode: ErrCodeNetworkError, + Summary: "Unable to establish SSH TCP connection to RouterOS", + CommonCauses: []string{ + "RouterOS host IP unreachable or firewall blocking port 22", + "RouterOS SSH service (/ip service ssh) is disabled or listening on a custom port", + }, + SuggestedNextSteps: []string{ + "Verify network route and ping to the router", + "Specify custom port with -p= if SSH is running on a non-standard port", + }, + }, + ErrCodeFileTransferFailed: { + SchemaVersion: "roswire.explain_error.v1", + ErrorCode: ErrCodeFileTransferFailed, + Summary: "SFTP file transfer failed between local host and RouterOS", + CommonCauses: []string{ + "Remote path invalid or permission denied on RouterOS disk/flash", + "Disk full on target RouterBOARD storage", + }, + SuggestedNextSteps: []string{ + "Check storage capacity with `sshx ros -h=router system resource print`", + "Verify target directory name (e.g. flash/ on NAND devices)", + }, + }, +} + +// RenderExplainErrorJSON returns the JSON explanation for an error code. +func RenderExplainErrorJSON(code string) (string, error) { + code = strings.ToUpper(strings.TrimSpace(code)) + if exp, ok := errorExplanations[code]; ok { + b, err := json.MarshalIndent(exp, "", " ") + return string(b), err + } + // Default fallback + exp := ExplainErrorPayload{ + SchemaVersion: "roswire.explain_error.v1", + ErrorCode: code, + Summary: "RouterOS operational error: " + code, + CommonCauses: []string{ + "Check execution context and error message for details", + }, + SuggestedNextSteps: []string{ + "Run `sshx ros doctor` to inspect environment health", + }, + } + b, err := json.MarshalIndent(exp, "", " ") + return string(b), err +} diff --git a/internal/ros/parse.go b/internal/ros/parse.go new file mode 100644 index 0000000..1526ed2 --- /dev/null +++ b/internal/ros/parse.go @@ -0,0 +1,179 @@ +package ros + +import ( + "encoding/json" + "regexp" + "strconv" + "strings" +) + +var ( + // Matches colon-separated single-record outputs (e.g. `/system resource print`) + colonRecordRegex = regexp.MustCompile(`^\s*([a-zA-Z0-9_\-\.]+)\s*:\s*(.*?)\s*$`) + + // Matches starting item index and optional flags, e.g. " 0 ;;; defconf" or " 1 D address=..." or " 0 name=..." + itemStartRegex = regexp.MustCompile(`^\s*(\d+)\s*([A-Z*]*)\s*(.*)$`) + + // Matches comments in detail output: ";;; comment text" + commentRegex = regexp.MustCompile(`^;;;\s*(.*)$`) + + // Tokenizes key=value or key="quoted value" pairs + kvPairRegex = regexp.MustCompile(`([a-zA-Z0-9_\-\.]+)=("(?:[^"\\]|\\.)*"|\S+)`) +) + +// ParseRouterOSOutput attempts to parse RouterOS output into structured JSON-compatible data. +// If the output cannot be parsed into structured records, it returns nil and the original string. +func ParseRouterOSOutput(output string) (any, bool) { + trimmed := strings.TrimSpace(output) + if trimmed == "" { + return []map[string]any{}, true + } + + // 1. If it's already JSON (e.g. from ROS v7 :serialize to=json) + if (strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]")) || + (strings.HasPrefix(trimmed, "{") && strings.HasSuffix(trimmed, "}")) { + var val any + if err := json.Unmarshal([]byte(trimmed), &val); err == nil { + return val, true + } + } + + lines := strings.Split(output, "\n") + + // 2. Try single-record key: value format + if record, ok := tryParseColonRecord(lines); ok { + return record, true + } + + // 3. Try multi-item detail/terse format + if items, ok := tryParseItemRecords(lines); ok && len(items) > 0 { + return items, true + } + + return nil, false +} + +// tryParseColonRecord checks if all non-empty lines are `key: value` format. +func tryParseColonRecord(lines []string) (map[string]any, bool) { + record := make(map[string]any) + matchedLines := 0 + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + // Skip header flags like "Flags: X - disabled..." + if strings.HasPrefix(trimmed, "Flags:") { + continue + } + matches := colonRecordRegex.FindStringSubmatch(line) + if len(matches) != 3 { + return nil, false + } + key := strings.TrimSpace(matches[1]) + val := strings.TrimSpace(matches[2]) + record[key] = coerceValue(val) + matchedLines++ + } + + if matchedLines == 0 { + return nil, false + } + return record, true +} + +// tryParseItemRecords parses RouterOS `print detail` or `print terse` outputs. +func tryParseItemRecords(lines []string) ([]map[string]any, bool) { + var items []map[string]any + var currentItem map[string]any + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + // Skip legend header + if strings.HasPrefix(trimmed, "Flags:") { + continue + } + + // Check if line starts with a new item number: " 0 ...", " 1 D ..." + sub := itemStartRegex.FindStringSubmatch(line) + // To be a valid item start, the line shouldn't be indented by many spaces and must match a digit index + if len(sub) == 4 && len(line) > 0 && (line[0] != '\t' && (len(line) < 4 || line[:4] != " ")) { + // Finish previous item + if currentItem != nil { + items = append(items, currentItem) + } + currentItem = make(map[string]any) + indexStr := sub[1] + flags := strings.TrimSpace(sub[2]) + rest := strings.TrimSpace(sub[3]) + + if idx, err := strconv.Atoi(indexStr); err == nil { + currentItem[".id"] = idx + } else { + currentItem[".id"] = indexStr + } + if flags != "" { + currentItem["flags"] = flags + } + + // Check if rest contains a comment + if commentMatches := commentRegex.FindStringSubmatch(rest); len(commentMatches) == 2 { + currentItem["comment"] = strings.TrimSpace(commentMatches[1]) + } else if rest != "" { + extractKVPairs(rest, currentItem) + } + continue + } + + // Continuation of current item + if currentItem != nil { + if commentMatches := commentRegex.FindStringSubmatch(trimmed); len(commentMatches) == 2 { + currentItem["comment"] = strings.TrimSpace(commentMatches[1]) + } else { + extractKVPairs(trimmed, currentItem) + } + } + } + + if currentItem != nil { + items = append(items, currentItem) + } + + if len(items) > 0 { + return items, true + } + return nil, false +} + +// extractKVPairs extracts key=value or key="val" from a line. +func extractKVPairs(line string, target map[string]any) { + matches := kvPairRegex.FindAllStringSubmatch(line, -1) + for _, m := range matches { + if len(m) == 3 { + key := m[1] + val := m[2] + if strings.HasPrefix(val, "\"") && strings.HasSuffix(val, "\"") && len(val) >= 2 { + val = val[1 : len(val)-1] + } + target[key] = coerceValue(val) + } + } +} + +// coerceValue converts string primitives like true/false or numbers when appropriate. +func coerceValue(val string) any { + if strings.EqualFold(val, "yes") || strings.EqualFold(val, "true") { + return true + } + if strings.EqualFold(val, "no") || strings.EqualFold(val, "false") { + return false + } + if n, err := strconv.ParseInt(val, 10, 64); err == nil { + return n + } + return val +} diff --git a/internal/ros/parse_test.go b/internal/ros/parse_test.go new file mode 100644 index 0000000..74f9271 --- /dev/null +++ b/internal/ros/parse_test.go @@ -0,0 +1,88 @@ +package ros + +import ( + "reflect" + "testing" +) + +func TestParseColonRecord(t *testing.T) { + output := ` + uptime: 1w3d4h20m + version: 7.14.3 (stable) + build-time: Apr/18/2024 10:00:00 + free-memory: 245.2MiB + total-memory: 256.0MiB + cpu-count: 1 + cpu-load: 5% +` + res, ok := ParseRouterOSOutput(output) + if !ok { + t.Fatalf("expected colon record parsing to succeed") + } + + record, ok := res.(map[string]any) + if !ok { + t.Fatalf("expected map[string]any, got %T", res) + } + + if record["version"] != "7.14.3 (stable)" { + t.Errorf("expected version, got %v", record["version"]) + } + if record["cpu-count"] != int64(1) { + t.Errorf("expected cpu-count 1, got %v", record["cpu-count"]) + } +} + +func TestParseItemRecords(t *testing.T) { + output := `Flags: X - disabled, I - invalid, D - dynamic + 0 ;;; defconf + address=192.168.88.1/24 network=192.168.88.0 interface=bridge + actual-interface=bridge + + 1 D address=10.0.0.2/24 network=10.0.0.0 interface=ether1 + actual-interface=ether1 +` + res, ok := ParseRouterOSOutput(output) + if !ok { + t.Fatalf("expected item records parsing to succeed") + } + + items, ok := res.([]map[string]any) + if !ok { + t.Fatalf("expected []map[string]any, got %T", res) + } + + if len(items) != 2 { + t.Fatalf("expected 2 items, got %d", len(items)) + } + + if items[0]["comment"] != "defconf" { + t.Errorf("item 0 comment: got %v", items[0]["comment"]) + } + if items[0]["address"] != "192.168.88.1/24" { + t.Errorf("item 0 address: got %v", items[0]["address"]) + } + if items[0]["interface"] != "bridge" { + t.Errorf("item 0 interface: got %v", items[0]["interface"]) + } + + if items[1]["flags"] != "D" { + t.Errorf("item 1 flags: got %v", items[1]["flags"]) + } + if items[1]["address"] != "10.0.0.2/24" { + t.Errorf("item 1 address: got %v", items[1]["address"]) + } +} + +func TestParseJSONOutput(t *testing.T) { + jsonStr := `[{"address":"192.168.88.1/24","interface":"bridge"}]` + res, ok := ParseRouterOSOutput(jsonStr) + if !ok { + t.Fatalf("expected JSON parsing to succeed") + } + + slice, ok := res.([]any) + if !ok || len(slice) != 1 { + t.Fatalf("expected []any of length 1, got %v", reflect.TypeOf(res)) + } +} diff --git a/internal/ros/safety.go b/internal/ros/safety.go new file mode 100644 index 0000000..305b30f --- /dev/null +++ b/internal/ros/safety.go @@ -0,0 +1,84 @@ +package ros + +import ( + "fmt" + "strings" + "unicode/utf8" +) + +// MaxScriptSourceBytes is the maximum allowed size for uploaded script source files (256 KB). +const MaxScriptSourceBytes = 256 * 1024 + +// dangerousPatterns contains known destructive RouterOS operations that must be guarded. +var dangerousPatterns = []struct { + pattern string + reason string +}{ + {"reset-configuration", "wipes complete router configuration"}, + {"system/reboot", "reboots the remote router"}, + {"system reboot", "reboots the remote router"}, + {"system/shutdown", "shuts down the remote router"}, + {"system shutdown", "shuts down the remote router"}, + {"disk format", "formats router storage partition"}, + {"disk/format", "formats router storage partition"}, + {"system/package/uninstall", "uninstalls router system package"}, + {"system package uninstall", "uninstalls router system package"}, +} + +// ValidateSafety checks whether a RouterOS command is dangerous or requires mutation confirmation. +func ValidateSafety(req *ROSRequest, allowWrite bool, force bool) *ROSErr { + cmdStr := strings.ToLower(BuildRouterOSCommand(req)) + + // Check for dangerous destructive commands + for _, dp := range dangerousPatterns { + if strings.Contains(cmdStr, dp.pattern) { + if !force { + return NewDangerousCommandError( + cmdStr, + dp.reason, + "use --force to bypass safety guardrail", + ) + } + } + } + + // Raw mutative commands require --allow-write or --force + if req.Mapping != nil && req.Mapping.IsRaw() && req.Mapping.ActionKind != ActionPrint { + if !allowWrite && !force { + return &ROSErr{ + ErrorCode: ErrCodeUsageError, + Message: "raw RouterOS commands that may mutate state require --allow-write", + Hint: "use raw /.../print for read-only commands, or add --allow-write for explicit raw writes", + ExitCode: 2, + Context: ROSContext{ + Command: req.RawCommand, + }, + } + } + } + + return nil +} + +// ValidateScriptSource checks size and UTF-8 validity of script contents. +func ValidateScriptSource(content []byte, sourcePath string) *ROSErr { + if len(content) > MaxScriptSourceBytes { + return &ROSErr{ + ErrorCode: ErrCodeFileTooLarge, + Message: fmt.Sprintf("script source %s exceeds %d bytes (got %d)", sourcePath, MaxScriptSourceBytes, len(content)), + Hint: "reduce script size or split into multiple scripts", + ExitCode: 2, + } + } + + if !utf8.Valid(content) { + return &ROSErr{ + ErrorCode: ErrCodeUsageError, + Message: fmt.Sprintf("script source %s must be UTF-8 text", sourcePath), + Hint: "save the .rsc file as UTF-8 text", + ExitCode: 2, + } + } + + return nil +} diff --git a/internal/ros/safety_test.go b/internal/ros/safety_test.go new file mode 100644 index 0000000..58a937d --- /dev/null +++ b/internal/ros/safety_test.go @@ -0,0 +1,77 @@ +package ros + +import ( + "testing" +) + +func TestValidateSafety_DangerousCommands(t *testing.T) { + dangerous := []string{ + "/system/reset-configuration", + "/system reset-configuration keep-users=yes", + "/system/reboot", + "/disk format drive=nand", + } + + for _, cmd := range dangerous { + req, err := ParseInvocation([]string{"raw", cmd}) + if err != nil { + t.Fatalf("unexpected invocation error: %v", err) + } + + // Blocked without force + safeErr := ValidateSafety(req, true, false) + if safeErr == nil { + t.Errorf("expected dangerous command %q to be blocked without force", cmd) + } else if safeErr.ErrorCode != ErrCodeDangerousCommandBlocked { + t.Errorf("expected %s, got %s", ErrCodeDangerousCommandBlocked, safeErr.ErrorCode) + } + + // Allowed with force + safeErrForce := ValidateSafety(req, true, true) + if safeErrForce != nil { + t.Errorf("expected dangerous command %q to be allowed with force, got %v", cmd, safeErrForce) + } + } +} + +func TestValidateSafety_RawMutations(t *testing.T) { + // Raw write command without allowWrite or force + req, err := ParseInvocation([]string{"raw", "/ip/address/add address=1.1.1.1/24 interface=ether1"}) + if err != nil { + t.Fatalf("unexpected invocation error: %v", err) + } + + safeErr := ValidateSafety(req, false, false) + if safeErr == nil { + t.Errorf("expected raw mutation to be blocked without allowWrite") + } + + safeErrAllowed := ValidateSafety(req, true, false) + if safeErrAllowed != nil { + t.Errorf("expected raw mutation to be allowed with allowWrite") + } +} + +func TestValidateScriptSource(t *testing.T) { + // Valid small script + valid := []byte(":put \"hello world\"\n") + if err := ValidateScriptSource(valid, "test.rsc"); err != nil { + t.Errorf("unexpected error for valid script: %v", err) + } + + // Too large script + large := make([]byte, MaxScriptSourceBytes+1) + if err := ValidateScriptSource(large, "large.rsc"); err == nil { + t.Errorf("expected too large error") + } else if err.ErrorCode != ErrCodeFileTooLarge { + t.Errorf("expected %s, got %s", ErrCodeFileTooLarge, err.ErrorCode) + } + + // Non-UTF8 script + invalidUTF8 := []byte{0xff, 0xfe, 0xfd} + if err := ValidateScriptSource(invalidUTF8, "binary.rsc"); err == nil { + t.Errorf("expected non-utf8 error") + } else if err.ErrorCode != ErrCodeUsageError { + t.Errorf("expected %s, got %s", ErrCodeUsageError, err.ErrorCode) + } +} diff --git a/internal/ros/types.go b/internal/ros/types.go new file mode 100644 index 0000000..215f367 --- /dev/null +++ b/internal/ros/types.go @@ -0,0 +1,149 @@ +package ros + +import ( + "encoding/json" + "fmt" +) + +// ActionKind represents the semantic kind of a RouterOS action. +type ActionKind string + +const ( + ActionPrint ActionKind = "print" + ActionAdd ActionKind = "add" + ActionSet ActionKind = "set" + ActionRemove ActionKind = "remove" + ActionRaw ActionKind = "raw-routeros-command" //nolint:misspell // RouterOS domain constant +) + +// Standard Error Codes matching roswire contract. +const ( + ErrCodeUsageError = "USAGE_ERROR" + ErrCodeConfigError = "CONFIG_ERROR" + ErrCodeAuthFailed = "AUTH_FAILED" + ErrCodeNetworkError = "NETWORK_ERROR" + ErrCodeRosExecError = "ROS_EXEC_ERROR" + ErrCodeDangerousCommandBlocked = "DANGEROUS_COMMAND_BLOCKED" + ErrCodeUnsupportedAction = "UNSUPPORTED_ACTION" + ErrCodeFileTooLarge = "FILE_TOO_LARGE" + ErrCodeFileTransferFailed = "FILE_TRANSFER_FAILED" + ErrCodeSchemaUnavailable = "SCHEMA_UNAVAILABLE" + ErrCodeInternalError = "INTERNAL_ERROR" +) + +// ArgumentSpec defines one argument accepted by a RouterOS command. +type ArgumentSpec struct { + Name string `json:"name"` + Style string `json:"style"` // "key-value", "flag", "positional" + Required bool `json:"required"` + Type string `json:"type"` // "string", "ip", "cidr", "int", "bool", etc. + Description string `json:"description"` + Example string `json:"example,omitempty"` +} + +// CommandMapping specifies the canonical definition of a statically supported RouterOS command. +type CommandMapping struct { + CLIPath []string `json:"cli_path"` + Action string `json:"action"` + ActionKind ActionKind `json:"action_kind"` + RouterOSPath string `json:"routeros_path"` //nolint:misspell // RouterOS domain field + SideEffects []string `json:"side_effects"` + Idempotency string `json:"idempotency"` // "read-only", "idempotent", "not-idempotent" + Summary string `json:"summary"` + Arguments []ArgumentSpec `json:"arguments,omitempty"` + Examples []string `json:"examples,omitempty"` +} + +func (m *CommandMapping) IsRaw() bool { + return len(m.CLIPath) == 1 && m.CLIPath[0] == "raw" +} + +// ROSRequest contains the parsed RouterOS command invocation. +type ROSRequest struct { + Path []string `json:"path"` + Action string `json:"action"` + Args map[string]string `json:"args"` + Flags []string `json:"flags"` + RawCommand string `json:"raw_command,omitempty"` + Mapping *CommandMapping `json:"mapping,omitempty"` + WorkflowName string `json:"workflow_name,omitempty"` +} + +// ROSPlan is the execution plan emitted for dry-run preview. +type ROSPlan struct { + SchemaVersion string `json:"schema_version"` + DryRun bool `json:"dry_run"` + Command string `json:"command"` + RouterOSPath string `json:"routeros_path"` //nolint:misspell // RouterOS domain field + Action string `json:"action"` + ResolvedArgs map[string]string `json:"resolved_args"` + Flags []string `json:"flags"` + SideEffects []string `json:"side_effects"` + Idempotency string `json:"idempotency"` + WillConnect bool `json:"will_connect"` + WillModifyRouterOS bool `json:"will_modify_routeros"` //nolint:misspell // RouterOS domain field +} + +// ROSContext captures the contextual parameters for structured error reporting. +type ROSContext struct { + Command string `json:"command"` + Path []string `json:"path,omitempty"` + Action string `json:"action,omitempty"` + Host string `json:"host,omitempty"` + ResolvedArgs map[string]string `json:"resolved_args,omitempty"` +} + +// ROSErr represents a structured RouterOS error adhering to the roswire JSON contract. +type ROSErr struct { + ErrorCode string `json:"error_code"` + Message string `json:"message"` + Hint string `json:"hint,omitempty"` + Context ROSContext `json:"context"` + ExitCode int `json:"-"` +} + +func (e *ROSErr) Error() string { + if e.Hint != "" { + return fmt.Sprintf("[%s] %s (hint: %s)", e.ErrorCode, e.Message, e.Hint) + } + return fmt.Sprintf("[%s] %s", e.ErrorCode, e.Message) +} + +// NewUsageError creates a usage-level error. +func NewUsageError(msg string, hint string) *ROSErr { + return &ROSErr{ + ErrorCode: ErrCodeUsageError, + Message: msg, + Hint: hint, + ExitCode: 2, + } +} + +// NewConfigError creates a configuration error. +func NewConfigError(msg string, hint string) *ROSErr { + return &ROSErr{ + ErrorCode: ErrCodeConfigError, + Message: msg, + Hint: hint, + ExitCode: 2, + } +} + +// NewDangerousCommandError creates a safety guardrail error. +func NewDangerousCommandError(command, reason, hint string) *ROSErr { + return &ROSErr{ + ErrorCode: ErrCodeDangerousCommandBlocked, + Message: fmt.Sprintf("dangerous RouterOS command blocked (%s): %s", command, reason), + Hint: hint, + ExitCode: 255, + } +} + +// ToJSON returns the formatted JSON string for the error. +func (e *ROSErr) ToJSON() string { + data, err := json.MarshalIndent(e, "", " ") + if err != nil { + return fmt.Sprintf(`{"error_code":"%s","message":%q}`, e.ErrorCode, e.Message) + } + return string(data) +} diff --git a/internal/ros/workflow.go b/internal/ros/workflow.go new file mode 100644 index 0000000..2acfc7c --- /dev/null +++ b/internal/ros/workflow.go @@ -0,0 +1,228 @@ +package ros + +import ( + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/pkg/sftp" +) + +// WorkflowType identifies the kind of RouterOS file/script workflow. +type WorkflowType string + +const ( + WorkflowFileUpload WorkflowType = "file_upload" + WorkflowFileDownload WorkflowType = "file_download" + WorkflowFileList WorkflowType = "file_list" + WorkflowScriptPut WorkflowType = "script_put" + WorkflowImport WorkflowType = "import" + WorkflowExportDownload WorkflowType = "export_download" + WorkflowBackupDownload WorkflowType = "backup_download" +) + +// WorkflowSpec defines the parameters for a workflow execution. +type WorkflowSpec struct { + Type WorkflowType `json:"type"` + LocalPath string `json:"local_path,omitempty"` + RemotePath string `json:"remote_path,omitempty"` + ScriptName string `json:"script_name,omitempty"` + BackupName string `json:"backup_name,omitempty"` + Compact bool `json:"compact,omitempty"` + Cleanup bool `json:"cleanup,omitempty"` +} + +// ParseWorkflowCommand detects if the CLI tokens form a workflow command. +func ParseWorkflowCommand(tokens []string, sourceFlag string) (*WorkflowSpec, bool) { + if len(tokens) == 0 { + return nil, false + } + + switch tokens[0] { + case "file": + if len(tokens) >= 3 && tokens[1] == "upload" { + return &WorkflowSpec{ + Type: WorkflowFileUpload, + LocalPath: tokens[2], + RemotePath: getOptionalToken(tokens, 3, filepath.Base(tokens[2])), + }, true + } + if len(tokens) >= 3 && tokens[1] == "download" { + return &WorkflowSpec{ + Type: WorkflowFileDownload, + RemotePath: tokens[2], + LocalPath: getOptionalToken(tokens, 3, filepath.Base(tokens[2])), + }, true + } + if len(tokens) >= 2 && (tokens[1] == "list" || tokens[1] == "ls") { + remoteDir := "/" + if len(tokens) >= 3 { + remoteDir = tokens[2] + } + return &WorkflowSpec{ + Type: WorkflowFileList, + RemotePath: remoteDir, + }, true + } + case "script": + if len(tokens) >= 3 && tokens[1] == "put" { + name := tokens[2] + srcPath := strings.TrimPrefix(sourceFlag, "@") + return &WorkflowSpec{ + Type: WorkflowScriptPut, + ScriptName: name, + LocalPath: srcPath, + }, true + } + case "import": + if len(tokens) >= 2 { + localPath := tokens[1] + remotePath := getOptionalToken(tokens, 2, "sshx_import_temp_"+filepath.Base(localPath)) + return &WorkflowSpec{ + Type: WorkflowImport, + LocalPath: localPath, + RemotePath: remotePath, + }, true + } + case "export": + if len(tokens) >= 3 && tokens[1] == "download" { + return &WorkflowSpec{ + Type: WorkflowExportDownload, + LocalPath: tokens[2], + }, true + } + case "backup": + if len(tokens) >= 3 && tokens[1] == "download" { + return &WorkflowSpec{ + Type: WorkflowBackupDownload, + LocalPath: tokens[2], + BackupName: "sshx_backup_temp", + }, true + } + } + + return nil, false +} + +func getOptionalToken(tokens []string, idx int, defaultVal string) string { + if idx < len(tokens) && tokens[idx] != "" && !strings.HasPrefix(tokens[idx], "-") { + return tokens[idx] + } + return defaultVal +} + +// RenderWorkflowPlanJSON creates a dry-run plan for file/script workflows. +func RenderWorkflowPlanJSON(spec *WorkflowSpec) (string, error) { + plan := map[string]any{ + "dry_run": true, + "operation": string(spec.Type), + "transfer_backend": "ssh", + "local_path": redactPath(spec.LocalPath), + "remote_path": spec.RemotePath, + } + + switch spec.Type { + case WorkflowScriptPut: + plan["schema_version"] = "roswire.workflow.script.put.plan.v1" + plan["script_name"] = spec.ScriptName + plan["routeros_command"] = "/system/script/add" //nolint:misspell // RouterOS domain key + plan["side_effects"] = []string{"creates-ros-script"} + plan["routeros_file_created"] = false //nolint:misspell // RouterOS domain key + plan["content_redacted"] = true + case WorkflowImport: + plan["schema_version"] = "roswire.workflow.import.plan.v1" + plan["cleanup"] = spec.Cleanup + plan["routeros_command"] = "/import file-name=" + spec.RemotePath //nolint:misspell // RouterOS domain key + case WorkflowExportDownload: + plan["schema_version"] = "roswire.workflow.export.plan.v1" + plan["compact"] = spec.Compact + plan["routeros_command"] = "/export" //nolint:misspell // RouterOS domain key + case WorkflowBackupDownload: + plan["schema_version"] = "roswire.workflow.backup.plan.v1" + plan["backup_name"] = spec.BackupName + plan["routeros_command"] = "/system backup save" //nolint:misspell // RouterOS domain key + plan["cleanup"] = spec.Cleanup + default: + plan["schema_version"] = "roswire.workflow.file.plan.v1" + } + + b, err := json.MarshalIndent(plan, "", " ") + return string(b), err +} + +func redactPath(p string) string { + if p == "" { + return "" + } + return "***REDACTED***/" + filepath.Base(p) +} + +// ExecuteSFTPUpload copies a local file to remote RouterOS using SFTP. +func ExecuteSFTPUpload(sftpClient *sftp.Client, localPath, remotePath string) (int64, error) { + srcFile, err := os.Open(filepath.Clean(localPath)) // #nosec G304 -- local file path provided via CLI flag + if err != nil { + return 0, fmt.Errorf("open local file: %w", err) + } + defer func() { _ = srcFile.Close() }() //nolint:errcheck // best-effort file close + + stat, err := srcFile.Stat() + if err != nil { + return 0, fmt.Errorf("stat local file: %w", err) + } + + dstFile, err := sftpClient.Create(remotePath) + if err != nil { + return 0, fmt.Errorf("create remote SFTP file %q: %w", remotePath, err) + } + defer func() { _ = dstFile.Close() }() //nolint:errcheck // best-effort file close + + n, err := io.Copy(dstFile, srcFile) + if err != nil { + return n, fmt.Errorf("copy to remote SFTP file: %w", err) + } + if n != stat.Size() { + return n, fmt.Errorf("partial transfer: copied %d of %d bytes", n, stat.Size()) + } + return n, nil +} + +// ExecuteSFTPDownload copies a remote file on RouterOS to local filesystem using SFTP. +func ExecuteSFTPDownload(sftpClient *sftp.Client, remotePath, localPath string) (int64, error) { + srcFile, err := sftpClient.Open(remotePath) + if err != nil { + return 0, fmt.Errorf("open remote SFTP file %q: %w", remotePath, err) + } + defer func() { _ = srcFile.Close() }() //nolint:errcheck // best-effort file close + + if mkdirErr := os.MkdirAll(filepath.Dir(localPath), 0750); mkdirErr != nil { + return 0, fmt.Errorf("create local directory: %w", mkdirErr) + } + + dstFile, err := os.Create(filepath.Clean(localPath)) // #nosec G304 -- local target path provided via CLI flag + if err != nil { + return 0, fmt.Errorf("create local file %q: %w", localPath, err) + } + defer func() { _ = dstFile.Close() }() //nolint:errcheck // best-effort file close + + n, err := io.Copy(dstFile, srcFile) + if err != nil { + return n, fmt.Errorf("copy from remote SFTP file: %w", err) + } + return n, nil +} + +// WaitForRemoteFile polls remote path via SFTP until it exists or deadline is exceeded. +func WaitForRemoteFile(sftpClient *sftp.Client, remotePath string, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if _, err := sftpClient.Stat(remotePath); err == nil { + return nil + } + time.Sleep(500 * time.Millisecond) + } + return fmt.Errorf("timed out waiting for remote file %q (%v)", remotePath, timeout) +} diff --git a/internal/ros/workflow_test.go b/internal/ros/workflow_test.go new file mode 100644 index 0000000..5dc5d4a --- /dev/null +++ b/internal/ros/workflow_test.go @@ -0,0 +1,58 @@ +package ros + +import ( + "strings" + "testing" +) + +func TestParseWorkflowCommand(t *testing.T) { + // 1. File upload + spec, ok := ParseWorkflowCommand([]string{"file", "upload", "setup.rsc", "flash/setup.rsc"}, "") + if !ok || spec.Type != WorkflowFileUpload { + t.Fatalf("expected file upload workflow, got %v", spec) + } + if spec.LocalPath != "setup.rsc" || spec.RemotePath != "flash/setup.rsc" { + t.Errorf("unexpected paths: %+v", spec) + } + + // 2. File download + specDown, ok := ParseWorkflowCommand([]string{"file", "download", "flash/setup.rsc", "local.rsc"}, "") + if !ok || specDown.Type != WorkflowFileDownload { + t.Fatalf("expected file download workflow, got %v", specDown) + } + + // 3. Import + specImp, ok := ParseWorkflowCommand([]string{"import", "config.rsc"}, "") + if !ok || specImp.Type != WorkflowImport { + t.Fatalf("expected import workflow, got %v", specImp) + } + + // 4. Script put + specScript, ok := ParseWorkflowCommand([]string{"script", "put", "bootstrap"}, "@./local.rsc") + if !ok || specScript.Type != WorkflowScriptPut { + t.Fatalf("expected script put workflow, got %v", specScript) + } + if specScript.ScriptName != "bootstrap" || specScript.LocalPath != "./local.rsc" { + t.Errorf("unexpected script spec: %+v", specScript) + } +} + +func TestRenderWorkflowPlanJSON(t *testing.T) { + spec := &WorkflowSpec{ + Type: WorkflowScriptPut, + ScriptName: "my_script", + LocalPath: "/path/to/my_script.rsc", + } + + jsonPlan, err := RenderWorkflowPlanJSON(spec) + if err != nil { + t.Fatalf("unexpected error rendering plan: %v", err) + } + + if !strings.Contains(jsonPlan, "roswire.workflow.script.put.plan.v1") { + t.Errorf("expected script put plan schema, got %s", jsonPlan) + } + if !strings.Contains(jsonPlan, "***REDACTED***/my_script.rsc") { + t.Errorf("expected redacted local path, got %s", jsonPlan) + } +} diff --git a/internal/sshclient/client.go b/internal/sshclient/client.go index 5648a41..3c10daf 100644 --- a/internal/sshclient/client.go +++ b/internal/sshclient/client.go @@ -258,6 +258,17 @@ type Config struct { LoginUseSudo bool LoginLiteralHost bool + // RouterOS execution fields (Mode == "ros"). + ROSTokens []string + ROSAllowWrite bool + ROSRouterOSVersion string + ROSRaw bool + ROSSource string + ROSCleanup bool + ROSCompact bool + ROSBackupName string + ROSIncludeRemote bool + // Bind is a local source address: a literal IP or a network interface name. // BindSet distinguishes "flag not provided" from an explicit empty --bind= // that must clear a named host's persisted bind. diff --git a/internal/sshclient/transport.go b/internal/sshclient/transport.go index b579048..0166dec 100644 --- a/internal/sshclient/transport.go +++ b/internal/sshclient/transport.go @@ -102,6 +102,11 @@ func (c *SSHClient) newSFTPClient() (*sftp.Client, error) { return client, nil } +// NewSFTPClient opens a new SFTP client session over the existing SSH connection. +func (c *SSHClient) NewSFTPClient() (*sftp.Client, error) { + return c.newSFTPClient() +} + // PeerAddress is the actual connected TCP peer, not the configured DNS name. func (c *SSHClient) PeerAddress() string { if c == nil { diff --git a/tests/e2e/mcp_e2e_test.go b/tests/e2e/mcp_e2e_test.go index 4cedacc..538c88e 100644 --- a/tests/e2e/mcp_e2e_test.go +++ b/tests/e2e/mcp_e2e_test.go @@ -283,7 +283,7 @@ func TestMCPServerContract(t *testing.T) { } assert.ElementsMatch(t, []string{ "sshx_run", "sshx_sql", "sshx_apply", "sshx_inspect", - "sshx_text", "sshx_sftp", "sshx_transfer", "sshx_host_list", + "sshx_text", "sshx_sftp", "sshx_transfer", "sshx_host_list", "sshx_ros", }, names) for _, name := range names { assert.NotContains(t, name, "password", "secret management must not be exposed over MCP") diff --git a/tests/e2e/ros_e2e_test.go b/tests/e2e/ros_e2e_test.go new file mode 100644 index 0000000..0143307 --- /dev/null +++ b/tests/e2e/ros_e2e_test.go @@ -0,0 +1,396 @@ +package e2e + +import ( + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/ssh" +) + +// TestROSE2E_Introspection verifies the self-describing introspection commands +// (commands, help, schema, doctor, explain-error) across the compiled sshx binary. +func TestROSE2E_Introspection(t *testing.T) { + home := t.TempDir() + + // 1. sshx ros commands --json + res := runSSHX(t, home, []string{"ros", "commands", "--json"}, nil) + require.Equal(t, 0, res.exitCode, res.stderr) + var cmds map[string]any + require.NoError(t, json.Unmarshal([]byte(res.stdout), &cmds)) + assert.Equal(t, "roswire.commands.v1", cmds["schema_version"]) + rawFound := false + if list, ok := cmds["commands"].([]any); ok { + for _, item := range list { + if m, ok := item.(map[string]any); ok && m["name"] == "raw" { + rawFound = true + assert.Equal(t, "raw-routeros-command", m["kind"]) //nolint:misspell // RouterOS domain kind + break + } + } + } + assert.True(t, rawFound, "commands must contain raw") + + // 2. sshx ros help --json + resHelp := runSSHX(t, home, []string{"ros", "help", "--json"}, nil) + require.Equal(t, 0, resHelp.exitCode, resHelp.stderr) + var helpIndex map[string]any + require.NoError(t, json.Unmarshal([]byte(resHelp.stdout), &helpIndex)) + assert.Equal(t, "roswire.help.index.v1", helpIndex["schema_version"]) + + // 3. sshx ros help raw --json + resHelpRaw := runSSHX(t, home, []string{"ros", "help", "raw", "--json"}, nil) + require.Equal(t, 0, resHelpRaw.exitCode, resHelpRaw.stderr) + var helpRaw map[string]any + require.NoError(t, json.Unmarshal([]byte(resHelpRaw.stdout), &helpRaw)) + assert.Equal(t, "roswire.help.command.v1", helpRaw["schema_version"]) + + // 4. sshx ros schema raw --json & sshx ros schema command raw --json + resSchemaRaw := runSSHX(t, home, []string{"ros", "schema", "raw", "--json"}, nil) + require.Equal(t, 0, resSchemaRaw.exitCode, resSchemaRaw.stderr) + var schemaRaw map[string]any + require.NoError(t, json.Unmarshal([]byte(resSchemaRaw.stdout), &schemaRaw)) + assert.Equal(t, "raw", schemaRaw["command"]) + + resSchemaCmdRaw := runSSHX(t, home, []string{"ros", "schema", "command", "raw", "--json"}, nil) + require.Equal(t, 0, resSchemaCmdRaw.exitCode, resSchemaCmdRaw.stderr) + + // 5. sshx ros doctor --json + resDoctor := runSSHX(t, home, []string{"ros", "doctor", "--json"}, nil) + require.Equal(t, 0, resDoctor.exitCode, resDoctor.stderr) + var doctor map[string]any + require.NoError(t, json.Unmarshal([]byte(resDoctor.stdout), &doctor)) + assert.Equal(t, "roswire.doctor.v1", doctor["schema_version"]) + + // 6. sshx ros explain-error DANGEROUS_COMMAND_BLOCKED --json + resExplain := runSSHX(t, home, []string{"ros", "explain-error", "DANGEROUS_COMMAND_BLOCKED", "--json"}, nil) + require.Equal(t, 0, resExplain.exitCode, resExplain.stderr) + var explain map[string]any + require.NoError(t, json.Unmarshal([]byte(resExplain.stdout), &explain)) + assert.Equal(t, "roswire.explain_error.v1", explain["schema_version"]) + assert.Equal(t, "DANGEROUS_COMMAND_BLOCKED", explain["error_code"]) +} + +// TestROSE2E_DryRun verifies the client execution plans without connecting over SSH. +func TestROSE2E_DryRun(t *testing.T) { + home := t.TempDir() + + // 1. Catalog print dry-run + resPrint := runSSHX(t, home, []string{ + "ros", "-h=192.168.88.1", "ip", "address", "print", "--dry-run", "--json", + }, nil) + require.Equal(t, 0, resPrint.exitCode, resPrint.stderr) + var planPrint map[string]any + require.NoError(t, json.Unmarshal([]byte(resPrint.stdout), &planPrint)) + assert.Equal(t, "roswire.command.plan.v1", planPrint["schema_version"]) + assert.Equal(t, false, planPrint["will_modify_routeros"]) //nolint:misspell // RouterOS domain key + assert.Equal(t, "read-only", planPrint["idempotency"]) + + // 2. Raw print with flags dry-run + resRawPrint := runSSHX(t, home, []string{ + "ros", "-h=192.168.88.1", "raw", "/interface/print", "detail", "--dry-run", "--json", + }, nil) + require.Equal(t, 0, resRawPrint.exitCode, resRawPrint.stderr) + var planRawPrint map[string]any + require.NoError(t, json.Unmarshal([]byte(resRawPrint.stdout), &planRawPrint)) + assert.Equal(t, "raw", planRawPrint["action"]) + assert.Equal(t, "/interface/print", planRawPrint["routeros_path"]) //nolint:misspell // RouterOS domain key + assert.Equal(t, false, planRawPrint["will_modify_routeros"]) //nolint:misspell // RouterOS domain key + + // 3. Raw write dry-run + resRawWrite := runSSHX(t, home, []string{ + "ros", "-h=192.168.88.1", "raw", "/ip/address/add", "address=10.0.0.1/24", "interface=ether2", "--dry-run", "--json", + }, nil) + require.Equal(t, 0, resRawWrite.exitCode, resRawWrite.stderr) + var planRawWrite map[string]any + require.NoError(t, json.Unmarshal([]byte(resRawWrite.stdout), &planRawWrite)) + assert.Equal(t, true, planRawWrite["will_modify_routeros"]) //nolint:misspell // RouterOS domain key + resolvedArgs, ok := planRawWrite["resolved_args"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "10.0.0.1/24", resolvedArgs["address"]) + assert.Equal(t, "ether2", resolvedArgs["interface"]) + + // 4. Workflow dry-run + resWf := runSSHX(t, home, []string{ + "ros", "-h=192.168.88.1", "backup", "download", "my.backup", "--dry-run", "--json", + }, nil) + require.Equal(t, 0, resWf.exitCode, resWf.stderr) + var planWf map[string]any + require.NoError(t, json.Unmarshal([]byte(resWf.stdout), &planWf)) + assert.Equal(t, "roswire.workflow.backup.plan.v1", planWf["schema_version"]) + assert.Equal(t, "backup_download", planWf["operation"]) + assert.Equal(t, "ssh", planWf["transfer_backend"]) +} + +// TestROSE2E_SafetyGuardrails verifies dangerous command blocking and raw mutation guards. +func TestROSE2E_SafetyGuardrails(t *testing.T) { + home := t.TempDir() + + // 1. Dangerous command blocked without force + resDangerous := runSSHX(t, home, []string{ + "ros", "-h=192.168.88.1", "raw", "/system/reset-configuration", + }, nil) + assert.NotEqual(t, 0, resDangerous.exitCode) + assert.Contains(t, resDangerous.stderr, "DANGEROUS_COMMAND_BLOCKED") + + // 2. Raw mutation blocked without --allow-write + resRawMut := runSSHX(t, home, []string{ + "ros", "-h=192.168.88.1", "raw", "/ip/address/add", "address=10.0.0.1/24", "interface=ether1", + }, nil) + assert.NotEqual(t, 0, resRawMut.exitCode) + assert.Contains(t, resRawMut.stderr, "require --allow-write") +} + +// TestROSE2E_LiveSSHExecution verifies actual network command execution, RouterOS CLI parsing, +// and --raw flag behavior against a mock RouterOS SSH server. +func TestROSE2E_LiveSSHExecution(t *testing.T) { + mockDetailOutput := " 0 address=192.168.88.1/24 network=192.168.88.0 interface=ether1 actual-interface=ether1 invalid=no dynamic=no disabled=no\n" + mockResourceOutput := " uptime: 2w4d12h\n version: 7.16.1 (stable)\n build-time: 2024-10-15 08:30:00\n free-memory: 245.5MiB\n total-memory: 512.0MiB\n cpu: MIPS\n cpu-count: 2\n cpu-frequency: 880MHz\n cpu-load: 3%\n" + + var executedCommands []string + server := startSSHServer(t, serverOptions{ + execHandler: func(ch ssh.Channel, cmd string, role string) { + executedCommands = append(executedCommands, cmd) + switch { + case strings.Contains(cmd, "/ip address print detail"): + _, _ = io.WriteString(ch, mockDetailOutput) //nolint:errcheck + sendExitStatus(ch, 0) + case strings.Contains(cmd, "/system/resource/print") || strings.Contains(cmd, "/system resource print"): + _, _ = io.WriteString(ch, mockResourceOutput) //nolint:errcheck + sendExitStatus(ch, 0) + case strings.Contains(cmd, "/ip/address/add") || strings.Contains(cmd, "/ip address add"): + // Successful mutation returns empty output in RouterOS CLI + sendExitStatus(ch, 0) + case strings.Contains(cmd, "/system/reset-configuration"): + _, _ = io.WriteString(ch, "configuration reset scheduled\n") //nolint:errcheck + sendExitStatus(ch, 0) + default: + _, _ = io.WriteString(ch.Stderr(), "syntax error\n") //nolint:errcheck + sendExitStatus(ch, 1) + } + _ = ch.Close() //nolint:errcheck + }, + }) + home := t.TempDir() + env := map[string]string{"SSH_PASSWORD": operatorPassword} + base := []string{ + "ros", + "-h=" + server.host, + "-p=" + server.port, + "-u=operator", + "--no-key", + "--accept-unknown-host", + } + + // 1. Execute print query with structured JSON output + resPrint := runSSHX(t, home, append(append([]string{}, base...), + "ip", "address", "print", "detail", "--json", + ), env) + require.Equal(t, 0, resPrint.exitCode, "stderr=%s stdout=%s", resPrint.stderr, resPrint.stdout) + var printJSON map[string]any + require.NoError(t, json.Unmarshal([]byte(resPrint.stdout), &printJSON)) + assert.Equal(t, "ok", printJSON["status"]) + assert.Equal(t, true, printJSON["success"]) + dataList, ok := printJSON["data"].([]any) + require.True(t, ok, "expected data array in json: %v", printJSON["data"]) + require.Len(t, dataList, 1) + record, ok := dataList[0].(map[string]any) + require.True(t, ok) + assert.Equal(t, "192.168.88.1/24", record["address"]) + assert.Equal(t, "ether1", record["interface"]) + + // 2. Execute raw read command with colon record parsing + resRaw := runSSHX(t, home, append(append([]string{}, base...), + "raw", "/system/resource/print", "--json", + ), env) + require.Equal(t, 0, resRaw.exitCode, "stderr=%s stdout=%s", resRaw.stderr, resRaw.stdout) + var rawJSON map[string]any + require.NoError(t, json.Unmarshal([]byte(resRaw.stdout), &rawJSON)) + assert.Equal(t, "ok", rawJSON["status"]) + resMap, ok := rawJSON["data"].(map[string]any) + require.True(t, ok, "expected parsed data map for colon records") + assert.Equal(t, "7.16.1 (stable)", resMap["version"]) + assert.Equal(t, "2", fmt.Sprintf("%v", resMap["cpu-count"])) + + // 3. Raw command with --raw flag suppresses output parsing + resRawSuppressed := runSSHX(t, home, append(append([]string{}, base...), + "raw", "/system/resource/print", "--raw", "--json", + ), env) + require.Equal(t, 0, resRawSuppressed.exitCode, resRawSuppressed.stderr) + var rawSupJSON map[string]any + require.NoError(t, json.Unmarshal([]byte(resRawSuppressed.stdout), &rawSupJSON)) + assert.Nil(t, rawSupJSON["data"], "data should be nil when --raw flag is specified") + rawOut, ok := rawSupJSON["stdout"].(string) + require.True(t, ok) + assert.Contains(t, rawOut, "7.16.1 (stable)") + + // 4. Raw mutative command with --allow-write succeeds + resRawAdd := runSSHX(t, home, append(append([]string{}, base...), + "raw", "/ip/address/add", "address=10.0.0.1/24", "interface=ether2", "--allow-write", "--json", + ), env) + require.Equal(t, 0, resRawAdd.exitCode, "stderr=%s stdout=%s", resRawAdd.stderr, resRawAdd.stdout) + var addJSON map[string]any + require.NoError(t, json.Unmarshal([]byte(resRawAdd.stdout), &addJSON)) + assert.Equal(t, "ok", addJSON["status"]) + + // 5. Dangerous command with --force succeeds + resForce := runSSHX(t, home, append(append([]string{}, base...), + "raw", "/system/reset-configuration", "--force", "--json", + ), env) + require.Equal(t, 0, resForce.exitCode, "stderr=%s stdout=%s", resForce.stderr, resForce.stdout) + var forceJSON map[string]any + require.NoError(t, json.Unmarshal([]byte(resForce.stdout), &forceJSON)) + assert.Equal(t, "ok", forceJSON["status"]) + + // Verify command strings dispatched over SSH + assert.Contains(t, executedCommands[0], "without-paging") +} + +// TestROSE2E_LiveWorkflows verifies SFTP file operations, backup, export, and script workflows +// against the mock server's real SFTP filesystem backend. +func TestROSE2E_LiveWorkflows(t *testing.T) { + var executedCommands []string + var serverRoot string + + server := startSSHServer(t, serverOptions{ + execHandler: func(ch ssh.Channel, cmd string, role string) { + executedCommands = append(executedCommands, cmd) + switch { + case strings.Contains(cmd, "backup") && strings.Contains(cmd, "save"): + name := "test_backup" + if idx := strings.Index(cmd, "name="); idx != -1 { + name = strings.Fields(cmd[idx+5:])[0] + } + backupFile := filepath.Join(serverRoot, name+".backup") + _ = os.WriteFile(backupFile, []byte("ROUTEROS-BINARY-BACKUP-PAYLOAD"), 0o600) //nolint:errcheck,misspell // fixture backup file + _, _ = io.WriteString(ch, fmt.Sprintf("Saving system configuration\nConfiguration backup saved to %s.backup\n", name)) //nolint:errcheck + sendExitStatus(ch, 0) + case strings.HasPrefix(cmd, "/export"): + for _, part := range strings.Fields(cmd) { + if strings.HasPrefix(part, "file=") { + name := strings.TrimPrefix(part, "file=") + exportFile := filepath.Join(serverRoot, name) + _ = os.WriteFile(exportFile, []byte("# RouterOS export script\n/ip address add address=192.168.88.1/24\n"), 0o600) //nolint:errcheck // fixture export file + break + } + } + sendExitStatus(ch, 0) + case strings.Contains(cmd, "script") && (strings.Contains(cmd, "add") || strings.Contains(cmd, "set")): + sendExitStatus(ch, 0) + case strings.Contains(cmd, "/file"): + sendExitStatus(ch, 0) + default: + sendExitStatus(ch, 0) + } + _ = ch.Close() //nolint:errcheck + }, + }) + serverRoot = server.root + + home := t.TempDir() + env := map[string]string{"SSH_PASSWORD": operatorPassword} + base := []string{ + "ros", + "-h=" + server.host, + "-p=" + server.port, + "-u=operator", + "--no-key", + "--accept-unknown-host", + } + + // 1. file upload via SFTP + localUpload := filepath.Join(home, "upload.rsc") + require.NoError(t, os.WriteFile(localUpload, []byte("/system identity set name=test-router\n"), 0o600)) + resUpload := runSSHX(t, home, append(append([]string{}, base...), + "file", "upload", localUpload, "upload.rsc", "--json", + ), env) + require.Equal(t, 0, resUpload.exitCode, "upload stderr=%s", resUpload.stderr) + uploadedContent, err := os.ReadFile(filepath.Clean(filepath.Join(server.root, "upload.rsc"))) // #nosec G304 -- test file verification + require.NoError(t, err) + assert.Equal(t, "/system identity set name=test-router\n", string(uploadedContent)) + + // 2. file download via SFTP + remoteFile := filepath.Join(server.root, "download_test.txt") + require.NoError(t, os.WriteFile(remoteFile, []byte("remote-router-config\n"), 0o600)) + localDownload := filepath.Join(home, "downloaded.txt") + resDownload := runSSHX(t, home, append(append([]string{}, base...), + "file", "download", "download_test.txt", localDownload, "--json", + ), env) + require.Equal(t, 0, resDownload.exitCode, "download stderr=%s", resDownload.stderr) + downloadedContent, err := os.ReadFile(filepath.Clean(localDownload)) // #nosec G304 -- test file verification + require.NoError(t, err) + assert.Equal(t, "remote-router-config\n", string(downloadedContent)) + + // 3. backup download with --cleanup + localBackup := filepath.Join(home, "router.backup") + resBackup := runSSHX(t, home, append(append([]string{}, base...), + "backup", "download", localBackup, "--name=test_backup", "--cleanup", "--json", + ), env) + require.Equal(t, 0, resBackup.exitCode, "backup stderr=%s", resBackup.stderr) + backupBytes, err := os.ReadFile(filepath.Clean(localBackup)) // #nosec G304 -- test file verification + require.NoError(t, err) + assert.Equal(t, "ROUTEROS-BINARY-BACKUP-PAYLOAD", string(backupBytes)) //nolint:misspell // test payload token + + // 4. export download with --compact and --cleanup + localExport := filepath.Join(home, "export.rsc") + resExport := runSSHX(t, home, append(append([]string{}, base...), + "export", "download", localExport, "--compact", "--cleanup", "--json", + ), env) + require.Equal(t, 0, resExport.exitCode, "export stderr=%s", resExport.stderr) + exportBytes, err := os.ReadFile(filepath.Clean(localExport)) // #nosec G304 -- test file verification + require.NoError(t, err) + assert.Contains(t, string(exportBytes), "/ip address add") + + // 5. script put with --source + localScript := filepath.Join(home, "my_script.rsc") + require.NoError(t, os.WriteFile(localScript, []byte(":log info \"hello ros\"\n"), 0o600)) + resScript := runSSHX(t, home, append(append([]string{}, base...), + "script", "put", "my-script", "--source=@"+localScript, "--json", + ), env) + require.Equal(t, 0, resScript.exitCode, "script put stderr=%s", resScript.stderr) + var scriptJSON map[string]any + require.NoError(t, json.Unmarshal([]byte(resScript.stdout), &scriptJSON)) + assert.Equal(t, "ok", scriptJSON["status"]) +} + +// TestROSE2E_MCPToolCall verifies AI agent invocation of the sshx_ros MCP tool. +func TestROSE2E_MCPToolCall(t *testing.T) { + server := startSSHServer(t, serverOptions{ + execHandler: func(ch ssh.Channel, cmd string, role string) { + if strings.Contains(cmd, "/system/resource/print") { + _, _ = io.WriteString(ch, " uptime: 10d\n version: 7.16\n") //nolint:errcheck + sendExitStatus(ch, 0) + } else { + sendExitStatus(ch, 0) + } + _ = ch.Close() //nolint:errcheck + }, + }) + home := t.TempDir() + env := map[string]string{ + "SSH_PASSWORD": operatorPassword, + } + + client := startMCPClient(t, home, env) + + // Dry run tool call over MCP stdio + isErr, out := client.callTool("sshx_ros", map[string]any{ + "target": server.host, + "port": 22, + "user": "operator", + "command": "raw /system/resource/print", + "dry_run": true, + }) + require.False(t, isErr, "MCP callTool returned error: %s", out) + var plan map[string]any + require.NoError(t, json.Unmarshal([]byte(out), &plan)) + assert.Equal(t, "roswire.command.plan.v1", plan["schema_version"]) + assert.Equal(t, "raw", plan["action"]) + assert.Equal(t, "/system/resource/print", plan["routeros_path"]) //nolint:misspell // RouterOS domain key +}