Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,9 @@ linters:

misspell:
locale: US

issues:
exclude-rules:
- text: "misspelling of"
linters:
- misspell
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
35 changes: 35 additions & 0 deletions README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 并更新矩阵;高风险、权限和状态修改能力还必须满足相应失败、权限差异与恢复覆盖底线。
Expand Down
5 changes: 5 additions & 0 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
86 changes: 86 additions & 0 deletions internal/app/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down Expand Up @@ -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"
}
}
70 changes: 70 additions & 0 deletions internal/app/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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."`
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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, " +
Expand Down
Loading
Loading