├── docs ├── .nojekyll ├── CNAME ├── en-us │ ├── faq.md │ ├── _sidebar.md │ ├── contribute.md │ ├── why-need-it.md │ ├── practices.md │ ├── install.md │ ├── overview.md │ ├── environment.md │ ├── release-notes.md │ └── usage.md ├── zh-cn │ ├── faq.md │ ├── contribute.md │ ├── why-need-it.md │ ├── install.md │ ├── practices.md │ ├── environment.md │ ├── overview.md │ ├── release-notes.md │ └── usage.md ├── _navbar.md ├── _coverpage.md ├── _sidebar.md ├── donate.md ├── index.html └── _media │ └── logo.svg ├── version ├── internal ├── errmsg │ └── errmsg.go ├── terminal │ ├── terminal.go │ ├── terminal_unix.go │ └── terminal_windows.go ├── tui │ └── table.go ├── encrypt │ ├── encrypt_test.go │ └── encrypt.go ├── lg │ ├── lg.go │ └── lg_test.go ├── slice │ ├── slice.go │ └── slice_test.go └── utils │ ├── utils_test.go │ └── utils.go ├── ssx ├── cleaner │ ├── cleaner.go │ └── cleaner_test.go ├── version │ └── version.go ├── repo.go ├── reserved.go ├── env │ └── env.go ├── entry │ ├── entry_test.go │ ├── proxy.go │ └── entry.go ├── cp_test.go ├── bbolt │ └── bbolt.go ├── client.go └── cp.go ├── cmd └── ssx │ ├── cmd │ ├── list.go │ ├── delete.go │ ├── info.go │ ├── tag.go │ ├── cp.go │ ├── root.go │ └── upgrade.go │ └── main.go ├── .github ├── dependabot.yml └── workflows │ ├── release.yml │ ├── stale.yml │ └── codeql.yml ├── .golangci.yml ├── .gitignore ├── LICENSE ├── .goreleaser.yml ├── go.mod ├── e2e ├── version_test.go ├── list_test.go ├── delete_test.go ├── README.md ├── tag_test.go ├── connect_test.go ├── info_test.go ├── e2e_test.go └── cp_test.go ├── Makefile ├── static └── logo.svg ├── README.md ├── README_zh.md └── go.sum /docs/.nojekyll: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /version: -------------------------------------------------------------------------------- 1 | v0.6.1 2 | -------------------------------------------------------------------------------- /docs/CNAME: -------------------------------------------------------------------------------- 1 | ssx.vimiix.com -------------------------------------------------------------------------------- /docs/en-us/faq.md: -------------------------------------------------------------------------------- 1 | # FAQ 2 | 3 | TBD... 4 | -------------------------------------------------------------------------------- /docs/zh-cn/faq.md: -------------------------------------------------------------------------------- 1 | # 常见问题 2 | 3 | TBD... 4 | -------------------------------------------------------------------------------- /docs/_navbar.md: -------------------------------------------------------------------------------- 1 | 2 | - [主页](/) 3 | - [赞助](/donate) 4 | - Language 5 | - [中文](/zh-cn/) 6 | - [English](/en-us/) -------------------------------------------------------------------------------- /docs/_coverpage.md: -------------------------------------------------------------------------------- 1 | 2 | ![logo](_media/logo.svg) 3 | 4 | > A retentive ssh client. 5 | 6 | open-source / zero-dependency / single-binary tool / easy upgrade 7 | 8 | [GitHub](https://github.com/vimiix/ssx/) 9 | [Getting Started](/zh-cn/overview) -------------------------------------------------------------------------------- /internal/errmsg/errmsg.go: -------------------------------------------------------------------------------- 1 | package errmsg 2 | 3 | import ( 4 | "github.com/pkg/errors" 5 | ) 6 | 7 | var ( 8 | ErrEntryNotExist = errors.New("entry does not exist") 9 | ErrRepoNotOpen = errors.New("repo is not open") 10 | ErrNoEntry = errors.New("no entry found") 11 | ) 12 | -------------------------------------------------------------------------------- /docs/zh-cn/contribute.md: -------------------------------------------------------------------------------- 1 | # 参与贡献 2 | 3 | > 一个人可以走得很快,但一群人可以走得很远 4 | 5 | SSX 目前只有我独自在做日常的开发和维护工作,我希望有更多对 SSX 项目感兴趣的开发者参与进来,欢迎随时提交 PR,我会保证尽快 Reivew PR 并且及时回复。但提交 PR 请确保 6 | 7 | - 通过所有单元测试,如若是新功能,请尽量为其增加对应的单元测试 8 | - 遵守 [Go 语言编码规范](https://github.com/xxjwxc/uber_go_guide_cn) 9 | - 如若需要,请更新相对应的文档 10 | -------------------------------------------------------------------------------- /docs/_sidebar.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | - [简介](/zh-cn/overview) 4 | - [安装](/zh-cn/install) 5 | - [使用方法](/zh-cn/usage) 6 | - [环境变量](/zh-cn/environment) 7 | - [实践](/zh-cn/practices) 8 | - [发布日志](/zh-cn/release-notes) 9 | - [需求来源](/zh-cn/why-need-it) 10 | - [参与贡献](/zh-cn/contribute) 11 | - [赞助](/donate) -------------------------------------------------------------------------------- /ssx/cleaner/cleaner.go: -------------------------------------------------------------------------------- 1 | package cleaner 2 | 3 | import ( 4 | "sync" 5 | ) 6 | 7 | var ( 8 | mu = sync.Mutex{} 9 | cbs []func() 10 | ) 11 | 12 | func RegisterCallback(cb func()) { 13 | mu.Lock() 14 | defer mu.Unlock() 15 | cbs = append(cbs, cb) 16 | } 17 | 18 | func Clean() { 19 | mu.Lock() 20 | defer mu.Unlock() 21 | for _, cb := range cbs { 22 | cb() 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /docs/en-us/_sidebar.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | - [Overview](/en-us/overview) 4 | - [Installation](/en-us/install) 5 | - [Usage](/en-us/usage) 6 | - [Environment Variables](/en-us/environment) 7 | - [Practices](/en-us/practices) 8 | - [Release Notes](/en-us/release-notes) 9 | - [Why SSX](/en-us/why-need-it) 10 | - [Contributing](/en-us/contribute) 11 | - [Donate](/donate) 12 | -------------------------------------------------------------------------------- /cmd/ssx/cmd/list.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | func newListCmd() *cobra.Command { 8 | cmd := &cobra.Command{ 9 | Use: "list", 10 | Aliases: []string{"l", "ls"}, 11 | Short: "list all entries", 12 | RunE: func(cmd *cobra.Command, args []string) error { 13 | return ssxInst.ListEntries() 14 | }} 15 | 16 | return cmd 17 | } 18 | -------------------------------------------------------------------------------- /ssx/cleaner/cleaner_test.go: -------------------------------------------------------------------------------- 1 | package cleaner 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestClean(t *testing.T) { 10 | var called bool 11 | cb := func() { 12 | called = true 13 | } 14 | assert.Equal(t, 0, len(cbs)) 15 | RegisterCallback(cb) 16 | assert.Equal(t, 1, len(cbs)) 17 | 18 | Clean() 19 | assert.True(t, called) 20 | } 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "gomod" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /docs/zh-cn/why-need-it.md: -------------------------------------------------------------------------------- 1 | # 需求来源 2 | 3 | 我是一名后端开发人员,对于一个后端程序员来说,在工作中免不了要和繁杂的服务器打交道(保守来说,我日常工作至少会和50+服务器打交道),ssh 是不可或缺的开发工具。但每次登录都需要输入密码的行为,而且这些服务器都不能直接生成互信密钥的服务器,基本都是临时登录查问题需要。对于认为一切皆可自动化的程序员来说,肯定是有点不可接受的(如果您是使用图形化界面的用户可忽略)。 4 | 5 | 所以我在开发 SSX 之前的一段时间就在思考,我需要一个 ssh 客户端,它不需要拥有许多复杂的功能,只需要满足我以下这几个需求即可满足日常使用: 6 | 7 | - 和 ssh 保持差不多的使用习惯 8 | - 仅在第一次登录时询问我密码,后续使用无需再提供密码 9 | - 可以给服务器打任意的标签,这样我就可以自由地通过IP 或者标签来登录 10 | 11 | 于是乎,我在业余时间就设计并编写了 SSX 这个轻量级的具有记忆功能的 ssh 客户端。它完美的实现了上面我所需要的功能,也已经被我愉快的应用到了日常的开发中,大大提高了搬砖效率。 12 | -------------------------------------------------------------------------------- /ssx/version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | "fmt" 5 | "runtime" 6 | "strings" 7 | ) 8 | 9 | // modify these values from build flag 10 | var ( 11 | Version = "develop" 12 | Revision = "unknown" 13 | BuildDate = "unknown" 14 | ) 15 | 16 | // Detail all infomations of ssx version 17 | func Detail() string { 18 | return strings.TrimSpace(fmt.Sprintf(` 19 | Version: %s 20 | Revision: %s 21 | Buildtime: %s 22 | OS/Arch: %s/%s 23 | `, Version, Revision, BuildDate, runtime.GOOS, runtime.GOARCH)) 24 | } 25 | -------------------------------------------------------------------------------- /docs/en-us/contribute.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | > One person can go fast, but a group can go far 4 | 5 | SSX is currently maintained by me alone. I hope more developers interested in the SSX project will participate. PRs are always welcome - I'll ensure timely review and response. When submitting a PR, please ensure: 6 | 7 | - All unit tests pass. For new features, please add corresponding unit tests 8 | - Follow [Go coding conventions](https://github.com/uber-go/guide/blob/master/style.md) 9 | - Update relevant documentation if needed 10 | -------------------------------------------------------------------------------- /ssx/repo.go: -------------------------------------------------------------------------------- 1 | package ssx 2 | 3 | import ( 4 | "github.com/vimiix/ssx/ssx/bbolt" 5 | "github.com/vimiix/ssx/ssx/entry" 6 | ) 7 | 8 | // Repo define a KV store interface 9 | type Repo interface { 10 | Init() error 11 | GetMetadata(key []byte) ([]byte, error) 12 | SetMetadata(key []byte, value []byte) error 13 | TouchEntry(e *entry.Entry) (err error) 14 | GetEntry(id uint64) (*entry.Entry, error) 15 | GetAllEntries() (map[uint64]*entry.Entry, error) 16 | DeleteEntry(id uint64) error 17 | } 18 | 19 | var _ Repo = (*bbolt.Repo)(nil) 20 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | timeout: 5m 3 | modules-download-mode: readonly 4 | linters: 5 | disable-all: true 6 | fast: false 7 | enable: 8 | - bodyclose 9 | - depguard 10 | - dogsled 11 | - dupl 12 | - errcheck 13 | - exportloopref 14 | - exhaustive 15 | - gosimple 16 | - govet 17 | - ineffassign 18 | - misspell 19 | - predeclared 20 | - revive 21 | - staticcheck 22 | - stylecheck 23 | - unconvert 24 | - whitespace 25 | - gofmt 26 | - goimports 27 | - gocyclo 28 | - typecheck 29 | - unused -------------------------------------------------------------------------------- /ssx/reserved.go: -------------------------------------------------------------------------------- 1 | package ssx 2 | 3 | var ( 4 | // Reference: https://github.com/vimiix/ssx/issues/14 5 | reservedWords = []string{ 6 | "l", "ls", "list", 7 | "t", "tag", 8 | "d", "del", "delete", 9 | "a", "add", 10 | "i", "info", 11 | "u", "update", 12 | "cp", "scp", 13 | "stats", "top", "share", 14 | "ssx", 15 | } 16 | reservedWordsMap = map[string]bool{} 17 | ) 18 | 19 | func init() { 20 | for _, word := range reservedWords { 21 | reservedWordsMap[word] = true 22 | } 23 | } 24 | 25 | func isReservedWord(word string) bool { 26 | return reservedWordsMap[word] 27 | } 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Dependency directories (remove the comment below to include it) 18 | # vendor/ 19 | 20 | # Go workspace file 21 | go.work 22 | 23 | .idea/ 24 | .vscode/ 25 | /dist/ 26 | .enter.env 27 | -------------------------------------------------------------------------------- /cmd/ssx/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "os/signal" 8 | "syscall" 9 | 10 | "github.com/fatih/color" 11 | 12 | "github.com/vimiix/ssx/cmd/ssx/cmd" 13 | "github.com/vimiix/ssx/ssx/cleaner" 14 | ) 15 | 16 | func main() { 17 | var ( 18 | exitCode = 0 19 | ) 20 | 21 | ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) 22 | defer cancel() 23 | 24 | if err := cmd.NewRoot().ExecuteContext(ctx); err != nil { 25 | fmt.Println(color.HiRedString(err.Error())) 26 | exitCode = 1 27 | } 28 | 29 | cleaner.Clean() 30 | os.Exit(exitCode) 31 | } 32 | -------------------------------------------------------------------------------- /cmd/ssx/cmd/delete.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | func newDeleteCmd() *cobra.Command { 10 | var ids []int 11 | cmd := &cobra.Command{ 12 | Use: "delete", 13 | Aliases: []string{"d", "del"}, 14 | Short: "delete entry by id", 15 | Example: "ssx delete --id 1 [--id 2 ...]", 16 | RunE: func(cmd *cobra.Command, args []string) error { 17 | if len(ids) == 0 { 18 | fmt.Println("no id specified, do nothing") 19 | return nil 20 | } 21 | return ssxInst.DeleteEntryByID(ids...) 22 | }, 23 | } 24 | cmd.Flags().IntSliceVarP(&ids, "id", "", nil, "entry id") 25 | return cmd 26 | } 27 | -------------------------------------------------------------------------------- /internal/terminal/terminal.go: -------------------------------------------------------------------------------- 1 | package terminal 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/containerd/console" 7 | ) 8 | 9 | func ReadPassword(ctx context.Context) ([]byte, error) { 10 | c := console.Current() 11 | defer func() { 12 | _ = c.Reset() 13 | }() 14 | 15 | var ( 16 | errch = make(chan error, 1) 17 | password []byte 18 | ) 19 | 20 | go func() { 21 | bs, readErr := readPassword() 22 | if readErr != nil { 23 | errch <- readErr 24 | } 25 | password = bs 26 | errch <- nil 27 | }() 28 | 29 | select { 30 | case err := <-errch: 31 | return password, err 32 | case <-ctx.Done(): 33 | return nil, ctx.Err() 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /ssx/env/env.go: -------------------------------------------------------------------------------- 1 | package env 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | ) 7 | 8 | const ( 9 | SSXDBPath = "SSX_DB_PATH" 10 | SSXConnectTimeout = "SSX_CONNECT_TIMEOUT" 11 | SSXImportSSHConfig = "SSX_IMPORT_SSH_CONFIG" // 设置了该环境变量的话,就会自动将 ~/.ssh/config 中的条目也加载 12 | SSXUnsafeMode = "SSX_UNSAFE_MODE" // deprecated 13 | SSXSecretKey = "SSX_SECRET_KEY" // deprecated, replaced by SSX_DEVICE_ID 14 | SSXDeviceID = "SSX_DEVICE_ID" 15 | ) 16 | 17 | func IsUnsafeMode() bool { 18 | switch strings.ToLower(os.Getenv(SSXUnsafeMode)) { 19 | case "t", "true", "on", "1": 20 | return true 21 | default: 22 | return false 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /internal/tui/table.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "io" 5 | "os" 6 | 7 | "github.com/vimiix/tablewriter" 8 | ) 9 | 10 | // PrintTable render a format table to stdout 11 | func PrintTable(header []string, rows [][]string) { 12 | PrintTableTo(os.Stdout, header, rows) 13 | } 14 | 15 | func PrintTableTo(wr io.Writer, header []string, rows [][]string) { 16 | table := tablewriter.NewWriter(wr) 17 | table.SetHeader(header) 18 | table.SetAutoWrapText(false) 19 | table.SetAlignment(tablewriter.ALIGN_LEFT) 20 | table.SetAutoFormatHeaders(false) 21 | table.EnableBorder(false) 22 | table.SetAutoMergeCellsByColumnIndex([]int{0}) 23 | table.AppendBulk(rows) 24 | table.Render() 25 | } 26 | -------------------------------------------------------------------------------- /ssx/entry/entry_test.go: -------------------------------------------------------------------------------- 1 | package entry 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/agiledragon/gomonkey/v2" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestEntry_String(t *testing.T) { 11 | e := Entry{Host: "host", Port: "22", User: "user"} 12 | assert.Equal(t, "user@host:22", e.String()) 13 | } 14 | 15 | func TestEntry_Tidy(t *testing.T) { 16 | patches := gomonkey.NewPatches() 17 | defer patches.Reset() 18 | 19 | e := &Entry{} 20 | if err := e.Tidy(); err != nil { 21 | t.Fatalf("Received unexpected error:\n%+v", err) 22 | } 23 | 24 | assert.Equal(t, "root", e.User) 25 | assert.Equal(t, "22", e.Port) 26 | assert.Equal(t, defaultIdentityFile, e.KeyPath) 27 | } 28 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v3 14 | with: 15 | fetch-depth: 0 16 | - name: Fetch all tags 17 | run: git fetch --force --tags 18 | - name: Set up Go 19 | uses: actions/setup-go@v4 20 | with: 21 | go-version: 1.23.1 22 | - name: Run GoReleaser 23 | uses: goreleaser/goreleaser-action@v4 24 | env: 25 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 26 | with: 27 | distribution: goreleaser 28 | version: "~> v2" 29 | args: release --clean --timeout=60m -------------------------------------------------------------------------------- /docs/donate.md: -------------------------------------------------------------------------------- 1 | # 赞助 Sponsor 2 | 3 | > 如果你觉得这个项目帮助到了你,你可以帮作者买一杯咖啡表示感谢! 4 | > 5 | > If you think this project helped you, you can buy the author a cup of coffee to say thank you! 6 | 7 | | 微信 Wechat | 支付宝 Alipay | 8 | | -------- | ---------- | 9 | | ![wechat](https://static.vimiix.com/uPic/2021-04-06/WeChatbb78a525854e77d40474fa446192ea3d.png?x-oss-process=image/resize,p_15)| ![alipay](https://static.vimiix.com/uPic/2021-04-06/WeChat7a6da5fc36f59ad4feae8fd10a788d07.png?x-oss-process=image/resize,p_15) | 10 | 11 | 你可以选择在赞助时,在备注中写明你的 Github 账号和想说的话,我会在下面赞助记录中列出表示感谢。 12 | 13 | You can choose to indicate your Github account and what you want to say in the comments when sponsoring, and I will list it in the sponsorship record below to express my thanks. 14 | 15 | 如未备注,我会统一采用 **匿名** 来记录。 16 | 17 | If there is no comment, I will use anonymous record. 18 | 19 | ## 赞助记录 20 | 21 | | 用户 | 金额 | 时间 | 备注 | 22 | |:---- | ---- | ---- | ----| 23 | 24 | (*暂无记录*) 25 | -------------------------------------------------------------------------------- /docs/zh-cn/install.md: -------------------------------------------------------------------------------- 1 | # 安装 2 | 3 | ## 在线下载 4 | 5 | 你可以通过 github 的 [release 页面](https://github.com/vimiix/ssx/releases)下载对应平台的软件包 6 | 7 | > 下载地址:[https://github.com/vimiix/ssx/releases](https://github.com/vimiix/ssx/releases) 8 | 9 | 下载后解压压缩包即可得到 `ssx` 二进制文件(windows 平台为 `ssx.exe`) 10 | 11 | 以 `linux x86_64` 为例: 12 | 13 | ```bash 14 | tar -xvf ssx_vX.Y.Z_linux_x86_64.tar.gz 15 | ``` 16 | 17 | 将解压得到的 `ssx` 二进制文件存放到任意 `$PATH` 中存在的目录中即可,当然也可以直接通过 `./ssx` 或绝对路径的方式使用,为了便于使用,建议还是放到 `$PATH` 中包含的目录中,比如 `/usr/local/bin`,如果 ssx 所在的目录不在 `$PATH` 环境变量中,可以通过配置添加: 18 | 19 | ```bash 20 | echo 'export PATH=:$PATH' >> ~/.bashrc 21 | source ~/.bashrc 22 | ``` 23 | 24 | 此时,我们就可以任何时候打开终端,在任意目录下直接使用 `ssx` 了。 25 | 26 | ## 源代码安装 27 | 28 | 如果 release 页面没有提供你所使用的平台的包,你可以可以通过源码来自己编译出对应平台的包: 29 | 30 | > 本地编译需要安装 go 1.19+ 31 | 32 | ```bash 33 | git clone https://github.com/vimiix/ssx.git 34 | cd ssx 35 | make ssx 36 | ``` 37 | 38 | 编译成功后,会在 **dist/** 目录下生成 ssx 的二进制文件。 39 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | # This workflow warns and then closes issues and PRs that have had no activity for a specified amount of time. 2 | # 3 | # You can adjust the behavior by modifying this file. 4 | # For more information, see: 5 | # https://github.com/actions/stale 6 | name: Mark stale issues 7 | 8 | on: 9 | schedule: 10 | - cron: '30 2 * * *' 11 | 12 | jobs: 13 | stale: 14 | 15 | runs-on: ubuntu-latest 16 | permissions: 17 | issues: write 18 | pull-requests: write 19 | 20 | steps: 21 | - uses: actions/stale@v5 22 | with: 23 | repo-token: ${{ secrets.GITHUB_TOKEN }} 24 | stale-issue-message: 'This issue is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days.' 25 | stale-issue-label: 'stale' 26 | days-before-stale: 30 27 | days-before-close: 5 28 | days-before-pr-close: -1 29 | exempt-issue-labels: plan,todo,in-progress,pined 30 | -------------------------------------------------------------------------------- /docs/zh-cn/practices.md: -------------------------------------------------------------------------------- 1 | # 实践 2 | 3 | ## 为所有的机器添加 hostname 的标签 4 | 5 | ```bash 6 | ssx list | grep -E '\s\d' | awk '{print $1}' | xargs -I id sh -c 'ssx --id id -c hostname|xargs ssx tag --id id -t ' 7 | ``` 8 | 9 | ## 查看某个服务器的信息 10 | 11 | ```bash 12 | ssx info --id 13 | ``` 14 | 15 | ## 获取服务器的IP 16 | 17 | ```bash 18 | ssx info --id | jq .host 19 | ``` 20 | 21 | ## 使用跳板机登录目标服务器 22 | 23 | ```bash 24 | # 单个跳板机 25 | ssx -J [proxy_user@]proxy_host[:proxy_port] [user@]host[:port] 26 | 27 | # 多层跳板机 28 | ssx -J address1,address2,... target_address 29 | ``` 30 | 31 | ## 批量备份远程文件到本地 32 | 33 | ```bash 34 | # 从多个服务器下载配置文件 35 | for tag in server1 server2 server3; do 36 | ssx cp $tag:/etc/nginx/nginx.conf ./backup/${tag}_nginx.conf 37 | done 38 | ``` 39 | 40 | ## 在两台服务器之间同步文件 41 | 42 | ```bash 43 | # 将生产服务器的日志复制到备份服务器 44 | ssx cp prod:/var/log/app.log backup:/var/log/app.log 45 | ``` 46 | 47 | ## 使用标签快速上传部署文件 48 | 49 | ```bash 50 | # 上传部署包到所有 web 服务器 51 | ssx cp ./deploy.tar.gz webserver:/opt/deploy/ 52 | ``` 53 | -------------------------------------------------------------------------------- /docs/zh-cn/environment.md: -------------------------------------------------------------------------------- 1 | # 环境变量 2 | 3 | SSX 支持如下环境变量: 4 | 5 | |环境变量名| 说明 | 默认值 | 6 | |:---|:---|:---| 7 | |`SSX_DB_PATH`| 用于存储条目的数据库文件 | ~/.ssx.db | 8 | |`SSX_CONNECT_TIMEOUT`| SSH连接超时,单位支持 h/m/s | `10s` | 9 | |`SSX_IMPORT_SSH_CONFIG`| 是否导入用户ssh配置 | | 10 | |`SSX_SECRET_KEY`| [v0.4+ 废弃] 为了兼容旧版本,该参数会等价于 `SSX_DEVICE_ID` | | 11 | |`SSX_DEVICE_ID`| 数据库文件需要绑定的设备ID,可以通过设置相同的该环境变量来实现不同设备共用同一份数据库 | [设备ID](#设备id) | 12 | 13 | ## 解释 14 | 15 | ### SSX_IMPORT_SSH_CONFIG 16 | 17 | 这个环境变量不设置时,ssx 默认是不会读取用户的 `~/.ssh/config` 文件的,ssx 只使用自己存储文件进行检索。如果将这个环境变量设置为非空(任意字符串),ssx 就会在初始化的时候加载用户 ssh 配置文件中存在的服务器条目,但 ssx 仅读取用于检索和登录,并不会将这些条目持久化到 ssx 的存储文件中,所以,如果 `ssx IP` 登录时,这个 `IP` 是 `~/.ssh/config` 文件中已经配置过登录验证方式的服务器,ssx 匹配到就直接登录了。但 ssx list 查看时,该服务器会被显示到 `found in ssh config` 的表格中,这个表格中的条目是不具有 ID 属性的。 18 | 19 | ### 设备ID 20 | 21 | - Linux 使用 `/var/lib/dbus/machine-id` ([man](http://man7.org/linux/man-pages/man5/machine-id.5.html)) 22 | - OS X 使用 `IOPlatformUUID` 23 | - Windows 使用 `MachineGuid`,取自 `HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Cryptography` 24 | -------------------------------------------------------------------------------- /internal/encrypt/encrypt_test.go: -------------------------------------------------------------------------------- 1 | package encrypt 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestEncryptDecrypt(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | text string 13 | }{ 14 | {"empty", ""}, 15 | {"regular", "abc123"}, 16 | {"symbol", "!*#$)@>?"}, 17 | } 18 | for _, tt := range tests { 19 | t.Run(tt.name, func(t *testing.T) { 20 | actual := Decrypt(Encrypt(tt.text)) 21 | assert.Equal(t, tt.text, actual) 22 | }) 23 | } 24 | } 25 | 26 | func TestDecrypt(t *testing.T) { 27 | tests := []struct { 28 | name string 29 | cipher string 30 | expect string 31 | }{ 32 | {"empty", "", ""}, 33 | {"regular", "NmUxODZmYWM8PTxFPUQ9QENIQUc2eGl4T2pEWnQtQ0I2YkE0RkRxRUI0ei1fLUlNMmZKYi1lTFlnQk0=", "abc123"}, 34 | {"plaintext", "abc123", "abc123"}, 35 | } 36 | for _, tt := range tests { 37 | t.Run(tt.name, func(t *testing.T) { 38 | actual := Decrypt(tt.cipher) 39 | assert.Equal(t, tt.expect, actual) 40 | }) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /internal/lg/lg.go: -------------------------------------------------------------------------------- 1 | package lg 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "strings" 8 | "sync/atomic" 9 | "time" 10 | 11 | "github.com/fatih/color" 12 | ) 13 | 14 | var ( 15 | verbose = atomic.Bool{} 16 | logger = log.New(os.Stderr, "", 0) 17 | ) 18 | 19 | func SetVerbose(v bool) { 20 | verbose.Store(v) 21 | } 22 | 23 | func defaultPrint(lvl, message string) { 24 | ts := time.Now().Format(time.RFC3339) 25 | logger.Print( 26 | strings.Join([]string{ts, lvl, message}, " "), 27 | ) 28 | } 29 | 30 | // printFunc 便于单元测试打桩 31 | var printFunc = defaultPrint 32 | 33 | func Debug(format string, v ...any) { 34 | if verbose.Load() { 35 | printFunc("DEBUG", fmt.Sprintf(format, v...)) 36 | } 37 | } 38 | 39 | func Info(format string, v ...any) { 40 | printFunc("INFO", fmt.Sprintf(format, v...)) 41 | } 42 | 43 | func Warn(format string, v ...any) { 44 | printFunc("WARN", color.YellowString(format, v...)) 45 | } 46 | 47 | func Error(format string, v ...any) { 48 | printFunc("ERROR", color.RedString(format, v...)) 49 | } 50 | -------------------------------------------------------------------------------- /cmd/ssx/cmd/info.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | "github.com/vimiix/ssx/ssx" 8 | ) 9 | 10 | func newInfoCmd() *cobra.Command { 11 | opt := new(ssx.CmdOption) 12 | cmd := &cobra.Command{ 13 | Use: "info", 14 | Short: "show entry detail", 15 | RunE: func(cmd *cobra.Command, args []string) error { 16 | if len(args) > 0 { 17 | // just use first word as search key 18 | opt.Keyword = args[0] 19 | } 20 | e, err := ssxInst.GetEntry(opt) 21 | if err != nil { 22 | return err 23 | } 24 | if e.ID <= 0 && opt.Keyword != "" { 25 | return fmt.Errorf("not matched any entry for %q", opt.Keyword) 26 | } 27 | bs, err := e.JSON() 28 | if err != nil { 29 | return err 30 | } 31 | fmt.Println(string(bs)) 32 | return nil 33 | }} 34 | cmd.Flags().Uint64VarP(&opt.EntryID, "id", "", 0, "entry id") 35 | cmd.Flags().StringVarP(&opt.Addr, "server", "s", "", "target server address\nsupport formats: [user@]host[:port]") 36 | cmd.Flags().StringVarP(&opt.Tag, "tag", "t", "", "search entry by tag") 37 | 38 | return cmd 39 | } 40 | -------------------------------------------------------------------------------- /docs/en-us/why-need-it.md: -------------------------------------------------------------------------------- 1 | # Why SSX 2 | 3 | As a backend developer, I frequently work with numerous servers (conservatively speaking, I interact with 50+ servers daily). SSH is an indispensable development tool. However, having to enter passwords every time I login - especially for servers where I can't set up key-based authentication because they're just for temporary troubleshooting - is somewhat unacceptable for a programmer who believes everything should be automated (if you use GUI tools, you can ignore this). 4 | 5 | Before developing SSX, I thought about what I needed: an SSH client that doesn't need many complex features, just these few requirements for daily use: 6 | 7 | - Similar usage habits to standard ssh 8 | - Only ask for password on first login, no password needed for subsequent logins 9 | - Ability to tag servers freely, so I can login via IP or tag 10 | 11 | So in my spare time, I designed and developed SSX - a lightweight SSH client with memory. It perfectly implements all the features I needed and has been happily integrated into my daily development workflow, greatly improving my productivity. 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Qian Yao 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | builds: 3 | - id: "ssx" 4 | main: "./cmd/ssx" 5 | binary: "ssx" 6 | flags: 7 | - -trimpath 8 | ldflags: 9 | - -s -w 10 | - -X github.com/vimiix/ssx/ssx/version.Version={{.Tag}} 11 | - -X github.com/vimiix/ssx/ssx/version.Revision={{.ShortCommit}} 12 | - -X github.com/vimiix/ssx/ssx/version.BuildDate={{.Date}} 13 | env: 14 | - CGO_ENABLED=0 15 | - GO111MODULE=on 16 | targets: 17 | - linux_amd64 18 | - linux_arm64 19 | - darwin_amd64 20 | - darwin_arm64 21 | - windows_amd64 22 | archives: 23 | - name_template: '{{ .ProjectName }}_{{ .Tag }}_{{ .Os }}_{{- if eq .Arch "amd64" }}x86_64{{- else }}{{ .Arch }}{{ end }}{{ if .Arm }}v{{ .Arm }}{{ end }}' 24 | format: tar.gz 25 | format_overrides: 26 | - goos: windows 27 | format: zip 28 | files: 29 | - LICENSE 30 | - README.md 31 | snapshot: 32 | name_template: "{{ incpatch .Version }}-preview" 33 | changelog: 34 | use: github 35 | format: "{{.SHA}}: {{.Message}} (@{{.AuthorUsername}})" 36 | filters: 37 | exclude: 38 | - "^docs:" 39 | - "^chore:" 40 | - (?i)Merge pull request 41 | -------------------------------------------------------------------------------- /docs/zh-cn/overview.md: -------------------------------------------------------------------------------- 1 |

2 | 4 |

5 | 6 |

7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |

16 | 17 | > 🦅 SSX 是一个有记忆的 SSH 客户端 18 | 19 | ## ✨ 特性 20 | 21 | SSX 是一个使用 [Go 语言](https://go.dev/)开发的,无依赖单文件即可运行的二进制文件,他在尽可能地保留和 ssh 类似的使用体验基础上,支持了如下特性: 22 | 23 | - 自动安全的存储已经登录过的服务器条目 24 | - 支持对登录过的条目进行打标签 25 | - 支持通过IP片段或标签进行模糊搜索登录 26 | - 支持文件上传、下载和远程到远程复制 27 | - 支持一键自动升级 28 | 29 | ## 📝 协议 30 | 31 | SSX 在 MIT 许可协议下分发。可查看 [LICENSE](https://github.com/vimiix/ssx/blob/main/LICENSE) 文件获取详情 32 | -------------------------------------------------------------------------------- /docs/en-us/practices.md: -------------------------------------------------------------------------------- 1 | # Practices 2 | 3 | ## Add hostname as tag for all machines 4 | 5 | ```bash 6 | ssx list | grep -E '\s\d' | awk '{print $1}' | xargs -I id sh -c 'ssx --id id -c hostname|xargs ssx tag --id id -t ' 7 | ``` 8 | 9 | ## View server information 10 | 11 | ```bash 12 | ssx info --id 13 | ``` 14 | 15 | ## Get server IP 16 | 17 | ```bash 18 | ssx info --id | jq .host 19 | ``` 20 | 21 | ## Login via jump server 22 | 23 | ```bash 24 | # Single jump server 25 | ssx -J [proxy_user@]proxy_host[:proxy_port] [user@]host[:port] 26 | 27 | # Multiple jump servers 28 | ssx -J address1,address2,... target_address 29 | ``` 30 | 31 | ## Batch backup remote files to local 32 | 33 | ```bash 34 | # Download config files from multiple servers 35 | for tag in server1 server2 server3; do 36 | ssx cp $tag:/etc/nginx/nginx.conf ./backup/${tag}_nginx.conf 37 | done 38 | ``` 39 | 40 | ## Sync files between two servers 41 | 42 | ```bash 43 | # Copy logs from production to backup server 44 | ssx cp prod:/var/log/app.log backup:/var/log/app.log 45 | ``` 46 | 47 | ## Quick upload deployment files using tags 48 | 49 | ```bash 50 | # Upload deployment package to all web servers 51 | ssx cp ./deploy.tar.gz webserver:/opt/deploy/ 52 | ``` 53 | -------------------------------------------------------------------------------- /internal/terminal/terminal_unix.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | 3 | package terminal 4 | 5 | import ( 6 | "context" 7 | "os" 8 | "os/signal" 9 | "syscall" 10 | 11 | "golang.org/x/crypto/ssh" 12 | "golang.org/x/term" 13 | 14 | "github.com/vimiix/ssx/internal/lg" 15 | ) 16 | 17 | func readPassword() ([]byte, error) { 18 | return term.ReadPassword(syscall.Stdin) 19 | } 20 | 21 | func GetAndWatchWindowSize(ctx context.Context, sess *ssh.Session) (int, int, error) { 22 | fd := int(os.Stdin.Fd()) 23 | width, height, err := term.GetSize(fd) 24 | if err != nil { 25 | return 0, 0, err 26 | } 27 | 28 | go func() { 29 | if err := watchWindowSize(ctx, sess, fd); err != nil { 30 | lg.Debug("watching window size err: %s", err) 31 | } 32 | }() 33 | 34 | return width, height, nil 35 | } 36 | 37 | func watchWindowSize(ctx context.Context, sess *ssh.Session, fd int) error { 38 | sigChan := make(chan os.Signal, 1) 39 | signal.Notify(sigChan, syscall.SIGWINCH) 40 | 41 | for { 42 | select { 43 | case <-sigChan: 44 | case <-ctx.Done(): 45 | return nil 46 | } 47 | 48 | w, h, err := term.GetSize(fd) 49 | if err != nil { 50 | return err 51 | } 52 | if err = sess.WindowChange(h, w); err != nil { 53 | return err 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /cmd/ssx/cmd/tag.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/pkg/errors" 5 | "github.com/spf13/cobra" 6 | ) 7 | 8 | func newTagCmd() *cobra.Command { 9 | var ( 10 | appendtTags []string 11 | deleteTags []string 12 | id int 13 | ) 14 | cmd := &cobra.Command{ 15 | Use: "tag", 16 | Aliases: []string{"t"}, 17 | Short: "add or delete tag for entry by id", 18 | Example: "ssx tag --id [-t TAG1 [-t TAG2 ...]] [-d TAG3 [-d TAG4 ...]]", 19 | RunE: func(cmd *cobra.Command, args []string) error { 20 | if len(appendtTags) == 0 && len(deleteTags) == 0 { 21 | return errors.New("no tag is spicified") 22 | } 23 | if len(deleteTags) > 0 { 24 | if err := ssxInst.DeleteTagByID(id, deleteTags...); err != nil { 25 | return err 26 | } 27 | } 28 | if len(appendtTags) > 0 { 29 | if err := ssxInst.AppendTagByID(id, appendtTags...); err != nil { 30 | return err 31 | } 32 | } 33 | return nil 34 | }, 35 | } 36 | 37 | cmd.Flags().IntVarP(&id, "id", "", 0, "entry id") 38 | cmd.Flags().StringSliceVarP(&appendtTags, "tag", "t", nil, "tag name to add") 39 | cmd.Flags().StringSliceVarP(&deleteTags, "delete", "d", nil, "tag name to delete") 40 | _ = cmd.MarkFlagRequired("id") 41 | return cmd 42 | } 43 | -------------------------------------------------------------------------------- /docs/en-us/install.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | ## Download Online 4 | 5 | You can download the package for your platform from the GitHub [release page](https://github.com/vimiix/ssx/releases). 6 | 7 | > Download link: [https://github.com/vimiix/ssx/releases](https://github.com/vimiix/ssx/releases) 8 | 9 | After downloading, extract the archive to get the `ssx` binary file (`ssx.exe` on Windows). 10 | 11 | For example, on `linux x86_64`: 12 | 13 | ```bash 14 | tar -xvf ssx_vX.Y.Z_linux_x86_64.tar.gz 15 | ``` 16 | 17 | Place the extracted `ssx` binary in any directory included in your `$PATH`. You can also use it directly via `./ssx` or with an absolute path. For convenience, it's recommended to place it in a directory included in `$PATH`, such as `/usr/local/bin`. If the directory containing ssx is not in `$PATH`, you can add it: 18 | 19 | ```bash 20 | echo 'export PATH=:$PATH' >> ~/.bashrc 21 | source ~/.bashrc 22 | ``` 23 | 24 | Now you can use `ssx` directly from any terminal, in any directory. 25 | 26 | ## Build from Source 27 | 28 | If the release page doesn't provide a package for your platform, you can compile it yourself from source: 29 | 30 | > Local compilation requires Go 1.19+ 31 | 32 | ```bash 33 | git clone https://github.com/vimiix/ssx.git 34 | cd ssx 35 | make ssx 36 | ``` 37 | 38 | After successful compilation, the ssx binary will be generated in the **dist/** directory. 39 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/vimiix/ssx 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | github.com/agiledragon/gomonkey/v2 v2.11.0 7 | github.com/bramvdbogaerde/go-scp v1.5.0 8 | github.com/containerd/console v1.0.5 9 | github.com/denisbrodbeck/machineid v1.0.1 10 | github.com/fatih/color v1.18.0 11 | github.com/jinzhu/copier v0.4.0 12 | github.com/kevinburke/ssh_config v1.4.0 13 | github.com/manifoldco/promptui v0.9.0 14 | github.com/pkg/errors v0.9.1 15 | github.com/skeema/knownhosts v1.3.2 16 | github.com/spf13/cobra v1.10.2 17 | github.com/stretchr/testify v1.11.1 18 | github.com/tidwall/gjson v1.18.0 19 | github.com/vimiix/tablewriter v0.0.0-20231207073205-aad9e2006284 20 | go.etcd.io/bbolt v1.4.3 21 | golang.org/x/crypto v0.45.0 22 | golang.org/x/sys v0.39.0 23 | golang.org/x/term v0.37.0 24 | ) 25 | 26 | require ( 27 | github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect 28 | github.com/davecgh/go-spew v1.1.1 // indirect 29 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 30 | github.com/mattn/go-colorable v0.1.13 // indirect 31 | github.com/mattn/go-isatty v0.0.20 // indirect 32 | github.com/mattn/go-runewidth v0.0.15 // indirect 33 | github.com/pmezard/go-difflib v1.0.0 // indirect 34 | github.com/rivo/uniseg v0.4.4 // indirect 35 | github.com/spf13/pflag v1.0.9 // indirect 36 | github.com/tidwall/match v1.1.1 // indirect 37 | github.com/tidwall/pretty v1.2.1 // indirect 38 | gopkg.in/yaml.v3 v3.0.1 // indirect 39 | ) 40 | -------------------------------------------------------------------------------- /docs/en-us/overview.md: -------------------------------------------------------------------------------- 1 |

2 | 4 |

5 | 6 |

7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |

16 | 17 | > 🦅 SSX is a retentive SSH client 18 | 19 | ## ✨ Features 20 | 21 | SSX is a dependency-free, single-binary executable developed in [Go](https://go.dev/). While preserving an SSH-like user experience, it supports the following features: 22 | 23 | - Automatically and securely store previously logged-in server entries 24 | - Support tagging for logged-in entries 25 | - Support fuzzy search login by IP fragment or tag 26 | - Support file upload, download, and remote-to-remote copy 27 | - Support one-click automatic upgrade 28 | 29 | ## 📝 License 30 | 31 | SSX is distributed under the MIT License. See [LICENSE](https://github.com/vimiix/ssx/blob/main/LICENSE) for more information. 32 | -------------------------------------------------------------------------------- /docs/en-us/environment.md: -------------------------------------------------------------------------------- 1 | # Environment Variables 2 | 3 | SSX supports the following environment variables: 4 | 5 | | Variable | Description | Default | 6 | |:---|:---|:---| 7 | | `SSX_DB_PATH` | Database file for storing entries | ~/.ssx.db | 8 | | `SSX_CONNECT_TIMEOUT` | SSH connection timeout (supports h/m/s units) | `10s` | 9 | | `SSX_IMPORT_SSH_CONFIG` | Whether to import user ssh config | | 10 | | `SSX_SECRET_KEY` | [Deprecated in v0.4+] For backward compatibility, equivalent to `SSX_DEVICE_ID` | | 11 | | `SSX_DEVICE_ID` | Device ID to bind the database file. Set the same value across devices to share a database | [Device ID](#device-id) | 12 | 13 | ## Explanation 14 | 15 | ### SSX_IMPORT_SSH_CONFIG 16 | 17 | When this environment variable is not set, ssx doesn't read the user's `~/.ssh/config` file by default. ssx only uses its own storage file for searching. If you set this environment variable to any non-empty string, ssx will load server entries from the user's ssh config file during initialization. However, ssx only reads these for searching and login purposes - it doesn't persist them to ssx's storage file. So when you run `ssx IP` and that IP is already configured in `~/.ssh/config` with authentication, ssx will match and login directly. In `ssx list`, these servers appear in the `found in ssh config` table, which doesn't have an ID property. 18 | 19 | ### Device ID 20 | 21 | - Linux uses `/var/lib/dbus/machine-id` ([man](http://man7.org/linux/man-pages/man5/machine-id.5.html)) 22 | - OS X uses `IOPlatformUUID` 23 | - Windows uses `MachineGuid` from `HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Cryptography` 24 | -------------------------------------------------------------------------------- /internal/lg/lg_test.go: -------------------------------------------------------------------------------- 1 | package lg 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | ) 7 | 8 | func _fmt(lvl, msg string) string { 9 | return lvl + " " + msg 10 | } 11 | 12 | func replacePrint() (buf *strings.Builder, restore func()) { 13 | buf = &strings.Builder{} 14 | rawPrint := printFunc 15 | printFunc = func(lvl, message string) { 16 | buf.WriteString(_fmt(lvl, message)) 17 | } 18 | return buf, func() { 19 | printFunc = rawPrint 20 | } 21 | } 22 | 23 | func TestSetVerbose(t *testing.T) { 24 | buf := &strings.Builder{} 25 | rawPrint := printFunc 26 | printFunc = func(_, message string) { 27 | buf.WriteString(message) 28 | } 29 | defer func() { 30 | printFunc = rawPrint 31 | }() 32 | SetVerbose(true) 33 | Debug("debugmsg") 34 | if buf.String() != "debugmsg" { 35 | t.Errorf("expect defaultPrint \"debugmsg\" if set verbose to true, but got:\n%q", buf.String()) 36 | } 37 | } 38 | 39 | func TestInfo(t *testing.T) { 40 | buf, restore := replacePrint() 41 | defer restore() 42 | Info("msg") 43 | actual := buf.String() 44 | expect := _fmt("INFO", "msg") 45 | if actual != expect { 46 | t.Errorf("expect %q, got %q", expect, actual) 47 | } 48 | } 49 | 50 | func TestWarn(t *testing.T) { 51 | buf, restore := replacePrint() 52 | defer restore() 53 | Warn("msg") 54 | actual := buf.String() 55 | expect := _fmt("WARN", "msg") 56 | if actual != expect { 57 | t.Errorf("expect %q, got %q", expect, actual) 58 | } 59 | } 60 | 61 | func TestError(t *testing.T) { 62 | buf, restore := replacePrint() 63 | defer restore() 64 | Error("msg") 65 | actual := buf.String() 66 | expect := _fmt("ERROR", "msg") 67 | if actual != expect { 68 | t.Errorf("expect %q, got %q", expect, actual) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /e2e/version_test.go: -------------------------------------------------------------------------------- 1 | package e2e 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | ) 7 | 8 | // TestVersion tests the version flag 9 | func TestVersion(t *testing.T) { 10 | stdout, _, err := runSSX(t, "--version") 11 | if err != nil { 12 | t.Fatalf("ssx --version failed: %v", err) 13 | } 14 | 15 | // Version output contains version info 16 | if !strings.Contains(stdout, "Version") { 17 | t.Errorf("Expected version output to contain 'Version', got: %s", stdout) 18 | } 19 | } 20 | 21 | // TestHelp tests the help flag 22 | func TestHelp(t *testing.T) { 23 | stdout, _, err := runSSX(t, "--help") 24 | if err != nil { 25 | t.Fatalf("ssx --help failed: %v", err) 26 | } 27 | 28 | expectedStrings := []string{ 29 | "ssx is a retentive ssh client", 30 | "Available Commands:", 31 | "list", 32 | "delete", 33 | "tag", 34 | "info", 35 | "cp", 36 | "upgrade", 37 | } 38 | 39 | for _, expected := range expectedStrings { 40 | if !strings.Contains(stdout, expected) { 41 | t.Errorf("Expected help output to contain %q, got: %s", expected, stdout) 42 | } 43 | } 44 | } 45 | 46 | // TestCpHelp tests the cp command help 47 | func TestCpHelp(t *testing.T) { 48 | stdout, _, err := runSSX(t, "cp", "--help") 49 | if err != nil { 50 | t.Fatalf("ssx cp --help failed: %v", err) 51 | } 52 | 53 | expectedStrings := []string{ 54 | "Copy files between local and remote hosts", 55 | "remote-to-remote", 56 | "SOURCE", 57 | "TARGET", 58 | "--identity-file", 59 | "--jump-server", 60 | "--port", 61 | } 62 | 63 | for _, expected := range expectedStrings { 64 | if !strings.Contains(stdout, expected) { 65 | t.Errorf("Expected cp help to contain %q, got: %s", expected, stdout) 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /internal/terminal/terminal_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | 3 | package terminal 4 | 5 | import ( 6 | "context" 7 | "time" 8 | 9 | "golang.org/x/crypto/ssh" 10 | "golang.org/x/sys/windows" 11 | "golang.org/x/term" 12 | 13 | "github.com/vimiix/ssx/internal/lg" 14 | ) 15 | 16 | func readPassword() ([]byte, error) { 17 | return term.ReadPassword(int(windows.Stdin)) 18 | } 19 | 20 | func GetAndWatchWindowSize(ctx context.Context, sess *ssh.Session) (int, int, error) { 21 | fd := windows.Stdout 22 | width, height, err := getConsoleSize(fd) 23 | if err != nil { 24 | return 0, 0, err 25 | } 26 | 27 | go func() { 28 | if err := watchWindowSize(ctx, sess, fd, width, height); err != nil { 29 | lg.Debug("watching window size err: %s", err) 30 | } 31 | }() 32 | 33 | return width, height, nil 34 | } 35 | 36 | func watchWindowSize(ctx context.Context, sess *ssh.Session, fd windows.Handle, width, height int) error { 37 | for { 38 | select { 39 | case <-ctx.Done(): 40 | return nil 41 | case <-time.After(500 * time.Millisecond): 42 | } 43 | 44 | newWidth, newHeight, err := getConsoleSize(fd) 45 | if err != nil { 46 | return err 47 | } 48 | 49 | if newWidth == width && newHeight == height { 50 | continue 51 | } 52 | 53 | width = newWidth 54 | height = newHeight 55 | 56 | if err = sess.WindowChange(height, width); err != nil { 57 | return err 58 | } 59 | } 60 | } 61 | 62 | func getConsoleSize(fd windows.Handle) (int, int, error) { 63 | var csbi windows.ConsoleScreenBufferInfo 64 | err := windows.GetConsoleScreenBufferInfo(fd, &csbi) 65 | if err != nil { 66 | return 0, 0, err 67 | } 68 | 69 | width := csbi.Window.Right - csbi.Window.Left + 1 70 | height := csbi.Window.Bottom - csbi.Window.Top + 1 71 | 72 | return int(width), int(height), nil 73 | } 74 | -------------------------------------------------------------------------------- /ssx/entry/proxy.go: -------------------------------------------------------------------------------- 1 | package entry 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net" 7 | 8 | "github.com/vimiix/ssx/internal/utils" 9 | "golang.org/x/crypto/ssh" 10 | ) 11 | 12 | // Proxy represents a jump server 13 | // Usage example: ssx -J [,,] 14 | type Proxy struct { 15 | Host string `json:"host"` 16 | User string `json:"user"` 17 | Port string `json:"port"` 18 | Password string `json:"password"` 19 | Proxy *Proxy `json:"proxy"` 20 | } 21 | 22 | func (p *Proxy) Mask() { 23 | if p == nil { 24 | return 25 | } 26 | p.Password = utils.MaskString(p.Password) 27 | if p.Proxy != nil { 28 | p.Proxy.Mask() 29 | } 30 | } 31 | 32 | func (p *Proxy) tidy() { 33 | if p.User == "" { 34 | p.User = defaultUser 35 | } 36 | if p.Port == "" { 37 | p.Port = defaultPort 38 | } 39 | if p.Proxy != nil { 40 | p.Proxy.tidy() 41 | } 42 | } 43 | 44 | func (p *Proxy) Address() string { 45 | return net.JoinHostPort(p.Host, p.Port) 46 | } 47 | 48 | func (p *Proxy) String() string { 49 | return fmt.Sprintf("%s@%s:%s", p.User, p.Host, p.Port) 50 | } 51 | 52 | func (p *Proxy) GenSSHConfig(ctx context.Context) (*ssh.ClientConfig, error) { 53 | cb, err := sshHostKeyCallback() 54 | if err != nil { 55 | return nil, err 56 | } 57 | var auth []ssh.AuthMethod 58 | if p.Password != "" { 59 | auth = append(auth, ssh.Password(p.Password)) 60 | } else { 61 | auth = append(auth, passwordCallback( 62 | ctx, p.User, p.Host, func(password string) { p.Password = password }, 63 | )) 64 | } 65 | cfg := &ssh.ClientConfig{ 66 | User: p.User, 67 | Auth: auth, 68 | HostKeyCallback: cb, 69 | Timeout: getConnectTimeout(), 70 | } 71 | return cfg, nil 72 | } 73 | 74 | func (p *Proxy) ClearPassword() { 75 | p.Password = "" 76 | if p.Proxy != nil { 77 | p.Proxy.ClearPassword() 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | VERSION := $(shell head -1 version) 2 | GO := GO111MODULE=on CGO_ENABLED=0 go 3 | _COMMIT := $(shell git describe --no-match --always --dirty) 4 | COMMIT := $(if $(COMMIT),$(COMMIT),$(_COMMIT)) 5 | BUILDDATE := $(shell date '+%Y-%m-%dT%H:%M:%S') 6 | REPO := github.com/vimiix/ssx 7 | LDFLAGS := -X "$(REPO)/ssx/version.Version=$(VERSION)" 8 | LDFLAGS += -X "$(REPO)/ssx/version.Revision=$(COMMIT)" 9 | LDFLAGS += -X "$(REPO)/ssx/version.BuildDate=$(BUILDDATE)" 10 | LDFLAGS += $(EXTRA_LDFLAGS) 11 | FILES := $$(find . -name "*.go") 12 | TEST_FILES := $$(go list ./...) 13 | 14 | .PHONY: help 15 | help: ## print help info 16 | @printf "%-30s %s\n" "Target" "Description" 17 | @printf "%-30s %s\n" "------" "-----------" 18 | @grep -E '^[ a-zA-Z1-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \ 19 | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 20 | 21 | .PHONY: tidy 22 | tidy: ## run go mod tidy 23 | @echo "go mod tidy" 24 | $(GO) mod tidy 25 | 26 | .PHONY: fmt 27 | fmt: ## format source code 28 | @echo "gofmt (simplify)" 29 | @gofmt -s -l -w $(FILES) 2>&1 30 | @echo "goimports (if installed)" 31 | $(shell goimports -w $(FILES) 2>/dev/null) 32 | 33 | .PHONY: lint 34 | lint: tidy fmt ## lint code with golangci-lint 35 | $([[ command -v golangci-lint ]] || go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.51.1) 36 | @golangci-lint run -v 37 | 38 | .PHONY: test 39 | test: ## run all unit tests 40 | $(GO) test -gcflags=all=-l $(TEST_FILES) -coverprofile dist/cov.out -covermode count 41 | 42 | .PHONY: ssx 43 | ssx: ## build ssx binary 44 | $(GO) build -ldflags '$(LDFLAGS)' -gcflags '-N -l' -o dist/ssx ./cmd/ssx/main.go 45 | 46 | .PHONY: tag 47 | tag: ## make tag with version.txt 48 | git tag -a "$(VERSION)" -m "Release version $(VERSION)" 49 | 50 | .PHONY: snapshot 51 | snapshot: ## build ssx release snapshot 52 | goreleaser release --clean --snapshot -------------------------------------------------------------------------------- /cmd/ssx/cmd/cp.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/vimiix/ssx/ssx" 7 | ) 8 | 9 | func newCpCmd() *cobra.Command { 10 | opt := &ssx.CpOption{} 11 | cmd := &cobra.Command{ 12 | Use: "cp ", 13 | Short: "copy files between local and remote hosts", 14 | Long: `Copy files between local and remote hosts using SCP protocol. 15 | Supports local-to-remote, remote-to-local, and remote-to-remote transfers. 16 | 17 | For remote-to-remote transfers, files are streamed through ssx without 18 | being stored locally, acting as a relay between the two remote hosts. 19 | 20 | Path format: 21 | Local: /path/to/file or ./relative/path 22 | Remote: [user@]host[:port]:/path/to/file 23 | tag:/path/to/file (use stored entry by tag/keyword) 24 | 25 | Examples: 26 | # Upload local file to remote 27 | ssx cp ./local.txt root@192.168.1.100:/tmp/remote.txt 28 | ssx cp ./local.txt myserver:/tmp/remote.txt 29 | 30 | # Download remote file to local 31 | ssx cp root@192.168.1.100:/tmp/remote.txt ./local.txt 32 | ssx cp myserver:/tmp/remote.txt ./local.txt 33 | 34 | # Remote to remote (streaming through ssx) 35 | ssx cp root@192.168.1.100:/tmp/file.txt root@192.168.1.200:/tmp/file.txt 36 | ssx cp server1:/data/file.txt server2:/backup/file.txt 37 | 38 | # With custom port 39 | ssx cp ./local.txt root@192.168.1.100:2222:/tmp/remote.txt 40 | 41 | # With identity file 42 | ssx cp -i ~/.ssh/id_rsa ./local.txt root@192.168.1.100:/tmp/remote.txt`, 43 | Args: cobra.ExactArgs(2), 44 | RunE: func(cmd *cobra.Command, args []string) error { 45 | opt.Source = args[0] 46 | opt.Target = args[1] 47 | return ssxInst.Copy(cmd.Context(), opt) 48 | }, 49 | } 50 | 51 | cmd.Flags().StringVarP(&opt.IdentityFile, "identity-file", "i", "", "identity file path for authentication") 52 | cmd.Flags().StringVarP(&opt.JumpServers, "jump-server", "J", "", "jump servers (proxy)") 53 | cmd.Flags().IntVarP(&opt.Port, "port", "P", 22, "port to connect to on the remote host") 54 | 55 | return cmd 56 | } 57 | -------------------------------------------------------------------------------- /e2e/list_test.go: -------------------------------------------------------------------------------- 1 | package e2e 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | ) 7 | 8 | // TestListEmpty tests listing entries when database is empty 9 | func TestListEmpty(t *testing.T) { 10 | setupDB(t) 11 | 12 | _, stderr, err := runSSXWithDB(t, "list") 13 | // Should return error when no entries exist 14 | if err == nil { 15 | t.Log("list command succeeded with empty database") 16 | } 17 | 18 | // Check for "no entry" message in combined output 19 | combined := stderr 20 | if !strings.Contains(combined, "no entry") && err != nil { 21 | t.Logf("Expected 'no entry' message or success, got stderr: %s", stderr) 22 | } 23 | } 24 | 25 | // TestListAliases tests list command aliases 26 | func TestListAliases(t *testing.T) { 27 | setupDB(t) 28 | 29 | // Test 'l' alias 30 | _, _, err1 := runSSXWithDB(t, "l") 31 | // Test 'ls' alias 32 | _, _, err2 := runSSXWithDB(t, "ls") 33 | 34 | // Both should behave the same (either succeed or fail with same error) 35 | if (err1 == nil) != (err2 == nil) { 36 | t.Errorf("List aliases behave differently: 'l' err=%v, 'ls' err=%v", err1, err2) 37 | } 38 | } 39 | 40 | // TestListAfterConnection tests listing entries after a successful connection 41 | func TestListAfterConnection(t *testing.T) { 42 | skipIfNoServer(t) 43 | cleanupDB(t) 44 | 45 | // First, connect to server to create an entry 46 | args := []string{serverAddr(), "-c", "echo hello"} 47 | if cfg.KeyPath != "" { 48 | args = append(args, "-i", cfg.KeyPath) 49 | } 50 | 51 | _, _, err := runSSXWithInput(t, cfg.Password+"\n", args...) 52 | if err != nil { 53 | t.Fatalf("Failed to connect to server: %v", err) 54 | } 55 | 56 | // Now list entries 57 | stdout, _, err := runSSX(t, "list") 58 | if err != nil { 59 | t.Fatalf("ssx list failed: %v", err) 60 | } 61 | 62 | // Should contain the server address 63 | if !strings.Contains(stdout, cfg.Host) { 64 | t.Errorf("Expected list output to contain %q, got: %s", cfg.Host, stdout) 65 | } 66 | 67 | if !strings.Contains(stdout, cfg.User) { 68 | t.Errorf("Expected list output to contain %q, got: %s", cfg.User, stdout) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /docs/zh-cn/release-notes.md: -------------------------------------------------------------------------------- 1 | # 发布日志 2 | 3 | ## v0.5.0 4 | 5 | 发布时间:2024年11月14日 6 | 7 | ### BREAKING CHANGE 8 | 9 | - Changed the SSH identity file flag from `-k` to `-i` to be more compatible with the standard `ssh` command 10 | - Changed the entry ID flag from `-i` to `--id` across all commands for consistency 11 | - Updated command examples and help text to reflect the new flag names 12 | 13 | ### Why 14 | 15 | - The `-i` flag is the standard flag for specifying identity files in SSH, making SSX more intuitive for users familiar with SSH 16 | - Using `--id` for entry IDs makes the parameter name more descriptive and avoids conflict with the SSH identity file flag 17 | 18 | ### Example Usage 19 | 20 | ```text 21 | Old: ssx delete -i 123 22 | New: ssx delete --id 123 23 | 24 | Old: ssx -k ~/.ssh/id_rsa 25 | New: ssx -i ~/.ssh/id_rsa 26 | ``` 27 | 28 | ## v0.4.3 29 | 30 | 发布时间:2024年9月20日 31 | 32 | **Bug Fix:** 33 | 34 | - 修复Mac m1中使用时,会出现unexpected fault address 0xxxxx的问题 ([#62](https://github.com/vimiix/ssx/issues/62)) 35 | 36 | ## v0.4.2 37 | 38 | 发布时间:2024年9月18日 39 | 40 | **Changelog:** 41 | 42 | - 更新依赖库版本 43 | 44 | ## v0.4.1 45 | 46 | 发布时间:2024年8月28日 47 | 48 | **Changelog:** 49 | 50 | - 更新依赖库版本 51 | 52 | ## v0.4.0 53 | 54 | 发布时间:2024年7月10日 55 | 56 | **Features:** 57 | 58 | - 强制要求给数据库文件设置管理员密码,未设置首次登录会要求补充 59 | - 新增环境变量 `SSX_DEVIVE_ID`, 默认采用设备ID,数据库文件会绑定设备,如果迁移到其他机器需校验管理员密码后更新设备ID 60 | 61 | **BREAKING CHANGE:** 62 | 63 | - 废弃参数 `--unsafe` 64 | - 废弃环境变量 `SSX_UNSAFE_MODE` 和 `SSX_SECRET_KEY` 65 | - 如果旧版本存在 safe 模式的条目,需要在登录时重新输入一次密码 66 | 67 | ## v0.3.1 68 | 69 | 发布时间:2024年6月18日 70 | 71 | **Features:** 72 | 73 | - 升级时校验当前版本和最新版,避免重复升级 74 | - 升级时无需加载条目数据库,减少非必要逻辑 75 | 76 | ## v0.3.0 77 | 78 | 发布时间:2024年6月12日 79 | 80 | **Features:** 81 | 82 | - 支持通过 `-k` 参数刷新存储的密钥记录 83 | - 支持在线升级 84 | 85 | **BREAKING CHANGE:** 86 | 87 | - 将 `--server` 和 `--tag` 标记为已弃用参数 88 | 89 | ## v0.2.0 90 | 91 | 发布时间:2024年6月11日 92 | 93 | **Features:** 94 | 95 | - 新增参数 `-p` 支持显式指定端口 96 | - 新增参数 `-J` 支持通过跳板机登录 97 | - 默认使用设备ID加密条目密码 98 | - 默认登录用户由当前用户修改为 root 99 | 100 | ## v0.1.0 101 | 102 | 发布时间:2024年2月29日 103 | 104 | **Features:** 105 | 106 | - 完成初版设计的预期需求,实现最小可用版本。 107 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SSX - A retentive ssh client. 6 | 7 | 8 | 9 | 10 | 11 | 12 |
Loading...
13 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /internal/slice/slice.go: -------------------------------------------------------------------------------- 1 | package slice 2 | 3 | // Distinct returns the unique vals of a slice 4 | func Distinct[T comparable](arrs []T) []T { 5 | m := make(map[T]int) 6 | order := 0 7 | for idx := range arrs { 8 | if _, exist := m[arrs[idx]]; !exist { 9 | m[arrs[idx]] = order 10 | order++ 11 | } 12 | } 13 | res := make([]T, len(m)) 14 | for k, v := range m { 15 | res[v] = k 16 | } 17 | return res 18 | } 19 | 20 | // Union returns a slice that contains the unique values of all the input slices 21 | func Union[T comparable](arrs ...[]T) []T { 22 | m := make(map[T]int) 23 | order := 0 24 | for idx1 := range arrs { 25 | for idx2 := range arrs[idx1] { 26 | if _, exist := m[arrs[idx1][idx2]]; !exist { 27 | m[arrs[idx1][idx2]] = order 28 | order++ 29 | } 30 | } 31 | } 32 | 33 | ret := make([]T, len(m)) 34 | for k, v := range m { 35 | ret[v] = k 36 | } 37 | 38 | return ret 39 | } 40 | 41 | // Intersect returns a slice of values that are present in all the input slices 42 | func Intersect[T comparable](arrs ...[]T) []T { 43 | m := make(map[T]int) 44 | var order []T 45 | for idx1 := range arrs { 46 | tmpArr := Distinct(arrs[idx1]) 47 | for idx2 := range tmpArr { 48 | count, ok := m[tmpArr[idx2]] 49 | if !ok { 50 | order = append(order, tmpArr[idx2]) 51 | m[tmpArr[idx2]] = 1 52 | } else { 53 | m[tmpArr[idx2]] = count + 1 54 | } 55 | } 56 | } 57 | 58 | var ( 59 | ret []T 60 | lenArrs = len(arrs) 61 | ) 62 | for idx := range order { 63 | if m[order[idx]] == lenArrs { 64 | ret = append(ret, order[idx]) 65 | } 66 | } 67 | 68 | return ret 69 | } 70 | 71 | // Difference returns a slice of values that are only present in one of the input slices 72 | func Difference[T comparable](arrs ...[]T) []T { 73 | m := make(map[T]int) 74 | var order []T 75 | for idx1 := range arrs { 76 | tmpArr := Distinct(arrs[idx1]) 77 | for idx2 := range tmpArr { 78 | count, ok := m[tmpArr[idx2]] 79 | if !ok { 80 | order = append(order, tmpArr[idx2]) 81 | m[tmpArr[idx2]] = 1 82 | } else { 83 | m[tmpArr[idx2]] = count + 1 84 | } 85 | } 86 | } 87 | 88 | var ( 89 | ret []T 90 | ) 91 | for idx := range order { 92 | if m[order[idx]] == 1 { 93 | ret = append(ret, order[idx]) 94 | } 95 | } 96 | 97 | return ret 98 | } 99 | 100 | // Delete deletes the element from the slice 101 | func Delete[T comparable](slice []T, elems ...T) []T { 102 | for _, val := range elems { 103 | for idx, elem := range slice { 104 | if val == elem { 105 | slice = append(slice[:idx], slice[idx+1:]...) 106 | } 107 | } 108 | } 109 | return slice 110 | } 111 | -------------------------------------------------------------------------------- /docs/en-us/release-notes.md: -------------------------------------------------------------------------------- 1 | # Release Notes 2 | 3 | ## v0.6.0 4 | 5 | Release Date: TBD 6 | 7 | ### Features 8 | 9 | - Added `cp` subcommand for file copy operations using SCP protocol 10 | - Support local-to-remote file upload 11 | - Support remote-to-local file download 12 | - Support remote-to-remote file transfer (streaming through ssx without local storage) 13 | - Support tag/keyword reference in remote paths 14 | 15 | ## v0.5.0 16 | 17 | Release Date: November 14, 2024 18 | 19 | ### BREAKING CHANGE 20 | 21 | - Changed the SSH identity file flag from `-k` to `-i` to be more compatible with the standard `ssh` command 22 | - Changed the entry ID flag from `-i` to `--id` across all commands for consistency 23 | - Updated command examples and help text to reflect the new flag names 24 | 25 | ### Why 26 | 27 | - The `-i` flag is the standard flag for specifying identity files in SSH, making SSX more intuitive for users familiar with SSH 28 | - Using `--id` for entry IDs makes the parameter name more descriptive and avoids conflict with the SSH identity file flag 29 | 30 | ### Example Usage 31 | 32 | ```text 33 | Old: ssx delete -i 123 34 | New: ssx delete --id 123 35 | 36 | Old: ssx -k ~/.ssh/id_rsa 37 | New: ssx -i ~/.ssh/id_rsa 38 | ``` 39 | 40 | ## v0.4.3 41 | 42 | Release Date: September 20, 2024 43 | 44 | **Bug Fix:** 45 | 46 | - Fixed "unexpected fault address 0xxxxx" issue on Mac M1 ([#62](https://github.com/vimiix/ssx/issues/62)) 47 | 48 | ## v0.4.2 49 | 50 | Release Date: September 18, 2024 51 | 52 | **Changelog:** 53 | 54 | - Updated dependency library versions 55 | 56 | ## v0.4.1 57 | 58 | Release Date: August 28, 2024 59 | 60 | **Changelog:** 61 | 62 | - Updated dependency library versions 63 | 64 | ## v0.4.0 65 | 66 | Release Date: July 10, 2024 67 | 68 | **Features:** 69 | 70 | - Mandatory admin password for database file; first login will prompt if not set 71 | - Added `SSX_DEVICE_ID` environment variable; database file binds to device by default; migration requires admin password verification 72 | 73 | **BREAKING CHANGE:** 74 | 75 | - Deprecated `--unsafe` parameter 76 | - Deprecated `SSX_UNSAFE_MODE` and `SSX_SECRET_KEY` environment variables 77 | - If safe mode entries exist from older versions, password re-entry is required on login 78 | 79 | ## v0.3.1 80 | 81 | Release Date: June 18, 2024 82 | 83 | **Features:** 84 | 85 | - Version check before upgrade to avoid redundant upgrades 86 | - No need to load entry database during upgrade, reducing unnecessary logic 87 | 88 | ## v0.3.0 89 | 90 | Release Date: June 12, 2024 91 | 92 | **Features:** 93 | 94 | - Support refreshing stored key records via `-k` parameter 95 | - Support online upgrade 96 | 97 | **BREAKING CHANGE:** 98 | 99 | - Marked `--server` and `--tag` as deprecated parameters 100 | 101 | ## v0.2.0 102 | 103 | Release Date: June 11, 2024 104 | 105 | **Features:** 106 | 107 | - Added `-p` parameter for explicit port specification 108 | - Added `-J` parameter for jump server login 109 | - Default encryption of entry passwords using device ID 110 | - Changed default login user from current user to root 111 | 112 | ## v0.1.0 113 | 114 | Release Date: February 29, 2024 115 | 116 | **Features:** 117 | 118 | - Completed initial design requirements, implemented minimum viable version 119 | -------------------------------------------------------------------------------- /internal/slice/slice_test.go: -------------------------------------------------------------------------------- 1 | package slice 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | type typ struct{ value int } 10 | 11 | func (a typ) Compare(b typ) int { 12 | if a.value == b.value { 13 | return 0 14 | } 15 | return 1 16 | } 17 | 18 | func TestDistinct(t *testing.T) { 19 | t.Run("string", func(t *testing.T) { 20 | actual := Distinct([]string{"a", "a", "b", "b"}) 21 | assert.Equal(t, []string{"a", "b"}, actual) 22 | }) 23 | 24 | t.Run("integer", func(t *testing.T) { 25 | actual := Distinct([]int{1, 1, 3, 3, 2}) 26 | assert.Equal(t, []int{1, 3, 2}, actual) 27 | }) 28 | 29 | t.Run("object", func(t *testing.T) { 30 | actual := Distinct([]typ{{1}, {1}, {2}}) 31 | assert.Equal(t, []typ{{1}, {2}}, actual) 32 | }) 33 | } 34 | 35 | func TestUnion(t *testing.T) { 36 | t.Run("string", func(t *testing.T) { 37 | actual := Union([]string{"a", "a", "b"}, []string{"b", "c"}) 38 | assert.Equal(t, []string{"a", "b", "c"}, actual) 39 | }) 40 | 41 | t.Run("integer", func(t *testing.T) { 42 | actual := Union([]int{1, 1, 2, 3}, []int{2, 2, 3, 4}, []int{3, 4, 5}) 43 | assert.Equal(t, []int{1, 2, 3, 4, 5}, actual) 44 | }) 45 | 46 | t.Run("integer_order", func(t *testing.T) { 47 | actual := Union([]int{1, 2, 2, 3}, []int{10, 10, 3, 6}, []int{4, 2, 8}) 48 | assert.Equal(t, []int{1, 2, 3, 10, 6, 4, 8}, actual) 49 | }) 50 | 51 | t.Run("object", func(t *testing.T) { 52 | actual := Union([]typ{{1}, {1}, {2}}, []typ{{1}, {3}}) 53 | assert.Equal(t, []typ{{1}, {2}, {3}}, actual) 54 | }) 55 | } 56 | 57 | func TestIntersect(t *testing.T) { 58 | t.Run("string", func(t *testing.T) { 59 | actual := Intersect([]string{"a", "a", "b"}, []string{"b", "c"}) 60 | assert.Equal(t, []string{"b"}, actual) 61 | }) 62 | 63 | t.Run("integer", func(t *testing.T) { 64 | actual := Intersect([]int{1, 1, 3, 2}, []int{2, 10, 3, 4}, []int{2, 3, 4, 5}) 65 | assert.Equal(t, []int{3, 2}, actual) 66 | }) 67 | 68 | t.Run("object", func(t *testing.T) { 69 | actual := Intersect([]typ{{1}, {1}, {2}}, []typ{{1}, {3}}) 70 | assert.Equal(t, []typ{{1}}, actual) 71 | }) 72 | } 73 | 74 | func TestDifference(t *testing.T) { 75 | t.Run("string", func(t *testing.T) { 76 | actual := Difference([]string{"a", "a", "b"}, []string{"b", "c"}) 77 | assert.Equal(t, []string{"a", "c"}, actual) 78 | }) 79 | 80 | t.Run("integer", func(t *testing.T) { 81 | actual := Difference([]int{1, 1, 3, 2}, []int{2, 10, 3, 4}, []int{2, 3, 4, 5}) 82 | assert.Equal(t, []int{1, 10, 5}, actual) 83 | }) 84 | 85 | t.Run("object", func(t *testing.T) { 86 | actual := Difference([]typ{{1}, {1}, {2}}, []typ{{1}, {3}}) 87 | assert.Equal(t, []typ{{2}, {3}}, actual) 88 | }) 89 | } 90 | 91 | func TestDelete(t *testing.T) { 92 | tests := []struct { 93 | name string 94 | slice []int 95 | remove []int 96 | expect []int 97 | }{ 98 | {"common", []int{1, 2, 3, 4, 5}, []int{2, 4}, []int{1, 3, 5}}, 99 | {"partial-non-exist", []int{1, 2, 3, 4, 5}, []int{2, 6}, []int{1, 3, 4, 5}}, 100 | {"all-non-exist", []int{1, 2, 3}, []int{6}, []int{1, 2, 3}}, 101 | } 102 | for _, tt := range tests { 103 | t.Run(tt.name, func(t *testing.T) { 104 | actual := Delete(tt.slice, tt.remove...) 105 | assert.Equal(t, tt.expect, actual) 106 | }) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /ssx/cp_test.go: -------------------------------------------------------------------------------- 1 | package ssx 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestParseCpPath(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | input string 13 | expected *CpPath 14 | }{ 15 | { 16 | name: "local absolute path", 17 | input: "/tmp/file.txt", 18 | expected: &CpPath{ 19 | IsRemote: false, 20 | Path: "/tmp/file.txt", 21 | }, 22 | }, 23 | { 24 | name: "local relative path", 25 | input: "./file.txt", 26 | expected: &CpPath{ 27 | IsRemote: false, 28 | Path: "./file.txt", 29 | }, 30 | }, 31 | { 32 | name: "remote with user and host", 33 | input: "root@192.168.1.100:/tmp/file.txt", 34 | expected: &CpPath{ 35 | IsRemote: true, 36 | User: "root", 37 | Host: "192.168.1.100", 38 | Port: "", 39 | Path: "/tmp/file.txt", 40 | }, 41 | }, 42 | { 43 | name: "remote with user, host and port", 44 | input: "root@192.168.1.100:22:/tmp/file.txt", 45 | expected: &CpPath{ 46 | IsRemote: true, 47 | User: "root", 48 | Host: "192.168.1.100", 49 | Port: "22", 50 | Path: "/tmp/file.txt", 51 | }, 52 | }, 53 | { 54 | name: "remote with host only", 55 | input: "192.168.1.100:/tmp/file.txt", 56 | expected: &CpPath{ 57 | IsRemote: true, 58 | User: "", 59 | Host: "192.168.1.100", 60 | Port: "", 61 | Path: "/tmp/file.txt", 62 | }, 63 | }, 64 | { 65 | name: "remote with hostname", 66 | input: "myserver.example.com:/tmp/file.txt", 67 | expected: &CpPath{ 68 | IsRemote: true, 69 | User: "", 70 | Host: "myserver.example.com", 71 | Port: "", 72 | Path: "/tmp/file.txt", 73 | }, 74 | }, 75 | { 76 | name: "tag with path", 77 | input: "myserver:/tmp/file.txt", 78 | expected: &CpPath{ 79 | IsRemote: true, 80 | RawKeyword: "myserver", 81 | Path: "/tmp/file.txt", 82 | }, 83 | }, 84 | { 85 | name: "tag with home path", 86 | input: "myserver:~/file.txt", 87 | expected: &CpPath{ 88 | IsRemote: true, 89 | RawKeyword: "myserver", 90 | Path: "~/file.txt", 91 | }, 92 | }, 93 | { 94 | name: "remote with underscore in user", 95 | input: "my_user@host:/path", 96 | expected: &CpPath{ 97 | IsRemote: true, 98 | User: "my_user", 99 | Host: "host", 100 | Port: "", 101 | Path: "/path", 102 | }, 103 | }, 104 | { 105 | name: "remote with dot in user", 106 | input: "user.name@host:/path", 107 | expected: &CpPath{ 108 | IsRemote: true, 109 | User: "user.name", 110 | Host: "host", 111 | Port: "", 112 | Path: "/path", 113 | }, 114 | }, 115 | } 116 | 117 | for _, tt := range tests { 118 | t.Run(tt.name, func(t *testing.T) { 119 | result := ParseCpPath(tt.input) 120 | assert.Equal(t, tt.expected.IsRemote, result.IsRemote, "IsRemote mismatch") 121 | assert.Equal(t, tt.expected.Path, result.Path, "Path mismatch") 122 | if tt.expected.IsRemote { 123 | assert.Equal(t, tt.expected.User, result.User, "User mismatch") 124 | assert.Equal(t, tt.expected.Host, result.Host, "Host mismatch") 125 | assert.Equal(t, tt.expected.Port, result.Port, "Port mismatch") 126 | assert.Equal(t, tt.expected.RawKeyword, result.RawKeyword, "RawKeyword mismatch") 127 | } 128 | }) 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /e2e/delete_test.go: -------------------------------------------------------------------------------- 1 | package e2e 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | // TestDeleteEntry tests deleting an entry 10 | func TestDeleteEntry(t *testing.T) { 11 | skipIfNoServer(t) 12 | cleanupDB(t) 13 | 14 | // Create an entry 15 | args := []string{serverAddr(), "-c", "echo setup"} 16 | if cfg.KeyPath != "" { 17 | args = append(args, "-i", cfg.KeyPath) 18 | } 19 | _, _, err := runSSXWithInput(t, cfg.Password+"\n", args...) 20 | if err != nil { 21 | t.Fatalf("Failed to setup entry: %v", err) 22 | } 23 | 24 | // Get entry ID 25 | stdout, _, _ := runSSX(t, "list") 26 | re := regexp.MustCompile(`\s+(\d+)\s+\|`) 27 | matches := re.FindStringSubmatch(stdout) 28 | if len(matches) < 2 { 29 | t.Fatalf("Could not find entry ID in list output: %s", stdout) 30 | } 31 | entryID := matches[1] 32 | 33 | // Delete the entry 34 | _, stderr, err := runSSX(t, "delete", "--id", entryID) 35 | if err != nil { 36 | t.Fatalf("Failed to delete entry: %v, stderr: %s", err, stderr) 37 | } 38 | 39 | // Verify entry was deleted 40 | _, stderr, err = runSSX(t, "list") 41 | // Should either show empty list or error 42 | if err == nil && strings.Contains(stdout, cfg.Host) { 43 | t.Errorf("Entry should have been deleted, but still found in list") 44 | } 45 | } 46 | 47 | // TestDeleteMultipleEntries tests deleting multiple entries at once 48 | func TestDeleteMultipleEntries(t *testing.T) { 49 | skipIfNoServer(t) 50 | cleanupDB(t) 51 | 52 | // Create first entry 53 | args := []string{serverAddr(), "-c", "echo setup1"} 54 | if cfg.KeyPath != "" { 55 | args = append(args, "-i", cfg.KeyPath) 56 | } 57 | _, _, err := runSSXWithInput(t, cfg.Password+"\n", args...) 58 | if err != nil { 59 | t.Fatalf("Failed to setup first entry: %v", err) 60 | } 61 | 62 | // Get entry IDs 63 | stdout, _, _ := runSSX(t, "list") 64 | re := regexp.MustCompile(`\s+(\d+)\s+\|`) 65 | matches := re.FindAllStringSubmatch(stdout, -1) 66 | if len(matches) < 1 { 67 | t.Fatalf("Could not find entry IDs") 68 | } 69 | 70 | // Delete all entries 71 | args = []string{"delete"} 72 | for _, match := range matches { 73 | args = append(args, "--id", match[1]) 74 | } 75 | 76 | _, stderr, err := runSSX(t, args...) 77 | if err != nil { 78 | t.Fatalf("Failed to delete entries: %v, stderr: %s", err, stderr) 79 | } 80 | 81 | // Verify all entries were deleted 82 | _, _, err = runSSX(t, "list") 83 | if err == nil { 84 | // If no error, check that our host is not in the list 85 | stdout, _, _ := runSSX(t, "list") 86 | if strings.Contains(stdout, cfg.Host) { 87 | t.Error("Entries should have been deleted") 88 | } 89 | } 90 | } 91 | 92 | // TestDeleteNoID tests delete command without ID 93 | func TestDeleteNoID(t *testing.T) { 94 | setupDB(t) 95 | 96 | stdout, _, err := runSSXWithDB(t, "delete") 97 | if err != nil { 98 | // It's okay if it errors, just check the message 99 | t.Logf("Delete without ID returned error (expected): %v", err) 100 | } 101 | 102 | if !strings.Contains(stdout, "no id specified") && !strings.Contains(stdout, "no id") { 103 | t.Logf("Expected 'no id specified' message, got: %s", stdout) 104 | } 105 | } 106 | 107 | // TestDeleteAliases tests delete command aliases 108 | func TestDeleteAliases(t *testing.T) { 109 | setupDB(t) 110 | 111 | // Test 'd' alias 112 | stdout1, _, _ := runSSXWithDB(t, "d") 113 | // Test 'del' alias 114 | stdout2, _, _ := runSSXWithDB(t, "del") 115 | 116 | // Both should show same "no id specified" message or similar behavior 117 | _ = stdout1 118 | _ = stdout2 119 | // Just verify aliases work without crashing 120 | } 121 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "main" ] 17 | pull_request: 18 | branches: [ "main" ] 19 | schedule: 20 | - cron: '37 3 * * 5' 21 | 22 | jobs: 23 | analyze: 24 | name: Analyze 25 | # Runner size impacts CodeQL analysis time. To learn more, please see: 26 | # - https://gh.io/recommended-hardware-resources-for-running-codeql 27 | # - https://gh.io/supported-runners-and-hardware-resources 28 | # - https://gh.io/using-larger-runners 29 | # Consider using larger runners for possible analysis time improvements. 30 | runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} 31 | timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} 32 | permissions: 33 | actions: read 34 | contents: read 35 | security-events: write 36 | 37 | strategy: 38 | fail-fast: false 39 | matrix: 40 | language: [ 'go' ] 41 | # CodeQL supports [ 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' ] 42 | # Use only 'java-kotlin' to analyze code written in Java, Kotlin or both 43 | # Use only 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both 44 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 45 | 46 | steps: 47 | - name: Checkout repository 48 | uses: actions/checkout@v3 49 | 50 | # Initializes the CodeQL tools for scanning. 51 | - name: Initialize CodeQL 52 | uses: github/codeql-action/init@v2 53 | with: 54 | languages: ${{ matrix.language }} 55 | # If you wish to specify custom queries, you can do so here or in a config file. 56 | # By default, queries listed here will override any specified in a config file. 57 | # Prefix the list here with "+" to use these queries and those in the config file. 58 | 59 | # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 60 | # queries: security-extended,security-and-quality 61 | 62 | 63 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). 64 | # If this step fails, then you should remove it and run the build manually (see below) 65 | - name: Autobuild 66 | uses: github/codeql-action/autobuild@v2 67 | 68 | # ℹ️ Command-line programs to run using the OS shell. 69 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 70 | 71 | # If the Autobuild fails above, remove it and uncomment the following three lines. 72 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 73 | 74 | # - run: | 75 | # echo "Run, Build Application using script" 76 | # ./location_of_script_within_repo/buildscript.sh 77 | 78 | - name: Perform CodeQL Analysis 79 | uses: github/codeql-action/analyze@v2 80 | with: 81 | category: "/language:${{matrix.language}}" 82 | -------------------------------------------------------------------------------- /docs/zh-cn/usage.md: -------------------------------------------------------------------------------- 1 | # 使用方法 2 | 3 | ## 新增条目 4 | 5 | 正确登录一次即代表新增条目。 6 | 7 | ```bash 8 | ssx [-J PROXY_USER@PROXY_HOST:PROXY_PORT] [USER@]HOST[:PORT] [-i IDENTITY_FILE] [-p PORT] 9 | ``` 10 | 11 | |参数| 说明 | 是否必填| 默认值 | 12 | |:---|:---|:---|:---| 13 | |`USER`| 要登录的操作系统用户 | 否 | `root` | 14 | |`HOST`| 目标服务器IP,目前仅支持 IPv4 | 是 || 15 | |`PORT`| 服务器 sshd 服务的端口| 否 | 22 | 16 | |`-i IDENTITY_FILE`| 私钥文件 | 否 | `~/.ssh/id_rsa` | 17 | |`-J`| 支持通过跳板机登录,跳板机的信息通过 -J 提供,跳板机目前仅支持密码登录 | 否 | | 18 | 19 | 当首次登录,不存在可用私钥时,会通过交互方式来让用户输入密码,一旦登录成功,这个密码就会被 ssx 保存到本地的数据文件中 (默认为 **~/.ssx/db**, 可通过环境变量 `SSX_DB_PATH` 进行自定义)。 20 | 21 | 下次登录时,直接执行 `ssx ` 即可自动登录。 22 | 23 | 同时,为了简洁也可通过 `` 的片段直接搜索匹配登录,比如存储了一个条目 `192.168.1.100`,那么可以直接通过 `ssx 100` 即可登录。 24 | 25 | ## 查看条目列表 26 | 27 | ```bash 28 | ssx list 29 | # output example 30 | # Entries (stored in ssx) 31 | # ID | Address | Tags 32 | #-----+----------------------+-------------------------- 33 | # 1 | root@172.23.1.84:22 | centos 34 | ``` 35 | 36 | ssx 默认不加载 `~/ssh/config` 文件,除非设置了环境变量 `SSX_IMPORT_SSH_CONFIG`。 37 | 38 | ssx 不会将用户的 ssh 配置文件中的条目存储到自己的数据库中,因此您不会在 list 命令的输出中看到 "ID" 字段。 39 | 40 | ```bash 41 | export SSX_IMPORT_SSH_CONFIG=true 42 | ssx list 43 | # output example 44 | # Entries (stored in ssx) 45 | # ID | Address | Tags 46 | #-----+----------------------+-------------------------- 47 | # 1 | root@172.23.1.84:22 | centos 48 | # 49 | # Entries (found in ssh config) 50 | # Address | Tags 51 | # -----------------------------------+---------------------------- 52 | # git@ssh.github.com:22 | github.com 53 | ``` 54 | 55 | ## 为条目打标签 56 | 57 | ssx 会给每个存储的服务器分配一个唯一的 `ID`,我们在打标签时就需要通过 `ID` 来指定服务器条目。 58 | 59 | 打标签需要通过 ssx 的 `tag` 子命令来完成,下面是 tag 命令的模式: 60 | 61 | ```bash 62 | ssx tag --id [-t TAG1 [-t TAG2 ...]] [-d TAG3 [-d TAG4 ...]] 63 | ``` 64 | 65 | - `--id`: 指定 list 命令输出的要操作的服务器对应的 ID 字段 66 | - `-t`: 指定要添加的标签名,可以多次指定就可以同时添加多个标签 67 | - `-d`: 打标签的同时也支持删除已有标签,通过 -d 指定要删除的标签名,同样也可以多次指定 68 | 69 | 当我们完成对服务器的打标签后,比如假设增加了一个 `centos` 的标签,那么我此时就可以通过标签来进行登录了: 70 | 71 | ```bash 72 | ssx centos 73 | ``` 74 | 75 | ## 登录服务器 76 | 77 | 如果没有指定任何参数标志,ssx 将把第二个参数作为搜索关键词,从主机和标签中搜索,如果没有匹配任何条目,ssx将把它作为一个新条目,并尝试登录。 78 | 79 | ```bash 80 | # 通过交互登录,只需运行SSX 81 | ssx 82 | 83 | # 按条目id登录 84 | ssx --id 85 | 86 | # 通过地址登录,支持部分单词 87 | ssx
88 | 89 | # 通过标签登录 90 | ssx 91 | ``` 92 | 93 | ## 执行单次命令 94 | 95 | SSX 支持通过 `-c` 参数指定一个 shell 命令,登录后执行该命令后退出,便于一些嵌入式场景非交互的方式执行远程命令 96 | 97 | ```bash 98 | ssx centos -c 'pwd' 99 | ``` 100 | 101 | ## 文件复制 102 | 103 | > v0.6.0+ 104 | 105 | SSX 支持通过 `cp` 子命令在本地和远程主机之间复制文件,使用 SCP 协议。 106 | 107 | ### 基本用法 108 | 109 | ```bash 110 | ssx cp 111 | ``` 112 | 113 | ### 路径格式 114 | 115 | - **本地路径**: `/path/to/file` 或 `./relative/path` 116 | - **远程路径**: `[user@]host[:port]:/path/to/file` 117 | - **标签引用**: `tag:/path/to/file` (使用已存储的条目标签或关键字) 118 | 119 | ### 上传文件到远程服务器 120 | 121 | ```bash 122 | # 使用完整地址 123 | ssx cp ./local.txt root@192.168.1.100:/tmp/remote.txt 124 | 125 | # 使用标签 126 | ssx cp ./local.txt myserver:/tmp/remote.txt 127 | 128 | # 指定端口 129 | ssx cp ./local.txt root@192.168.1.100:2222:/tmp/remote.txt 130 | 131 | # 使用私钥认证 132 | ssx cp -i ~/.ssh/id_rsa ./local.txt root@192.168.1.100:/tmp/remote.txt 133 | ``` 134 | 135 | ### 从远程服务器下载文件 136 | 137 | ```bash 138 | # 使用完整地址 139 | ssx cp root@192.168.1.100:/tmp/remote.txt ./local.txt 140 | 141 | # 使用标签 142 | ssx cp myserver:/tmp/remote.txt ./local.txt 143 | ``` 144 | 145 | ### 远程到远程复制 146 | 147 | SSX 支持在两台远程服务器之间直接复制文件,文件通过 SSX 流式转发,本地不存储文件。 148 | 149 | ```bash 150 | # 使用完整地址 151 | ssx cp root@192.168.1.100:/tmp/file.txt root@192.168.1.200:/tmp/file.txt 152 | 153 | # 使用标签 154 | ssx cp server1:/data/file.txt server2:/backup/file.txt 155 | ``` 156 | 157 | ### cp 命令参数 158 | 159 | | 参数 | 说明 | 默认值 | 160 | |:---|:---|:---| 161 | | `-i, --identity-file` | 私钥文件路径 | | 162 | | `-J, --jump-server` | 跳板机地址 | | 163 | | `-P, --port` | 远程主机端口 | 22 | 164 | 165 | ## 升级SSX 166 | 167 | > v0.3.0+ 168 | 169 | ```bash 170 | ssx upgrade [] 171 | ``` 172 | 173 | 默认不指定版本时会自动更新到 github 上的最新版。 174 | -------------------------------------------------------------------------------- /cmd/ssx/cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | 8 | "github.com/spf13/cobra" 9 | 10 | "github.com/vimiix/ssx/internal/lg" 11 | "github.com/vimiix/ssx/ssx" 12 | "github.com/vimiix/ssx/ssx/version" 13 | ) 14 | 15 | var ( 16 | logVerbose bool 17 | printVersion bool 18 | ssxInst *ssx.SSX 19 | ) 20 | 21 | func NewRoot() *cobra.Command { 22 | opt := &ssx.CmdOption{} 23 | root := &cobra.Command{ 24 | Use: "ssx", 25 | Short: "🦅 ssx is a retentive ssh client", 26 | Example: `# First login 27 | ssx [USER@]HOST[:PORT] 28 | 29 | # Login with proxy server 30 | ssx [-J PROXY_USER@PROXY_HOST:PROXY_PORT] [USER@]HOST[:PORT]] 31 | 32 | # After login once, you can login directly with host or tag or specify ID with --id 33 | ssx [USER@]HOST[:PORT] 34 | ssx TAG_NAME 35 | ssx --id ID 36 | 37 | # Fuzzy search is also supported 38 | # For example, you want to login to 192.168.1.100 and 39 | # suppose you can uniquely locate one entry by '100', 40 | # you just need to enter: 41 | ssx 100 42 | 43 | # If a command is specified, it will be executed on the remote host instead of a login shell. 44 | ssx 100 -c pwd 45 | # if the '-c' is omitted, the secend and subsequent arguments will be treated as COMMAND 46 | ssx 100 pwd`, 47 | SilenceUsage: true, 48 | SilenceErrors: true, 49 | DisableAutoGenTag: true, 50 | DisableSuggestions: true, 51 | Args: cobra.ArbitraryArgs, // accept arbitrary args for supporting quick login 52 | PersistentPreRunE: func(cmd *cobra.Command, args []string) error { 53 | lg.SetVerbose(logVerbose) 54 | if !printVersion && cmd.Use != "upgrade" { 55 | s, err := ssx.NewSSX(opt) 56 | if err != nil { 57 | return err 58 | } 59 | ssxInst = s 60 | } 61 | return nil 62 | }, 63 | RunE: func(cmd *cobra.Command, args []string) error { 64 | if printVersion { 65 | fmt.Fprintln(os.Stdout, version.Detail()) 66 | return nil 67 | } 68 | if len(args) > 0 { 69 | // just use first word as search key 70 | opt.Keyword = args[0] 71 | } 72 | if len(args) > 1 && len(opt.Command) == 0 { 73 | opt.Command = strings.Join(args[1:], " ") 74 | } 75 | return ssxInst.Main(cmd.Context()) 76 | }, 77 | } 78 | root.Flags().StringVarP(&opt.DBFile, "file", "f", "", "filepath to store auth data") 79 | root.Flags().Uint64VarP(&opt.EntryID, "id", "", 0, "entry id") 80 | root.Flags().StringVarP(&opt.Addr, "server", "s", "", "target server address\nsupport format: [user@]host[:port]") 81 | root.Flags().StringVarP(&opt.Tag, "tag", "t", "", "search entry by tag") 82 | root.Flags().StringVarP(&opt.IdentityFile, "identity-file", "i", "", "identity_file path") 83 | root.Flags().StringVarP(&opt.JumpServers, "jump-server", "J", "", "jump servers, multiple jump hops may be specified separated by comma characters\nformat: [user1@]host1[:port1][,[user2@]host2[:port2]...]") 84 | root.Flags().StringVarP(&opt.Command, "cmd", "c", "", "excute the command and exit") 85 | root.Flags().DurationVar(&opt.Timeout, "timeout", 0, "timeout for connecting and executing command") 86 | root.Flags().IntVarP(&opt.Port, "port", "p", 22, "port to connect to on the remote host") 87 | root.Flags().BoolVar(&opt.Unsafe, "unsafe", false, "store host secret information with unsafe format") 88 | 89 | root.PersistentFlags().BoolVarP(&printVersion, "version", "v", false, "print ssx version") 90 | root.PersistentFlags().BoolVar(&logVerbose, "verbose", false, "output detail logs") 91 | 92 | root.AddCommand(newListCmd()) 93 | root.AddCommand(newDeleteCmd()) 94 | root.AddCommand(newTagCmd()) 95 | root.AddCommand(newInfoCmd()) 96 | root.AddCommand(newUpgradeCmd()) 97 | root.AddCommand(newCpCmd()) 98 | 99 | // no longer needed, hidden them for backwards compatibility 100 | _ = root.Flags().MarkDeprecated("server", "it will remove in the future") 101 | _ = root.Flags().MarkDeprecated("tag", "it will remove in the future") 102 | _ = root.Flags().MarkDeprecated("unsafe", "no longer work after v0.4") 103 | 104 | root.CompletionOptions.HiddenDefaultCmd = true 105 | root.SetHelpCommand(&cobra.Command{Hidden: true}) 106 | return root 107 | } 108 | -------------------------------------------------------------------------------- /static/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 13 | 14 | 15 | SSX -------------------------------------------------------------------------------- /docs/_media/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 13 | 14 | 15 | SSX -------------------------------------------------------------------------------- /e2e/README.md: -------------------------------------------------------------------------------- 1 | # SSX E2E 测试 2 | 3 | 本目录包含 ssx 命令行工具的端到端(E2E)测试用例集。 4 | 5 | ## 环境变量配置 6 | 7 | 运行测试前需要设置以下环境变量: 8 | 9 | | 变量名 | 必需 | 说明 | 默认值 | 10 | |--------|------|------|--------| 11 | | `SSX_E2E_HOST` | 是* | SSH 服务器主机名或 IP | - | 12 | | `SSX_E2E_PORT` | 否 | SSH 服务器端口 | 22 | 13 | | `SSX_E2E_USER` | 是* | SSH 用户名 | - | 14 | | `SSX_E2E_PASSWORD` | 是** | SSH 密码 | - | 15 | | `SSX_E2E_KEY` | 是** | SSH 私钥文件路径 | - | 16 | | `SSX_E2E_HOST2` | 否 | 第二台 SSH 服务器(用于 remote-to-remote 测试) | - | 17 | | `SSX_E2E_PORT2` | 否 | 第二台 SSH 服务器端口 | 22 | 18 | | `SSX_E2E_USER2` | 否 | 第二台 SSH 服务器用户名(默认使用 SSX_E2E_USER) | - | 19 | 20 | > \* 需要 SSH 服务器的测试用例必需 21 | > \*\* `SSX_E2E_PASSWORD` 和 `SSX_E2E_KEY` 至少需要设置一个 22 | 23 | ## 运行测试 24 | 25 | ### 运行不需要服务器的测试 26 | 27 | ```bash 28 | go test -v ./e2e/... -run "TestVersion|TestHelp|TestCpLocalToLocal|TestCpMissingArgs" 29 | ``` 30 | 31 | ### 运行完整测试 32 | 33 | ```bash 34 | SSX_E2E_HOST=192.168.1.100 \ 35 | SSX_E2E_USER=root \ 36 | SSX_E2E_PASSWORD=your_password \ 37 | go test -v ./e2e/... 38 | ``` 39 | 40 | ### 使用 SSH 密钥认证 41 | 42 | ```bash 43 | SSX_E2E_HOST=192.168.1.100 \ 44 | SSX_E2E_USER=root \ 45 | SSX_E2E_KEY=~/.ssh/id_rsa \ 46 | go test -v ./e2e/... 47 | ``` 48 | 49 | ### 运行 remote-to-remote 测试 50 | 51 | ```bash 52 | SSX_E2E_HOST=192.168.1.100 \ 53 | SSX_E2E_USER=root \ 54 | SSX_E2E_PASSWORD=your_password \ 55 | SSX_E2E_HOST2=192.168.1.101 \ 56 | go test -v ./e2e/... -run "TestCpRemoteToRemote" 57 | ``` 58 | 59 | ## 测试用例说明 60 | 61 | ### version_test.go - 版本和帮助信息 62 | 63 | | 测试用例 | 说明 | 需要服务器 | 64 | |----------|------|:----------:| 65 | | `TestVersion` | 测试 `--version` 输出 | 否 | 66 | | `TestHelp` | 测试 `--help` 输出 | 否 | 67 | | `TestCpHelp` | 测试 `cp --help` 输出 | 否 | 68 | 69 | ### list_test.go - 列表功能 70 | 71 | | 测试用例 | 说明 | 需要服务器 | 72 | |----------|------|:----------:| 73 | | `TestListEmpty` | 测试空数据库时的列表输出 | 否 | 74 | | `TestListAliases` | 测试 `l`/`ls` 别名 | 否 | 75 | | `TestListAfterConnection` | 测试连接后的列表显示 | 是 | 76 | 77 | ### connect_test.go - 连接功能 78 | 79 | | 测试用例 | 说明 | 需要服务器 | 80 | |----------|------|:----------:| 81 | | `TestConnectAndExecute` | 测试连接并执行命令 | 是 | 82 | | `TestConnectWithPort` | 测试指定端口连接 | 是 | 83 | | `TestConnectWithIdentityFile` | 测试使用 SSH 密钥连接 | 是 | 84 | | `TestConnectByKeyword` | 测试通过关键字匹配连接 | 是 | 85 | | `TestConnectWithTimeout` | 测试命令超时功能 | 是 | 86 | | `TestConnectTimeoutExceeded` | 测试超时中断 | 是 | 87 | 88 | ### tag_test.go - 标签功能 89 | 90 | | 测试用例 | 说明 | 需要服务器 | 91 | |----------|------|:----------:| 92 | | `TestTagAddAndDelete` | 测试添加和删除标签 | 是 | 93 | | `TestTagRequiresID` | 测试缺少 `--id` 参数时的错误 | 否 | 94 | | `TestTagNoTagSpecified` | 测试未指定标签时的错误 | 否 | 95 | | `TestConnectByTag` | 测试通过标签连接服务器 | 是 | 96 | 97 | ### delete_test.go - 删除功能 98 | 99 | | 测试用例 | 说明 | 需要服务器 | 100 | |----------|------|:----------:| 101 | | `TestDeleteEntry` | 测试删除单个条目 | 是 | 102 | | `TestDeleteMultipleEntries` | 测试批量删除条目 | 是 | 103 | | `TestDeleteNoID` | 测试未指定 ID 时的错误 | 否 | 104 | | `TestDeleteAliases` | 测试 `d`/`del` 别名 | 否 | 105 | 106 | ### info_test.go - 信息查询功能 107 | 108 | | 测试用例 | 说明 | 需要服务器 | 109 | |----------|------|:----------:| 110 | | `TestInfoByID` | 测试通过 ID 查询条目信息 | 是 | 111 | | `TestInfoByKeyword` | 测试通过关键字查询条目信息 | 是 | 112 | | `TestInfoByTag` | 测试通过标签查询条目信息 | 是 | 113 | | `TestInfoNotFound` | 测试查询不存在条目时的错误 | 否 | 114 | | `TestInfoPasswordMasked` | 测试密码在输出中被掩码 | 是 | 115 | 116 | ### cp_test.go - 文件复制功能 117 | 118 | | 测试用例 | 说明 | 需要服务器 | 119 | |----------|------|:----------:| 120 | | `TestCpUpload` | 测试上传本地文件到远程 | 是 | 121 | | `TestCpDownload` | 测试下载远程文件到本地 | 是 | 122 | | `TestCpWithTag` | 测试使用标签引用远程主机 | 是 | 123 | | `TestCpRemoteToRemote` | 测试远程到远程文件复制 | 是(双服务器) | 124 | | `TestCpLocalToLocal` | 测试本地到本地复制被拒绝 | 否 | 125 | | `TestCpMissingArgs` | 测试缺少参数时的错误 | 否 | 126 | | `TestCpNonExistentLocalFile` | 测试上传不存在的文件时的错误 | 是 | 127 | 128 | ## 测试文件结构 129 | 130 | ``` 131 | e2e/ 132 | ├── README.md # 本文件 133 | ├── e2e_test.go # 测试框架和辅助函数 134 | ├── version_test.go # 版本和帮助测试 135 | ├── list_test.go # 列表功能测试 136 | ├── connect_test.go # 连接功能测试 137 | ├── tag_test.go # 标签功能测试 138 | ├── delete_test.go # 删除功能测试 139 | ├── info_test.go # 信息查询测试 140 | └── cp_test.go # 文件复制测试 141 | ``` 142 | 143 | ## 注意事项 144 | 145 | 1. **测试会自动编译 ssx 二进制文件**:测试开始时会在临时目录编译最新的 ssx 二进制文件 146 | 2. **测试使用独立数据库**:每个测试使用独立的临时数据库,不会影响本地 ssx 配置 147 | 3. **测试会在远程服务器创建临时文件**:文件复制测试会在 `/tmp` 目录创建临时文件,测试结束后会自动清理 148 | 4. **跳过机制**:缺少必要环境变量的测试会被自动跳过,不会报错 149 | -------------------------------------------------------------------------------- /internal/encrypt/encrypt.go: -------------------------------------------------------------------------------- 1 | package encrypt 2 | 3 | import ( 4 | "bytes" 5 | "crypto/aes" 6 | "crypto/cipher" 7 | "crypto/md5" 8 | "crypto/rand" 9 | "encoding/base64" 10 | "encoding/hex" 11 | "fmt" 12 | "io" 13 | "strings" 14 | "time" 15 | 16 | "github.com/pkg/errors" 17 | 18 | "github.com/vimiix/ssx/internal/lg" 19 | "github.com/vimiix/ssx/internal/utils" 20 | ) 21 | 22 | // Encrypt Generates the ciphertext for the given string. 23 | // If the encryption fails, the original characters will be returned. 24 | // If the passed string is empty, return empty directly. 25 | func Encrypt(text string) string { 26 | if text == "" { 27 | return "" 28 | } 29 | 30 | curTime := time.Now().Format("01021504") 31 | salt := md5encode(curTime) 32 | key := salt[:8] + curTime 33 | cipherText, err := aesEncrypt(text, key) 34 | if err != nil { 35 | lg.Debug("failed to encrypt text '%s': %s", utils.MaskString(text), err) 36 | return text 37 | } 38 | return base64.StdEncoding.EncodeToString([]byte(salt[:8] + shiftEncode(curTime) + cipherText)) 39 | } 40 | 41 | func Decrypt(rawCipher string) string { 42 | if rawCipher == "" { 43 | return "" 44 | } 45 | 46 | dec, err := base64.StdEncoding.DecodeString(rawCipher) 47 | if err != nil { 48 | lg.Debug("failed to base64 decode cipher text '%s': %s", rawCipher, err) 49 | return rawCipher 50 | } 51 | 52 | key := string(dec[:8]) + shiftDecode(string(dec[8:16])) 53 | text := string(dec[16:]) 54 | res, err := aesDecrypt(text, key) 55 | if err != nil { 56 | lg.Debug("failed to decypt cipher '%s': %s", text, err) 57 | return rawCipher 58 | } 59 | return res 60 | } 61 | 62 | func md5encode(s string) string { 63 | h := md5.New() 64 | h.Write([]byte(s)) 65 | return hex.EncodeToString(h.Sum(nil)) 66 | } 67 | 68 | func shiftEncode(s string) string { 69 | rs := make([]string, 0, len(s)) 70 | for _, c := range s[:] { 71 | // start with '<' 72 | rs = append(rs, fmt.Sprintf("%c", c+12)) 73 | } 74 | return strings.Join(rs, "") 75 | } 76 | 77 | func shiftDecode(s string) string { 78 | rs := make([]string, 0, len(s)) 79 | for _, c := range s[:] { 80 | rs = append(rs, fmt.Sprintf("%c", c-12)) 81 | } 82 | return strings.Join(rs, "") 83 | } 84 | 85 | func addBase64Padding(value string) string { 86 | m := len(value) % 4 87 | if m != 0 { 88 | value += strings.Repeat("=", 4-m) 89 | } 90 | 91 | return value 92 | } 93 | 94 | func removeBase64Padding(value string) string { 95 | return strings.Replace(value, "=", "", -1) 96 | } 97 | 98 | func pad(src []byte) []byte { 99 | padding := aes.BlockSize - len(src)%aes.BlockSize 100 | padtext := bytes.Repeat([]byte{byte(padding)}, padding) 101 | return append(src, padtext...) 102 | } 103 | 104 | func unpad(src []byte) ([]byte, error) { 105 | length := len(src) 106 | unpadding := int(src[length-1]) 107 | 108 | if unpadding > length { 109 | return nil, errors.New("unpad error. This could happen when incorrect encryption key is used") 110 | } 111 | 112 | return src[:(length - unpadding)], nil 113 | } 114 | 115 | func aesEncrypt(text string, key string) (string, error) { 116 | block, err := aes.NewCipher([]byte(key)) 117 | if err != nil { 118 | return "", err 119 | } 120 | 121 | msg := pad([]byte(text)) 122 | ciphertext := make([]byte, aes.BlockSize+len(msg)) 123 | iv := ciphertext[:aes.BlockSize] 124 | if _, err = io.ReadFull(rand.Reader, iv); err != nil { 125 | return "", err 126 | } 127 | 128 | cfb := cipher.NewCFBEncrypter(block, iv) 129 | cfb.XORKeyStream(ciphertext[aes.BlockSize:], msg) 130 | finalMsg := removeBase64Padding(base64.URLEncoding.EncodeToString(ciphertext)) 131 | return finalMsg, nil 132 | } 133 | 134 | func aesDecrypt(text string, key string) (string, error) { 135 | block, err := aes.NewCipher([]byte(key)) 136 | if err != nil { 137 | return "", err 138 | } 139 | 140 | decodedMsg, err := base64.URLEncoding.DecodeString(addBase64Padding(text)) 141 | if err != nil { 142 | return "", err 143 | } 144 | 145 | if (len(decodedMsg) % aes.BlockSize) != 0 { 146 | return "", errors.New("blocksize must be multiple of decoded message length") 147 | } 148 | 149 | iv := decodedMsg[:aes.BlockSize] 150 | msg := decodedMsg[aes.BlockSize:] 151 | 152 | cfb := cipher.NewCFBDecrypter(block, iv) 153 | cfb.XORKeyStream(msg, msg) 154 | 155 | unpadMsg, err := unpad(msg) 156 | if err != nil { 157 | return "", err 158 | } 159 | 160 | return string(unpadMsg), nil 161 | } 162 | -------------------------------------------------------------------------------- /e2e/tag_test.go: -------------------------------------------------------------------------------- 1 | package e2e 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | // TestTagAddAndDelete tests adding and deleting tags 10 | func TestTagAddAndDelete(t *testing.T) { 11 | skipIfNoServer(t) 12 | cleanupDB(t) 13 | 14 | // First, create an entry 15 | args := []string{serverAddr(), "-c", "echo setup"} 16 | if cfg.KeyPath != "" { 17 | args = append(args, "-i", cfg.KeyPath) 18 | } 19 | _, _, err := runSSXWithInput(t, cfg.Password+"\n", args...) 20 | if err != nil { 21 | t.Fatalf("Failed to setup entry: %v", err) 22 | } 23 | 24 | // Get entry ID from list 25 | stdout, _, err := runSSX(t, "list") 26 | if err != nil { 27 | t.Fatalf("Failed to list entries: %v", err) 28 | } 29 | 30 | // Extract ID (assuming format like " 1 | root@host...") 31 | re := regexp.MustCompile(`\s+(\d+)\s+\|`) 32 | matches := re.FindStringSubmatch(stdout) 33 | if len(matches) < 2 { 34 | t.Fatalf("Could not find entry ID in list output: %s", stdout) 35 | } 36 | entryID := matches[1] 37 | 38 | // Add tags 39 | _, stderr, err := runSSX(t, "tag", "--id", entryID, "-t", "test-tag", "-t", "production") 40 | if err != nil { 41 | t.Fatalf("Failed to add tags: %v, stderr: %s", err, stderr) 42 | } 43 | 44 | // Verify tags were added 45 | stdout, _, err = runSSX(t, "list") 46 | if err != nil { 47 | t.Fatalf("Failed to list entries: %v", err) 48 | } 49 | 50 | if !strings.Contains(stdout, "test-tag") { 51 | t.Errorf("Expected list to contain 'test-tag', got: %s", stdout) 52 | } 53 | if !strings.Contains(stdout, "production") { 54 | t.Errorf("Expected list to contain 'production', got: %s", stdout) 55 | } 56 | 57 | // Delete one tag 58 | _, stderr, err = runSSX(t, "tag", "--id", entryID, "-d", "test-tag") 59 | if err != nil { 60 | t.Fatalf("Failed to delete tag: %v, stderr: %s", err, stderr) 61 | } 62 | 63 | // Verify tag was deleted 64 | stdout, _, err = runSSX(t, "list") 65 | if err != nil { 66 | t.Fatalf("Failed to list entries: %v", err) 67 | } 68 | 69 | if strings.Contains(stdout, "test-tag") { 70 | t.Errorf("Expected 'test-tag' to be deleted, but still found in: %s", stdout) 71 | } 72 | if !strings.Contains(stdout, "production") { 73 | t.Errorf("Expected 'production' tag to remain, got: %s", stdout) 74 | } 75 | } 76 | 77 | // TestTagRequiresID tests that tag command requires --id flag 78 | func TestTagRequiresID(t *testing.T) { 79 | setupDB(t) 80 | 81 | _, stderr, err := runSSXWithDB(t, "tag", "-t", "sometag") 82 | if err == nil { 83 | t.Error("Expected error when --id is not provided") 84 | } 85 | 86 | // The error could be about required flag or invalid id 87 | _ = stderr // Error is expected 88 | } 89 | 90 | // TestTagNoTagSpecified tests error when no tag is specified 91 | func TestTagNoTagSpecified(t *testing.T) { 92 | setupDB(t) 93 | 94 | _, stderr, err := runSSXWithDB(t, "tag", "--id", "1") 95 | if err == nil { 96 | t.Error("Expected error when no tag is specified") 97 | } 98 | 99 | if !strings.Contains(stderr, "no tag") { 100 | t.Logf("Expected 'no tag' error, got: %s", stderr) 101 | } 102 | } 103 | 104 | // TestConnectByTag tests connecting using tag 105 | func TestConnectByTag(t *testing.T) { 106 | skipIfNoServer(t) 107 | cleanupDB(t) 108 | 109 | // Create an entry 110 | args := []string{serverAddr(), "-c", "echo setup"} 111 | if cfg.KeyPath != "" { 112 | args = append(args, "-i", cfg.KeyPath) 113 | } 114 | _, _, err := runSSXWithInput(t, cfg.Password+"\n", args...) 115 | if err != nil { 116 | t.Fatalf("Failed to setup entry: %v", err) 117 | } 118 | 119 | // Get entry ID 120 | stdout, _, _ := runSSX(t, "list") 121 | re := regexp.MustCompile(`\s+(\d+)\s+\|`) 122 | matches := re.FindStringSubmatch(stdout) 123 | if len(matches) < 2 { 124 | t.Fatalf("Could not find entry ID") 125 | } 126 | entryID := matches[1] 127 | 128 | // Add a unique tag 129 | tagName := "e2e-test-server" 130 | _, _, err = runSSX(t, "tag", "--id", entryID, "-t", tagName) 131 | if err != nil { 132 | t.Fatalf("Failed to add tag: %v", err) 133 | } 134 | 135 | // Connect using tag 136 | args = []string{tagName, "-c", "echo tag_connect_test"} 137 | stdout, stderr, err := runSSXWithInput(t, cfg.Password+"\n", args...) 138 | if err != nil { 139 | t.Fatalf("Failed to connect by tag: %v\nstdout: %s\nstderr: %s", err, stdout, stderr) 140 | } 141 | 142 | if !strings.Contains(stdout, "tag_connect_test") { 143 | t.Errorf("Expected output to contain 'tag_connect_test', got: %s", stdout) 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /e2e/connect_test.go: -------------------------------------------------------------------------------- 1 | package e2e 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | ) 7 | 8 | // TestConnectAndExecute tests connecting to a server and executing a command 9 | func TestConnectAndExecute(t *testing.T) { 10 | skipIfNoServer(t) 11 | cleanupDB(t) 12 | 13 | args := []string{serverAddr(), "-c", "echo hello_ssx_test"} 14 | if cfg.KeyPath != "" { 15 | args = append(args, "-i", cfg.KeyPath) 16 | } 17 | 18 | stdout, stderr, err := runSSXWithInput(t, cfg.Password+"\n", args...) 19 | if err != nil { 20 | t.Fatalf("Failed to connect and execute command: %v\nstdout: %s\nstderr: %s", err, stdout, stderr) 21 | } 22 | 23 | if !strings.Contains(stdout, "hello_ssx_test") { 24 | t.Errorf("Expected output to contain 'hello_ssx_test', got: %s", stdout) 25 | } 26 | } 27 | 28 | // TestConnectWithPort tests connecting with explicit port 29 | func TestConnectWithPort(t *testing.T) { 30 | skipIfNoServer(t) 31 | cleanupDB(t) 32 | 33 | addr := cfg.User + "@" + cfg.Host + ":" + cfg.Port 34 | args := []string{addr, "-c", "whoami"} 35 | if cfg.KeyPath != "" { 36 | args = append(args, "-i", cfg.KeyPath) 37 | } 38 | 39 | stdout, stderr, err := runSSXWithInput(t, cfg.Password+"\n", args...) 40 | if err != nil { 41 | t.Fatalf("Failed to connect with port: %v\nstdout: %s\nstderr: %s", err, stdout, stderr) 42 | } 43 | 44 | if !strings.Contains(stdout, cfg.User) { 45 | t.Errorf("Expected whoami output to contain %q, got: %s", cfg.User, stdout) 46 | } 47 | } 48 | 49 | // TestConnectWithIdentityFile tests connecting with SSH key 50 | func TestConnectWithIdentityFile(t *testing.T) { 51 | if cfg.KeyPath == "" { 52 | t.Skip("Skipping key-based auth test: SSX_E2E_KEY not set") 53 | } 54 | skipIfNoServer(t) 55 | cleanupDB(t) 56 | 57 | args := []string{serverAddr(), "-i", cfg.KeyPath, "-c", "hostname"} 58 | 59 | stdout, stderr, err := runSSX(t, args...) 60 | if err != nil { 61 | t.Fatalf("Failed to connect with identity file: %v\nstdout: %s\nstderr: %s", err, stdout, stderr) 62 | } 63 | 64 | // Should get some output (hostname) 65 | if strings.TrimSpace(stdout) == "" { 66 | t.Error("Expected hostname output, got empty string") 67 | } 68 | } 69 | 70 | // TestConnectByKeyword tests connecting using partial keyword match 71 | func TestConnectByKeyword(t *testing.T) { 72 | skipIfNoServer(t) 73 | cleanupDB(t) 74 | 75 | // First, create an entry by connecting 76 | args := []string{serverAddr(), "-c", "echo setup"} 77 | if cfg.KeyPath != "" { 78 | args = append(args, "-i", cfg.KeyPath) 79 | } 80 | _, _, err := runSSXWithInput(t, cfg.Password+"\n", args...) 81 | if err != nil { 82 | t.Fatalf("Failed to setup entry: %v", err) 83 | } 84 | 85 | // Now connect using partial host match 86 | // Extract a portion of the host for keyword search 87 | keyword := cfg.Host 88 | if len(keyword) > 4 { 89 | keyword = keyword[len(keyword)-4:] // Use last 4 characters 90 | } 91 | 92 | args = []string{keyword, "-c", "echo keyword_test"} 93 | stdout, stderr, err := runSSXWithInput(t, cfg.Password+"\n", args...) 94 | if err != nil { 95 | t.Fatalf("Failed to connect by keyword: %v\nstdout: %s\nstderr: %s", err, stdout, stderr) 96 | } 97 | 98 | if !strings.Contains(stdout, "keyword_test") { 99 | t.Errorf("Expected output to contain 'keyword_test', got: %s", stdout) 100 | } 101 | } 102 | 103 | // TestConnectWithTimeout tests command execution with timeout 104 | func TestConnectWithTimeout(t *testing.T) { 105 | skipIfNoServer(t) 106 | cleanupDB(t) 107 | 108 | args := []string{serverAddr(), "-c", "sleep 1 && echo done", "--timeout", "5s"} 109 | if cfg.KeyPath != "" { 110 | args = append(args, "-i", cfg.KeyPath) 111 | } 112 | 113 | stdout, stderr, err := runSSXWithInput(t, cfg.Password+"\n", args...) 114 | if err != nil { 115 | t.Fatalf("Failed with timeout: %v\nstdout: %s\nstderr: %s", err, stdout, stderr) 116 | } 117 | 118 | if !strings.Contains(stdout, "done") { 119 | t.Errorf("Expected output to contain 'done', got: %s", stdout) 120 | } 121 | } 122 | 123 | // TestConnectTimeoutExceeded tests that timeout actually works 124 | func TestConnectTimeoutExceeded(t *testing.T) { 125 | skipIfNoServer(t) 126 | cleanupDB(t) 127 | 128 | args := []string{serverAddr(), "-c", "sleep 10", "--timeout", "1s"} 129 | if cfg.KeyPath != "" { 130 | args = append(args, "-i", cfg.KeyPath) 131 | } 132 | 133 | _, _, err := runSSXWithInput(t, cfg.Password+"\n", args...) 134 | if err == nil { 135 | t.Error("Expected timeout error, but command succeeded") 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 4 |

5 | 6 |

7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |

16 | 17 |

English | 中文

18 | 19 | 🦅 ssx is a retentive ssh client. 20 | 21 | It will automatically remember the server which login through it, 22 | so you do not need to enter the password again when you log in again. 23 | 24 | ## Document 25 | 26 | - 👉 [https://ssx.vimiix.com/](https://ssx.vimiix.com/) 27 | - 🤖 [https://deepwiki.com/vimiix/ssx](https://deepwiki.com/vimiix/ssx/1-overview) 28 | 29 | ## Getting Started 30 | 31 | ### Installation 32 | 33 | Download binary from [releases](https://github.com/vimiix/ssx/releases), extract it and add its path to your `$PATH` list. 34 | 35 | If you want to install from source code, you can run command under project root directory: 36 | 37 | ```bash 38 | make ssx 39 | ``` 40 | 41 | then you can get ssx binary in **dist** directory. 42 | 43 | ### Add a new entry 44 | 45 | ```bash 46 | ssx [USER@]HOST[:PORT] [-i IDENTITY_FILE] [-p PORT] 47 | ``` 48 | 49 | If given address matched an exist entry, ssx will login directly. 50 | 51 | ### List exist entries 52 | 53 | ```bash 54 | ssx list 55 | # output example 56 | # Entries (stored in ssx) 57 | # ID | Address | Tags 58 | #-----+----------------------+-------------------------- 59 | # 1 | root@172.23.1.84:22 | centos 60 | ``` 61 | 62 | ssx does not read `~/.ssh/config` by default unless the environment variable `SSX_IMPORT_SSH_CONFIG` is set. 63 | ssx will not store user ssh config entries to itself db, so you won't see their `ID` in the output of the list command 64 | 65 | ```bash 66 | export SSX_IMPORT_SSH_CONFIG=true 67 | ssx list 68 | # output example 69 | # Entries (stored in ssx) 70 | # ID | Address | Tags 71 | #-----+----------------------+-------------------------- 72 | # 1 | root@172.23.1.84:22 | centos 73 | # 74 | # Entries (found in ssh config) 75 | # Address | Tags 76 | # -----------------------------------+---------------------------- 77 | # git@ssh.github.com:22 | github.com 78 | ``` 79 | 80 | ### Tag an entry 81 | 82 | ```bash 83 | ssx tag --id [-t TAG1 [-t TAG2 ...]] [-d TAG3 [-d TAG4 ...]] 84 | ``` 85 | 86 | Once we tag the entry, we can log in through the tag later. 87 | 88 | ### Login 89 | 90 | If not specified any flag, ssx will treat the second argument as a keyword for searching from host and tags, if not matched any entry, ssx will treat it as a new entry, and try to login. 91 | 92 | ```bash 93 | # login by interacting, just run ssx 94 | ssx 95 | 96 | # login by entry id 97 | ssx --id 98 | 99 | # login by address, support partial words 100 | ssx
101 | 102 | # login by tag 103 | ssx 104 | ``` 105 | 106 | ### Execute command 107 | 108 | ```bash 109 | ssx
[-c] [--timeout 30s] 110 | ssx [-c] [--timeout 30s] 111 | 112 | # for example: login 192.168.1.100 and execute command 'pwd': 113 | ssx 1.100 pwd 114 | ``` 115 | 116 | ### Delete an entry 117 | 118 | ```bash 119 | ssx delete --id 120 | ``` 121 | 122 | ## Supported environment variables 123 | 124 | - `SSX_DB_PATH`: DB file to store entries, default is `~/.ssx.db`. 125 | - `SSX_CONNECT_TIMEOUT`: SSH connect timeout, default is `10s`. 126 | - `SSX_IMPORT_SSH_CONFIG`: Whether to import the user ssh config, default is empty. 127 | - `SSX_UNSAFE_MODE`: The password is stored in unsafe mode 128 | - `SSX_SECRET_KEY`: The secret key for encrypting entry's password, default will use machineid 129 | 130 | ## Upgrade SSX 131 | 132 | > Since: v0.3.0 133 | 134 | ```bash 135 | ssx upgrade 136 | ``` 137 | 138 | ## Copyright 139 | 140 | © 2023-2024 Vimiix 141 | 142 | Distributed under the MIT License. See [LICENSE](https://github.com/vimiix/ssx/blob/main/LICENSE) file for details. 143 | -------------------------------------------------------------------------------- /README_zh.md: -------------------------------------------------------------------------------- 1 |

2 | 4 |

5 | 6 |

7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |

16 | 17 |

English | 中文

18 | 19 | 20 | 🦅 SSX 是一个有记忆的 SSH 客户端。 21 | 22 | 它会自动记住通过它登录的服务器,因此,当您再次登录时,无需再次输入密码。 23 | 24 | ## 在线文档 25 | 26 | 👉 [https://ssx.vimiix.com/](https://ssx.vimiix.com/) 27 | 28 | ## 使用方式 29 | 30 | ### 安装 31 | 32 | ssx 是通过 golang 开发的一个独立的二进制文件,安装方式就是从 release 页面下载对应平台的软件包,解压后把 `ssx` 二进制放到系统的任意目录下,这里我习惯放到 `/usr/local/bin` 目录下,如果你选择其他目录下,需要确保存放的目录添加到 `$PATH` 环境变量中,这样后续使用我们就不用再添加路径前缀,直接通过 `ssx` 命令就可以运行了。 33 | 34 | 如果你想从源代码安装,你可以在项目根目录下运行命令: 35 | 36 | ```bash 37 | make ssx 38 | ``` 39 | 40 | 然后你可以在 **dist** 目录下得到 ssx 的二进制文件。 41 | 42 | ### 添加新条目(登录一次即代表新增) 43 | 44 | ```bash 45 | ssx [USER@]HOST[:PORT] [-i IDENTITY_FILE] [-p PORT] 46 | ``` 47 | 48 | > 如果给定的地址与一个存在的条目匹配,ssx 将直接登录。 49 | 50 | 在这个命令中,`USER` 是可以省略的,如果省略则是系统当前用户名;`PORT` 是可以省略的,默认是 `22`,`-i IDENTITY_FILE` 代表的是使用私钥登录,通过 `-i` 来指定私钥的路径,也是可以省略的,默认是 `~/.ssh/id_rsa`,当然了,前提是这个文件存在。所以精简后的登录命令就是:`ssx ` 51 | 52 | 当首次登录,不存在可用私钥时,会通过交互方式来让用户输入密码,一旦登录成功,这个密码就会被 ssx 保存到本地的数据文件中 (默认为 **~/.ssx/db**, 可通过环境变量 `SSX_DB_PATH` 进行自定义),下次登录时,仍然执行 `ssx ` 即可自动登录。 53 | 54 | 注意,登录过的服务器,再次登录时,我嫌输入全部 IP 比较繁琐,所以 ssx 支持输入 IP 中的部分字符,自动搜索匹配进行登录。 55 | 56 | ### 列出存在的条目 57 | 58 | ```bash 59 | ssx list 60 | # output example 61 | # Entries (stored in ssx) 62 | # ID | Address | Tags 63 | #-----+----------------------+-------------------------- 64 | # 1 | root@172.23.1.84:22 | centos 65 | ``` 66 | 67 | ssx 默认不加载 `~/ssh/config` 文件,除非设置了环境变量 `SSX_IMPORT_SSH_CONFIG`。 68 | 69 | ssx 不会将用户的 ssh 配置文件中的条目存储到自己的数据库中,因此您不会在 list 命令的输出中看到 “ID” 字段。 70 | 71 | ```bash 72 | export SSX_IMPORT_SSH_CONFIG=true 73 | ssx list 74 | # output example 75 | # Entries (stored in ssx) 76 | # ID | Address | Tags 77 | #-----+----------------------+-------------------------- 78 | # 1 | root@172.23.1.84:22 | centos 79 | # 80 | # Entries (found in ssh config) 81 | # Address | Tags 82 | # -----------------------------------+---------------------------- 83 | # git@ssh.github.com:22 | github.com 84 | ``` 85 | 86 | ### 为服务器打标签 87 | 88 | ssx 会给每个存储的服务器分配一个唯一的 `ID`,我们在打标签时就需要通过 `ID` 来指定服务器条目。 89 | 90 | 打标签需要通过 ssx 的 `tag` 子命令来完成,下面是 tag 命令的模式: 91 | 92 | ```bash 93 | ssx tag --id [-t TAG1 [-t TAG2 ...]] [-d TAG3 [-d TAG4 ...]] 94 | ``` 95 | 96 | - --id 指定 list 命令输出的要操作的服务器对应的 ID 字段 97 | - -t 指定要添加的标签名,可以多次指定就可以同时添加多个标签 98 | - -d 打标签的同时也支持删除已有标签,通过 -d 指定要删除的标签名,同样也可以多次指定 99 | 100 | 当我们完成对服务器的打标签后,比如假设增加了一个 `centos` 的标签,那么我此时就可以通过标签来进行登录了: 101 | 102 | ```bash 103 | ssx centos 104 | ``` 105 | 106 | ### 登录服务器 107 | 108 | 如果没有指定任何参数标志,ssx 将把第二个参数作为搜索关键词,从主机和标签中搜索,如果没有匹配任何条目,ssx将把它作为一个新条目,并尝试登录。 109 | 110 | ```bash 111 | # 通过交互登录,只需运行SSX 112 | ssx 113 | 114 | # 按条目id登录 115 | ssx --id 116 | 117 | # 通过地址登录,支持部分单词 118 | ssx
119 | 120 | # 通过标签登录 121 | ssx 122 | ``` 123 | 124 | ### 执行命令 125 | 126 | 类似 ssh,ssx 也支持非交互式地执行指定的 shell 命令,可通过 `-c` 参数执行单条命令,如果没有执行 -c, ssx 会将第二个参数及其后面的所有参数均视为 shell 命令 127 | 128 | ```bash 129 | ssx
[-c] [--timeout 30s] 130 | ssx [-c] [--timeout 30s] 131 | 132 | # 例如:登录192.168.1.100,执行命令'pwd': 133 | ssx 1.100 pwd 134 | # 通过 centos 标签执行 135 | ssx centos [-c] pwd 136 | ``` 137 | 138 | ### 删除服务器条目 139 | 140 | ```bash 141 | ssx delete --id 142 | ``` 143 | 144 | ## 支持的环境变量 145 | 146 | - `SSX_DB_PATH`: 用于存储条目的数据库文件,默认为 **~/.ssx.db**; 147 | - `SSX_CONNECT_TIMEOUT`: SSH连接超时,默认为: `10s`; 148 | - `SSX_IMPORT_SSH_CONFIG`: 是否导入用户ssh配置,默认为空。 149 | - `SSX_UNSAFE_MODE`: 密码以不安全模式存储 150 | - `SSX_SECRET_KEY`: 用于加密条目密码的密钥,默认使用所在服务器的设备ID 151 | 152 | 这里解释一下 `SSX_IMPORT_SSH_CONFIG` 的作用,这个环境变量不设置时,ssx 默认是不会读取用户的 `~/.ssh/config` 文件的,ssx 只使用自己存储文件进行检索。如果将这个环境变量设置为非空(任意字符串),ssx 就会在初始化的时候加载用户 ssh 配置文件中存在的服务器条目,但 ssx 仅读取用于检索和登录,并不会将这些条目持久化到 ssx 的存储文件中,所以,如果 `ssx IP` 登录时,这个 `IP` 是 `~/.ssh/config` 文件中已经配置过登录验证方式的服务器,ssx 匹配到就直接登录了。但 ssx list 查看时,该服务器会被显示到 `found in ssh config` 的表格中,这个表格中的条目是不具有 ID 属性的。 153 | 154 | ## Upgrade SSX 155 | 156 | > 新增于: v0.3.0 157 | 158 | ```bash 159 | ssx upgrade 160 | ``` 161 | 162 | ## 版权 163 | 164 | © 2023-2024 Vimiix 165 | 166 | 在 MIT 许可协议下分发。可查看 [LICENSE](https://github.com/vimiix/ssx/blob/main/LICENSE) 文件详情 167 | -------------------------------------------------------------------------------- /docs/en-us/usage.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | 3 | ## Add Entry 4 | 5 | A successful login automatically creates a new entry. 6 | 7 | ```bash 8 | ssx [-J PROXY_USER@PROXY_HOST:PROXY_PORT] [USER@]HOST[:PORT] [-i IDENTITY_FILE] [-p PORT] 9 | ``` 10 | 11 | | Parameter | Description | Required | Default | 12 | |:---|:---|:---|:---| 13 | | `USER` | OS user to login as | No | `root` | 14 | | `HOST` | Target server IP (IPv4 only) | Yes | | 15 | | `PORT` | SSH service port | No | 22 | 16 | | `-i IDENTITY_FILE` | Private key file | No | `~/.ssh/id_rsa` | 17 | | `-J` | Jump server for proxy login (password auth only) | No | | 18 | 19 | On first login without an available private key, you'll be prompted to enter a password interactively. Once logged in successfully, the password will be saved to the local data file (default: **~/.ssx/db**, customizable via `SSX_DB_PATH` environment variable). 20 | 21 | For subsequent logins, simply run `ssx ` to login automatically. 22 | 23 | You can also use partial IP fragments for fuzzy matching. For example, if you have an entry for `192.168.1.100`, you can login with just `ssx 100`. 24 | 25 | ## List Entries 26 | 27 | ```bash 28 | ssx list 29 | # output example 30 | # Entries (stored in ssx) 31 | # ID | Address | Tags 32 | #-----+----------------------+-------------------------- 33 | # 1 | root@172.23.1.84:22 | centos 34 | ``` 35 | 36 | By default, ssx doesn't load `~/.ssh/config` unless the `SSX_IMPORT_SSH_CONFIG` environment variable is set. 37 | 38 | ssx doesn't store entries from your ssh config file in its database, so you won't see an "ID" field for those entries in the list output. 39 | 40 | ```bash 41 | export SSX_IMPORT_SSH_CONFIG=true 42 | ssx list 43 | # output example 44 | # Entries (stored in ssx) 45 | # ID | Address | Tags 46 | #-----+----------------------+-------------------------- 47 | # 1 | root@172.23.1.84:22 | centos 48 | # 49 | # Entries (found in ssh config) 50 | # Address | Tags 51 | # -----------------------------------+---------------------------- 52 | # git@ssh.github.com:22 | github.com 53 | ``` 54 | 55 | ## Tag Entries 56 | 57 | ssx assigns a unique `ID` to each stored server. Use this ID to specify the server entry when tagging. 58 | 59 | Use the `tag` subcommand to manage tags: 60 | 61 | ```bash 62 | ssx tag --id [-t TAG1 [-t TAG2 ...]] [-d TAG3 [-d TAG4 ...]] 63 | ``` 64 | 65 | - `--id`: Specify the server ID from the list command output 66 | - `-t`: Add tags (can be specified multiple times) 67 | - `-d`: Delete existing tags (can be specified multiple times) 68 | 69 | After tagging a server (e.g., with `centos`), you can login using the tag: 70 | 71 | ```bash 72 | ssx centos 73 | ``` 74 | 75 | ## Login to Server 76 | 77 | Without any parameter flags, ssx treats the second argument as a search keyword, searching hosts and tags. If no entry matches, ssx treats it as a new entry and attempts to login. 78 | 79 | ```bash 80 | # Interactive login 81 | ssx 82 | 83 | # Login by entry ID 84 | ssx --id 85 | 86 | # Login by address (supports partial match) 87 | ssx
88 | 89 | # Login by tag 90 | ssx 91 | ``` 92 | 93 | ## Execute Single Command 94 | 95 | SSX supports executing a shell command via the `-c` parameter, then exiting after execution. This is useful for non-interactive remote command execution in embedded scenarios. 96 | 97 | ```bash 98 | ssx centos -c 'pwd' 99 | ``` 100 | 101 | ## File Copy 102 | 103 | > v0.6.0+ 104 | 105 | SSX supports copying files between local and remote hosts using the `cp` subcommand with the SCP protocol. 106 | 107 | ### Basic Usage 108 | 109 | ```bash 110 | ssx cp 111 | ``` 112 | 113 | ### Path Formats 114 | 115 | - **Local path**: `/path/to/file` or `./relative/path` 116 | - **Remote path**: `[user@]host[:port]:/path/to/file` 117 | - **Tag reference**: `tag:/path/to/file` (use stored entry tag or keyword) 118 | 119 | ### Upload Files to Remote Server 120 | 121 | ```bash 122 | # Using full address 123 | ssx cp ./local.txt root@192.168.1.100:/tmp/remote.txt 124 | 125 | # Using tag 126 | ssx cp ./local.txt myserver:/tmp/remote.txt 127 | 128 | # With custom port 129 | ssx cp ./local.txt root@192.168.1.100:2222:/tmp/remote.txt 130 | 131 | # With identity file 132 | ssx cp -i ~/.ssh/id_rsa ./local.txt root@192.168.1.100:/tmp/remote.txt 133 | ``` 134 | 135 | ### Download Files from Remote Server 136 | 137 | ```bash 138 | # Using full address 139 | ssx cp root@192.168.1.100:/tmp/remote.txt ./local.txt 140 | 141 | # Using tag 142 | ssx cp myserver:/tmp/remote.txt ./local.txt 143 | ``` 144 | 145 | ### Remote-to-Remote Copy 146 | 147 | SSX supports copying files directly between two remote servers. Files are streamed through SSX without being stored locally. 148 | 149 | ```bash 150 | # Using full addresses 151 | ssx cp root@192.168.1.100:/tmp/file.txt root@192.168.1.200:/tmp/file.txt 152 | 153 | # Using tags 154 | ssx cp server1:/data/file.txt server2:/backup/file.txt 155 | ``` 156 | 157 | ### cp Command Options 158 | 159 | | Option | Description | Default | 160 | |:---|:---|:---| 161 | | `-i, --identity-file` | Private key file path | | 162 | | `-J, --jump-server` | Jump server address | | 163 | | `-P, --port` | Remote host port | 22 | 164 | 165 | ## Upgrade SSX 166 | 167 | > v0.3.0+ 168 | 169 | ```bash 170 | ssx upgrade [] 171 | ``` 172 | 173 | Without specifying a version, it automatically updates to the latest version on GitHub. 174 | -------------------------------------------------------------------------------- /e2e/info_test.go: -------------------------------------------------------------------------------- 1 | package e2e 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | // TestInfoByID tests showing entry info by ID 10 | func TestInfoByID(t *testing.T) { 11 | skipIfNoServer(t) 12 | cleanupDB(t) 13 | 14 | // Create an entry 15 | args := []string{serverAddr(), "-c", "echo setup"} 16 | if cfg.KeyPath != "" { 17 | args = append(args, "-i", cfg.KeyPath) 18 | } 19 | _, _, err := runSSXWithInput(t, cfg.Password+"\n", args...) 20 | if err != nil { 21 | t.Fatalf("Failed to setup entry: %v", err) 22 | } 23 | 24 | // Get entry ID 25 | stdout, _, _ := runSSX(t, "list") 26 | re := regexp.MustCompile(`\s+(\d+)\s+\|`) 27 | matches := re.FindStringSubmatch(stdout) 28 | if len(matches) < 2 { 29 | t.Fatalf("Could not find entry ID") 30 | } 31 | entryID := matches[1] 32 | 33 | // Get info by ID 34 | stdout, stderr, err := runSSX(t, "info", "--id", entryID) 35 | if err != nil { 36 | t.Fatalf("Failed to get info: %v, stderr: %s", err, stderr) 37 | } 38 | 39 | // Should be JSON format with entry details 40 | if !strings.Contains(stdout, "host") { 41 | t.Errorf("Expected JSON output with 'host' field, got: %s", stdout) 42 | } 43 | if !strings.Contains(stdout, cfg.Host) { 44 | t.Errorf("Expected output to contain host %q, got: %s", cfg.Host, stdout) 45 | } 46 | if !strings.Contains(stdout, cfg.User) { 47 | t.Errorf("Expected output to contain user %q, got: %s", cfg.User, stdout) 48 | } 49 | } 50 | 51 | // TestInfoByKeyword tests showing entry info by keyword 52 | func TestInfoByKeyword(t *testing.T) { 53 | skipIfNoServer(t) 54 | cleanupDB(t) 55 | 56 | // Create an entry 57 | args := []string{serverAddr(), "-c", "echo setup"} 58 | if cfg.KeyPath != "" { 59 | args = append(args, "-i", cfg.KeyPath) 60 | } 61 | _, _, err := runSSXWithInput(t, cfg.Password+"\n", args...) 62 | if err != nil { 63 | t.Fatalf("Failed to setup entry: %v", err) 64 | } 65 | 66 | // Get info by keyword (partial host match) 67 | keyword := cfg.Host 68 | if len(keyword) > 4 { 69 | keyword = keyword[len(keyword)-4:] 70 | } 71 | 72 | stdout, stderr, err := runSSX(t, "info", keyword) 73 | if err != nil { 74 | t.Fatalf("Failed to get info by keyword: %v, stderr: %s", err, stderr) 75 | } 76 | 77 | if !strings.Contains(stdout, cfg.Host) { 78 | t.Errorf("Expected output to contain host %q, got: %s", cfg.Host, stdout) 79 | } 80 | } 81 | 82 | // TestInfoByTag tests showing entry info by tag 83 | func TestInfoByTag(t *testing.T) { 84 | skipIfNoServer(t) 85 | cleanupDB(t) 86 | 87 | // Create an entry 88 | args := []string{serverAddr(), "-c", "echo setup"} 89 | if cfg.KeyPath != "" { 90 | args = append(args, "-i", cfg.KeyPath) 91 | } 92 | _, _, err := runSSXWithInput(t, cfg.Password+"\n", args...) 93 | if err != nil { 94 | t.Fatalf("Failed to setup entry: %v", err) 95 | } 96 | 97 | // Get entry ID and add tag 98 | stdout, _, _ := runSSX(t, "list") 99 | re := regexp.MustCompile(`\s+(\d+)\s+\|`) 100 | matches := re.FindStringSubmatch(stdout) 101 | if len(matches) < 2 { 102 | t.Fatalf("Could not find entry ID") 103 | } 104 | entryID := matches[1] 105 | 106 | tagName := "info-test-tag" 107 | _, _, err = runSSX(t, "tag", "--id", entryID, "-t", tagName) 108 | if err != nil { 109 | t.Fatalf("Failed to add tag: %v", err) 110 | } 111 | 112 | // Get info by tag 113 | stdout, stderr, err := runSSX(t, "info", "--tag", tagName) 114 | if err != nil { 115 | t.Fatalf("Failed to get info by tag: %v, stderr: %s", err, stderr) 116 | } 117 | 118 | if !strings.Contains(stdout, cfg.Host) { 119 | t.Errorf("Expected output to contain host %q, got: %s", cfg.Host, stdout) 120 | } 121 | if !strings.Contains(stdout, tagName) { 122 | t.Errorf("Expected output to contain tag %q, got: %s", tagName, stdout) 123 | } 124 | } 125 | 126 | // TestInfoNotFound tests info command with non-existent entry 127 | func TestInfoNotFound(t *testing.T) { 128 | setupDB(t) 129 | 130 | _, stderr, err := runSSXWithDB(t, "info", "nonexistent-host-12345") 131 | if err == nil { 132 | t.Error("Expected error for non-existent entry") 133 | } 134 | 135 | if !strings.Contains(stderr, "not matched") && !strings.Contains(stderr, "no entry") && !strings.Contains(stderr, "not found") { 136 | t.Logf("Expected 'not matched' or 'no entry' error, got: %s", stderr) 137 | } 138 | } 139 | 140 | // TestInfoPasswordMasked tests that password is masked in info output 141 | func TestInfoPasswordMasked(t *testing.T) { 142 | skipIfNoServer(t) 143 | if cfg.Password == "" { 144 | t.Skip("Skipping password mask test: no password configured") 145 | } 146 | cleanupDB(t) 147 | 148 | // Create an entry with password 149 | args := []string{serverAddr(), "-c", "echo setup"} 150 | _, _, err := runSSXWithInput(t, cfg.Password+"\n", args...) 151 | if err != nil { 152 | t.Fatalf("Failed to setup entry: %v", err) 153 | } 154 | 155 | // Get entry ID 156 | stdout, _, _ := runSSX(t, "list") 157 | re := regexp.MustCompile(`\s+(\d+)\s+\|`) 158 | matches := re.FindStringSubmatch(stdout) 159 | if len(matches) < 2 { 160 | t.Fatalf("Could not find entry ID") 161 | } 162 | entryID := matches[1] 163 | 164 | // Get info 165 | stdout, _, err = runSSX(t, "info", "--id", entryID) 166 | if err != nil { 167 | t.Fatalf("Failed to get info: %v", err) 168 | } 169 | 170 | // Password should be masked (contains ***) 171 | if strings.Contains(stdout, cfg.Password) { 172 | t.Error("Password should be masked in info output") 173 | } 174 | if !strings.Contains(stdout, "***") { 175 | t.Log("Expected masked password with '***' in output") 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /ssx/bbolt/bbolt.go: -------------------------------------------------------------------------------- 1 | package bbolt 2 | 3 | import ( 4 | "encoding/binary" 5 | "encoding/json" 6 | "time" 7 | 8 | "go.etcd.io/bbolt" 9 | 10 | "github.com/vimiix/ssx/internal/encrypt" 11 | "github.com/vimiix/ssx/internal/errmsg" 12 | "github.com/vimiix/ssx/internal/lg" 13 | "github.com/vimiix/ssx/ssx/entry" 14 | ) 15 | 16 | // itob returns an 8-byte big endian representation of v. 17 | func itob(v uint64) []byte { 18 | b := make([]byte, 8) 19 | binary.BigEndian.PutUint64(b, v) 20 | return b 21 | } 22 | 23 | type Repo struct { 24 | db *bbolt.DB 25 | file string 26 | metaBucket []byte 27 | entryBucket []byte 28 | } 29 | 30 | func (r *Repo) GetMetadata(key []byte) ([]byte, error) { 31 | if err := r.open(); err != nil { 32 | return nil, err 33 | } 34 | defer r.close() 35 | var res []byte 36 | lg.Debug("bbolt repo: get metadata: %s", string(key)) 37 | _ = r.db.View(func(tx *bbolt.Tx) error { 38 | v := tx.Bucket(r.metaBucket).Get(key) 39 | res = make([]byte, len(v)) 40 | // 'v' is only valid for the life of the transaction 41 | copy(res, v) 42 | return nil 43 | }) 44 | return res, nil 45 | } 46 | 47 | func (r *Repo) SetMetadata(key []byte, value []byte) error { 48 | if err := r.open(); err != nil { 49 | return err 50 | } 51 | defer r.close() 52 | lg.Debug("bbolt repo: set metadata: %s", string(key)) 53 | return r.db.Update(func(tx *bbolt.Tx) error { 54 | return tx.Bucket(r.metaBucket).Put(key, value) 55 | }) 56 | } 57 | 58 | func (r *Repo) TouchEntry(e *entry.Entry) error { 59 | if err := r.open(); err != nil { 60 | return err 61 | } 62 | defer r.close() 63 | 64 | return r.db.Update(func(tx *bbolt.Tx) error { 65 | b := tx.Bucket(r.entryBucket) 66 | var bs []byte 67 | if e.ID > 0 { 68 | bs = b.Get(itob(e.ID)) 69 | } 70 | if len(bs) == 0 { 71 | // insert 72 | e.ID, _ = b.NextSequence() 73 | lg.Debug("bbolt repo: touch new entry: %d", e.ID) 74 | now := time.Now() 75 | e.VisitCount = 1 76 | e.CreateAt = now 77 | e.UpdateAt = now 78 | } else { 79 | var rawEntry = &entry.Entry{} 80 | if err := json.Unmarshal(bs, rawEntry); err != nil { 81 | return err 82 | } 83 | e.ID = rawEntry.ID 84 | lg.Debug("bbolt repo: update entry: %d", e.ID) 85 | e.VisitCount = rawEntry.VisitCount + 1 86 | e.CreateAt = rawEntry.CreateAt 87 | e.UpdateAt = time.Now() 88 | } 89 | // update 90 | buf, encodeErr := encodeEntry(e) 91 | if encodeErr != nil { 92 | return encodeErr 93 | } 94 | return b.Put(itob(e.ID), buf) 95 | }) 96 | } 97 | 98 | func (r *Repo) GetEntry(id uint64) (e *entry.Entry, err error) { 99 | if err = r.open(); err != nil { 100 | return 101 | } 102 | defer r.close() 103 | 104 | lg.Debug("bbolt repo: get entry by id: %d", id) 105 | err = r.db.View(func(tx *bbolt.Tx) error { 106 | bs := tx.Bucket(r.entryBucket).Get(itob(id)) 107 | if len(bs) == 0 { 108 | return errmsg.ErrEntryNotExist 109 | } 110 | var decodeErr error 111 | e, decodeErr = decodeEntry(bs) 112 | if decodeErr != nil { 113 | return decodeErr 114 | } 115 | return nil 116 | }) 117 | return 118 | } 119 | 120 | // GetAllEntries returns all entries map, key format is "ip/user" 121 | func (r *Repo) GetAllEntries() (map[uint64]*entry.Entry, error) { 122 | if err := r.open(); err != nil { 123 | return nil, err 124 | } 125 | defer r.close() 126 | 127 | var ( 128 | err error 129 | m = map[uint64]*entry.Entry{} 130 | ) 131 | 132 | lg.Debug("bbolt repo: get all enrties") 133 | err = r.db.View(func(tx *bbolt.Tx) error { 134 | b := tx.Bucket(r.entryBucket) 135 | c := b.Cursor() 136 | for k, v := c.First(); k != nil; k, v = c.Next() { 137 | e, decodeErr := decodeEntry(v) 138 | if decodeErr != nil { 139 | return decodeErr 140 | } 141 | m[e.ID] = e 142 | } 143 | return nil 144 | }) 145 | return m, err 146 | } 147 | 148 | func (r *Repo) DeleteEntry(id uint64) error { 149 | if err := r.open(); err != nil { 150 | return err 151 | } 152 | defer r.close() 153 | 154 | lg.Debug("bbolt repo: delete entry: %d", id) 155 | return r.db.Update(func(tx *bbolt.Tx) error { 156 | b := tx.Bucket(r.entryBucket) 157 | return b.Delete(itob(id)) 158 | }) 159 | } 160 | 161 | func (r *Repo) Init() error { 162 | if err := r.open(); err != nil { 163 | return err 164 | } 165 | defer r.close() 166 | return r.db.Update(func(tx *bbolt.Tx) error { 167 | for _, bucketName := range r.buckets() { 168 | _, createErr := tx.CreateBucketIfNotExists(bucketName) 169 | if createErr != nil { 170 | return createErr 171 | } 172 | } 173 | return nil 174 | }) 175 | } 176 | 177 | func (r *Repo) close() error { 178 | if r.db == nil { 179 | return nil 180 | } 181 | err := r.db.Close() 182 | if err == nil { 183 | r.db = nil 184 | } 185 | return err 186 | } 187 | 188 | func (r *Repo) open() error { 189 | db, err := bbolt.Open(r.file, 0600, nil) 190 | if err != nil { 191 | return err 192 | } 193 | r.db = db 194 | return nil 195 | } 196 | 197 | func (r *Repo) buckets() [][]byte { 198 | return [][]byte{r.metaBucket, r.entryBucket} 199 | } 200 | 201 | func NewRepo(file string) *Repo { 202 | lg.Debug("new repo with %q", file) 203 | return &Repo{ 204 | file: file, 205 | metaBucket: []byte("metadata"), 206 | entryBucket: []byte("entries"), 207 | } 208 | } 209 | 210 | func encodeEntry(e *entry.Entry) ([]byte, error) { 211 | e.Password = encrypt.Encrypt(e.Password) 212 | e.Passphrase = encrypt.Encrypt(e.Passphrase) 213 | return json.Marshal(e) 214 | } 215 | 216 | func decodeEntry(bs []byte) (*entry.Entry, error) { 217 | var e = &entry.Entry{} 218 | if err := json.Unmarshal(bs, e); err != nil { 219 | return nil, err 220 | } 221 | e.Password = encrypt.Decrypt(e.Password) 222 | e.Passphrase = encrypt.Decrypt(e.Passphrase) 223 | return e, nil 224 | } 225 | -------------------------------------------------------------------------------- /cmd/ssx/cmd/upgrade.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "os" 9 | "path/filepath" 10 | "runtime" 11 | "strings" 12 | 13 | "github.com/vimiix/ssx/ssx/version" 14 | 15 | "github.com/pkg/errors" 16 | "github.com/spf13/cobra" 17 | "github.com/tidwall/gjson" 18 | "github.com/vimiix/ssx/internal/lg" 19 | "github.com/vimiix/ssx/internal/utils" 20 | ) 21 | 22 | const ( 23 | GITHUB_LATEST_API = "https://api.github.com/repos/vimiix/ssx/releases/latest" 24 | GITHUB_PKG_FMT = "https://github.com/vimiix/ssx/releases/download/v{VERSION}/ssx_v{VERSION}_{OS}_{ARCH}.{SUFFIX}" 25 | ) 26 | 27 | type upgradeOpt struct { 28 | PkgPath string 29 | Version string 30 | } 31 | 32 | type LatestPkgInfo struct { 33 | Version string 34 | DownloadURL string 35 | } 36 | 37 | func newUpgradeCmd() *cobra.Command { 38 | opt := &upgradeOpt{} 39 | cmd := &cobra.Command{ 40 | Use: "upgrade", 41 | Short: "upgrade ssx version", 42 | Example: `# Upgrade online 43 | ssx upgrade [] 44 | 45 | # Upgrade with local filepath or specify new package URL path 46 | ssx upgrade -p 47 | 48 | # If both version and package path are specified, 49 | # ssx prefer to use package path.`, 50 | RunE: func(cmd *cobra.Command, args []string) error { 51 | if len(args) > 0 { 52 | opt.Version = args[0] 53 | } 54 | return upgrade(cmd.Context(), opt) 55 | }} 56 | cmd.Flags().StringVarP(&opt.PkgPath, "package", "p", "", "new package file or URL path") 57 | return cmd 58 | } 59 | 60 | func unifyArch() (string, error) { 61 | switch runtime.GOARCH { 62 | case "amd64", "x86_64": 63 | return "x86_64", nil 64 | case "arm64", "aarch64": 65 | return "arm64", nil 66 | default: 67 | return "", errors.Errorf("not supported architecture: %s", runtime.GOARCH) 68 | } 69 | } 70 | 71 | func fileSuffix() string { 72 | if runtime.GOOS == "windows" { 73 | return "zip" 74 | } 75 | return "tar.gz" 76 | } 77 | 78 | func upgrade(ctx context.Context, opt *upgradeOpt) error { 79 | tempDir, err := os.MkdirTemp("", "*") 80 | if err != nil { 81 | return err 82 | } 83 | lg.Debug("make temp dir: %s", tempDir) 84 | defer os.RemoveAll(tempDir) 85 | var localPkg string 86 | suffix := fileSuffix() 87 | localFileName := "ssx." + suffix 88 | if opt.PkgPath != "" { 89 | if strings.Contains(opt.PkgPath, "://") { 90 | localPkg = filepath.Join(tempDir, localFileName) 91 | lg.Info("downloading package from %s", opt.PkgPath) 92 | if err := utils.DownloadFile(ctx, opt.PkgPath, localPkg); err != nil { 93 | return err 94 | } 95 | } else { 96 | if !utils.FileExists(opt.PkgPath) { 97 | return errors.Errorf("file not found: %s", opt.PkgPath) 98 | } 99 | localPkg = opt.PkgPath 100 | } 101 | } else if opt.Version != "" { 102 | semVer := strings.TrimPrefix(opt.Version, "v") 103 | if len(strings.Split(semVer, ".")) != 3 { 104 | return errors.Errorf("bad version: %s", opt.Version) 105 | } 106 | arch, err := unifyArch() 107 | if err != nil { 108 | return err 109 | } 110 | replacer := strings.NewReplacer("{VERSION}", semVer, "{OS}", runtime.GOOS, 111 | "{ARCH}", arch, "{SUFFIX}", suffix) 112 | urlStr := replacer.Replace(GITHUB_PKG_FMT) 113 | localPkg = filepath.Join(tempDir, localFileName) 114 | lg.Info("downloading package from %s", urlStr) 115 | if err := utils.DownloadFile(ctx, urlStr, localPkg); err != nil { 116 | return err 117 | } 118 | } else { 119 | lg.Info("detecting latest package info") 120 | pkgInfo, err := getLatestPkgInfo() 121 | if err != nil { 122 | return err 123 | } 124 | // check version 125 | lg.Info("latest version: %s, current version: %s", pkgInfo.Version, version.Version) 126 | if pkgInfo.Version == version.Version { 127 | lg.Info("You are currently using the latest version.") 128 | return nil 129 | } 130 | if pkgInfo.DownloadURL == "" { 131 | return errors.New("failed to get latest package url") 132 | } 133 | localPkg = filepath.Join(tempDir, localFileName) 134 | lg.Info("downloading latest package from %s", pkgInfo.DownloadURL) 135 | if err := utils.DownloadFile(ctx, pkgInfo.DownloadURL, localPkg); err != nil { 136 | return err 137 | } 138 | } 139 | lg.Info("extracting package") 140 | if err := utils.Extract(localPkg, tempDir); err != nil { 141 | return err 142 | } 143 | newBin := filepath.Join(tempDir, "ssx") 144 | if !utils.FileExists(newBin) { 145 | return errors.New("not found ssx binary after extracting package") 146 | } 147 | execPath, err := os.Executable() 148 | if err != nil { 149 | return err 150 | } 151 | execAbsPath, err := filepath.Abs(execPath) 152 | if err != nil { 153 | return err 154 | } 155 | lg.Info("replacing old binary with new binary") 156 | if err := replaceBinary(newBin, execAbsPath); err != nil { 157 | return err 158 | } 159 | lg.Info("upgrade success") 160 | return nil 161 | } 162 | 163 | func getLatestPkgInfo() (*LatestPkgInfo, error) { 164 | arch, err := unifyArch() 165 | if err != nil { 166 | return nil, err 167 | } 168 | r, err := http.Get(GITHUB_LATEST_API) 169 | if err != nil { 170 | return nil, err 171 | } 172 | defer r.Body.Close() 173 | jsonBody, err := io.ReadAll(r.Body) 174 | if err != nil { 175 | return nil, err 176 | } 177 | 178 | stringBody := string(jsonBody) 179 | // get latest version by tag name 180 | latestVersion := gjson.Get(stringBody, "tag_name") 181 | // get download url 182 | suffix := fileSuffix() 183 | downloadURL := gjson.Get(stringBody, 184 | fmt.Sprintf(`assets.#(name%%"*%s_%s.%s").browser_download_url`, runtime.GOOS, arch, suffix)) 185 | return &LatestPkgInfo{ 186 | Version: latestVersion.String(), 187 | DownloadURL: downloadURL.String(), 188 | }, nil 189 | } 190 | 191 | func replaceBinary(newBin string, oldBin string) error { 192 | bakBin := oldBin + ".bak" 193 | lg.Debug("backup old binary from %s to %s", oldBin, bakBin) 194 | if err := os.Link(oldBin, bakBin); err != nil { 195 | return err 196 | } 197 | 198 | lg.Debug("remove old binary") 199 | if err := os.RemoveAll(oldBin); err != nil { 200 | return err 201 | } 202 | 203 | lg.Debug("make the new binary effective") 204 | if err := utils.CopyFile(newBin, oldBin, 0700); err != nil { 205 | _ = os.RemoveAll(oldBin) 206 | renameErr := os.Rename(bakBin, oldBin) 207 | if renameErr != nil { 208 | lg.Warn("restore old binary failed, please rename it manually\n"+ 209 | " mv %s %s", bakBin, oldBin) 210 | } 211 | return err 212 | } 213 | _ = os.RemoveAll(bakBin) 214 | return nil 215 | } 216 | -------------------------------------------------------------------------------- /e2e/e2e_test.go: -------------------------------------------------------------------------------- 1 | // Package e2e provides end-to-end tests for the ssx command-line tool. 2 | // These tests require a real SSH server to run against. 3 | // 4 | // To run these tests, set the following environment variables: 5 | // - SSX_E2E_HOST: SSH server hostname or IP 6 | // - SSX_E2E_PORT: SSH server port (default: 22) 7 | // - SSX_E2E_USER: SSH username 8 | // - SSX_E2E_PASSWORD: SSH password (optional if using key) 9 | // - SSX_E2E_KEY: Path to SSH private key (optional if using password) 10 | // - SSX_E2E_HOST2: Second SSH server for remote-to-remote tests (optional) 11 | // - SSX_E2E_PORT2: Second SSH server port (default: 22) 12 | // - SSX_E2E_USER2: Second SSH username (optional) 13 | // 14 | // Example: 15 | // 16 | // SSX_E2E_HOST=192.168.1.100 SSX_E2E_USER=root SSX_E2E_PASSWORD=secret go test -v ./e2e/... 17 | package e2e 18 | 19 | import ( 20 | "bytes" 21 | "context" 22 | "fmt" 23 | "os" 24 | "os/exec" 25 | "path/filepath" 26 | "strings" 27 | "testing" 28 | "time" 29 | ) 30 | 31 | // Test configuration from environment variables 32 | type testConfig struct { 33 | Host string 34 | Port string 35 | User string 36 | Password string 37 | KeyPath string 38 | 39 | // Second host for remote-to-remote tests 40 | Host2 string 41 | Port2 string 42 | User2 string 43 | } 44 | 45 | var ( 46 | cfg testConfig 47 | ssxBinary string 48 | testDBPath string 49 | ) 50 | 51 | func TestMain(m *testing.M) { 52 | // Load configuration from environment 53 | cfg = testConfig{ 54 | Host: os.Getenv("SSX_E2E_HOST"), 55 | Port: getEnvOrDefault("SSX_E2E_PORT", "22"), 56 | User: os.Getenv("SSX_E2E_USER"), 57 | Password: os.Getenv("SSX_E2E_PASSWORD"), 58 | KeyPath: os.Getenv("SSX_E2E_KEY"), 59 | Host2: os.Getenv("SSX_E2E_HOST2"), 60 | Port2: getEnvOrDefault("SSX_E2E_PORT2", "22"), 61 | User2: os.Getenv("SSX_E2E_USER2"), 62 | } 63 | 64 | // Build ssx binary for testing 65 | tmpDir, err := os.MkdirTemp("", "ssx-e2e-*") 66 | if err != nil { 67 | fmt.Fprintf(os.Stderr, "Failed to create temp dir: %v\n", err) 68 | os.Exit(1) 69 | } 70 | 71 | ssxBinary = filepath.Join(tmpDir, "ssx") 72 | testDBPath = filepath.Join(tmpDir, "test.db") 73 | 74 | // Build the binary 75 | cmd := exec.Command("go", "build", "-o", ssxBinary, "../cmd/ssx") 76 | cmd.Stdout = os.Stdout 77 | cmd.Stderr = os.Stderr 78 | if err := cmd.Run(); err != nil { 79 | fmt.Fprintf(os.Stderr, "Failed to build ssx binary: %v\n", err) 80 | os.Exit(1) 81 | } 82 | 83 | // Run tests 84 | code := m.Run() 85 | 86 | // Cleanup 87 | os.RemoveAll(tmpDir) 88 | 89 | os.Exit(code) 90 | } 91 | 92 | func getEnvOrDefault(key, defaultVal string) string { 93 | if v := os.Getenv(key); v != "" { 94 | return v 95 | } 96 | return defaultVal 97 | } 98 | 99 | // skipIfNoServer skips the test if no SSH server is configured 100 | func skipIfNoServer(t *testing.T) { 101 | if cfg.Host == "" || cfg.User == "" { 102 | t.Skip("Skipping e2e test: SSX_E2E_HOST and SSX_E2E_USER must be set") 103 | } 104 | if cfg.Password == "" && cfg.KeyPath == "" { 105 | t.Skip("Skipping e2e test: SSX_E2E_PASSWORD or SSX_E2E_KEY must be set") 106 | } 107 | } 108 | 109 | // skipIfNoSecondServer skips the test if second SSH server is not configured 110 | func skipIfNoSecondServer(t *testing.T) { 111 | skipIfNoServer(t) 112 | if cfg.Host2 == "" { 113 | t.Skip("Skipping remote-to-remote test: SSX_E2E_HOST2 must be set") 114 | } 115 | } 116 | 117 | // runSSX runs the ssx command with given arguments 118 | func runSSX(t *testing.T, args ...string) (string, string, error) { 119 | return runSSXWithEnv(t, nil, args...) 120 | } 121 | 122 | // runSSXWithEnv runs the ssx command with given arguments and environment 123 | func runSSXWithEnv(t *testing.T, env []string, args ...string) (string, string, error) { 124 | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 125 | defer cancel() 126 | 127 | cmd := exec.CommandContext(ctx, ssxBinary, args...) 128 | cmd.Env = append(os.Environ(), fmt.Sprintf("SSX_DB_PATH=%s", testDBPath)) 129 | cmd.Env = append(cmd.Env, env...) 130 | 131 | var stdout, stderr bytes.Buffer 132 | cmd.Stdout = &stdout 133 | cmd.Stderr = &stderr 134 | 135 | err := cmd.Run() 136 | return stdout.String(), stderr.String(), err 137 | } 138 | 139 | // runSSXWithInput runs the ssx command with stdin input 140 | func runSSXWithInput(t *testing.T, input string, args ...string) (string, string, error) { 141 | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 142 | defer cancel() 143 | 144 | cmd := exec.CommandContext(ctx, ssxBinary, args...) 145 | cmd.Env = append(os.Environ(), fmt.Sprintf("SSX_DB_PATH=%s", testDBPath)) 146 | 147 | cmd.Stdin = strings.NewReader(input) 148 | 149 | var stdout, stderr bytes.Buffer 150 | cmd.Stdout = &stdout 151 | cmd.Stderr = &stderr 152 | 153 | err := cmd.Run() 154 | return stdout.String(), stderr.String(), err 155 | } 156 | 157 | // initDB initializes the test database with a password 158 | func initDB(t *testing.T) { 159 | // Run a simple command to initialize the database with password 160 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 161 | defer cancel() 162 | 163 | cmd := exec.CommandContext(ctx, ssxBinary, "list") 164 | cmd.Env = append(os.Environ(), fmt.Sprintf("SSX_DB_PATH=%s", testDBPath)) 165 | cmd.Stdin = strings.NewReader("testpassword\n") 166 | 167 | var stdout, stderr bytes.Buffer 168 | cmd.Stdout = &stdout 169 | cmd.Stderr = &stderr 170 | cmd.Run() // Ignore error, just initializing 171 | } 172 | 173 | // runSSXWithDB runs ssx with initialized database (provides password if needed) 174 | func runSSXWithDB(t *testing.T, args ...string) (string, string, error) { 175 | return runSSXWithInput(t, "testpassword\n", args...) 176 | } 177 | 178 | // serverAddr returns the full server address 179 | func serverAddr() string { 180 | addr := cfg.User + "@" + cfg.Host 181 | if cfg.Port != "22" { 182 | addr += ":" + cfg.Port 183 | } 184 | return addr 185 | } 186 | 187 | // serverAddr2 returns the second server address 188 | func serverAddr2() string { 189 | user := cfg.User2 190 | if user == "" { 191 | user = cfg.User 192 | } 193 | addr := user + "@" + cfg.Host2 194 | if cfg.Port2 != "22" { 195 | addr += ":" + cfg.Port2 196 | } 197 | return addr 198 | } 199 | 200 | // cleanupDB removes the test database 201 | func cleanupDB(t *testing.T) { 202 | os.Remove(testDBPath) 203 | } 204 | 205 | // setupDB cleans and initializes the test database 206 | func setupDB(t *testing.T) { 207 | cleanupDB(t) 208 | initDB(t) 209 | } 210 | -------------------------------------------------------------------------------- /internal/utils/utils_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "archive/zip" 5 | "fmt" 6 | "os" 7 | "os/user" 8 | "path" 9 | "path/filepath" 10 | "testing" 11 | 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | func TestFileExists(t *testing.T) { 16 | tests := []struct { 17 | caseName string 18 | file string 19 | expect bool 20 | }{ 21 | {"file-should-not-exists", "foo/bar", false}, 22 | {"file-should-exists", t.TempDir(), true}, 23 | } 24 | for _, tt := range tests { 25 | t.Run(tt.caseName, func(t *testing.T) { 26 | actual := FileExists(tt.file) 27 | if actual != tt.expect { 28 | t.Errorf("expect %t, got %t", tt.expect, actual) 29 | } 30 | }) 31 | } 32 | } 33 | 34 | func TestExpandHomeDir(t *testing.T) { 35 | tmpHome := "/home" 36 | getCurrentUserFunc = func() (*user.User, error) { 37 | return &user.User{HomeDir: tmpHome}, nil 38 | } 39 | tests := []struct { 40 | path string 41 | expect string 42 | }{ 43 | {"~", tmpHome}, 44 | {"~/a/b", path.Join(tmpHome, "a/b")}, 45 | {"/a/b", "/a/b"}, 46 | {"", ""}, 47 | {"./a", "./a"}, 48 | } 49 | for _, tt := range tests { 50 | t.Run(fmt.Sprintf("expand %q", tt.path), func(t *testing.T) { 51 | actual := ExpandHomeDir(tt.path) 52 | assert.Equal(t, tt.expect, actual) 53 | }) 54 | } 55 | } 56 | 57 | func TestMaskString(t *testing.T) { 58 | tests := []struct { 59 | s string 60 | expect string 61 | }{ 62 | {"", ""}, 63 | {"a", "a***"}, 64 | {"ab", "a***"}, 65 | {"abc", "a***"}, 66 | {"abcd", "ab***d"}, 67 | {"abcdefgh", "ab***h"}, 68 | } 69 | for _, tt := range tests { 70 | actual := MaskString(tt.s) 71 | assert.Equal(t, tt.expect, actual) 72 | } 73 | } 74 | 75 | func TestMatchAddress(t *testing.T) { 76 | tests := []struct { 77 | addr string 78 | username string 79 | host string 80 | port string 81 | }{ 82 | {"user@host:22", "user", "host", "22"}, 83 | {"host:2222", "", "host", "2222"}, 84 | {"host", "", "host", ""}, 85 | {"a.b@1.1.1.1", "a.b", "1.1.1.1", ""}, 86 | {"a_b@1.1.1.1", "a_b", "1.1.1.1", ""}, 87 | } 88 | for _, tt := range tests { 89 | t.Run(tt.addr, func(t *testing.T) { 90 | addr, err := MatchAddress(tt.addr) 91 | assert.NoError(t, err) 92 | assert.Equal(t, tt.username, addr.User) 93 | assert.Equal(t, tt.host, addr.Host) 94 | assert.Equal(t, tt.port, addr.Port) 95 | }) 96 | } 97 | } 98 | 99 | func TestHashWithSHA256(t *testing.T) { 100 | type args struct { 101 | input string 102 | } 103 | tests := []struct { 104 | name string 105 | args args 106 | want string 107 | }{ 108 | { 109 | name: "Hash empty string", 110 | args: args{ 111 | input: "", 112 | }, 113 | want: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", 114 | }, 115 | { 116 | name: "Hash non-empty string", 117 | args: args{ 118 | input: "hello world", 119 | }, 120 | want: "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9", 121 | }, 122 | } 123 | for _, tt := range tests { 124 | t.Run(tt.name, func(t *testing.T) { 125 | if got := HashWithSHA256(tt.args.input); got != tt.want { 126 | t.Errorf("HashWithSHA256() = %v, want %v", got, tt.want) 127 | } 128 | }) 129 | } 130 | } 131 | 132 | func TestUnzip(t *testing.T) { 133 | // Create a temporary directory for testing 134 | tmpDir := t.TempDir() 135 | // Create a test zip file 136 | testZipPath := filepath.Join(tmpDir, "test.zip") 137 | if err := createTestZip(testZipPath); err != nil { 138 | t.Fatal(err) 139 | } 140 | 141 | // Create target directory for extraction 142 | targetDir := filepath.Join(tmpDir, "extracted") 143 | 144 | tests := []struct { 145 | name string 146 | zipPath string 147 | targetDir string 148 | files []string 149 | wantErr bool 150 | checkFn func(t *testing.T, targetDir string) error 151 | }{ 152 | { 153 | name: "extract all files", 154 | zipPath: testZipPath, 155 | targetDir: targetDir, 156 | files: nil, 157 | wantErr: false, 158 | checkFn: func(t *testing.T, targetDir string) error { 159 | // Check if all files exist 160 | files := []string{ 161 | "file1.txt", 162 | "dir1/file2.txt", 163 | "dir1/dir2/file3.txt", 164 | } 165 | for _, f := range files { 166 | path := filepath.Join(targetDir, f) 167 | if !FileExists(path) { 168 | return fmt.Errorf("file not found: %s", path) 169 | } 170 | } 171 | return nil 172 | }, 173 | }, 174 | { 175 | name: "extract specific file", 176 | zipPath: testZipPath, 177 | targetDir: filepath.Join(targetDir, "specific"), 178 | files: []string{"file1.txt"}, 179 | wantErr: false, 180 | checkFn: func(t *testing.T, targetDir string) error { 181 | // Check if only specified file exists 182 | if !FileExists(filepath.Join(targetDir, "file1.txt")) { 183 | return fmt.Errorf("file1.txt not found") 184 | } 185 | if FileExists(filepath.Join(targetDir, "dir1/file2.txt")) { 186 | return fmt.Errorf("file2.txt should not exist") 187 | } 188 | return nil 189 | }, 190 | }, 191 | { 192 | name: "invalid zip path", 193 | zipPath: "nonexistent.zip", 194 | targetDir: targetDir, 195 | wantErr: true, 196 | checkFn: nil, 197 | }, 198 | } 199 | 200 | for _, tt := range tests { 201 | t.Run(tt.name, func(t *testing.T) { 202 | // Clean target directory before each test 203 | os.RemoveAll(tt.targetDir) 204 | 205 | err := Unzip(tt.zipPath, tt.targetDir, tt.files...) 206 | if (err != nil) != tt.wantErr { 207 | t.Errorf("Unzip() error = %v, wantErr %v", err, tt.wantErr) 208 | return 209 | } 210 | 211 | if !tt.wantErr && tt.checkFn != nil { 212 | if err := tt.checkFn(t, tt.targetDir); err != nil { 213 | t.Errorf("check failed: %v", err) 214 | } 215 | } 216 | }) 217 | } 218 | } 219 | 220 | // createTestZip creates a test zip file with some test content 221 | func createTestZip(zipPath string) error { 222 | zipFile, err := os.Create(zipPath) 223 | if err != nil { 224 | return err 225 | } 226 | defer zipFile.Close() 227 | 228 | zipWriter := zip.NewWriter(zipFile) 229 | defer zipWriter.Close() 230 | 231 | // Test files to create 232 | files := map[string]string{ 233 | "file1.txt": "content of file 1", 234 | "dir1/file2.txt": "content of file 2", 235 | "dir1/dir2/file3.txt": "content of file 3", 236 | } 237 | 238 | // Create each file in the zip 239 | for name, content := range files { 240 | f, err := zipWriter.Create(name) 241 | if err != nil { 242 | return err 243 | } 244 | _, err = f.Write([]byte(content)) 245 | if err != nil { 246 | return err 247 | } 248 | } 249 | 250 | return nil 251 | } 252 | -------------------------------------------------------------------------------- /ssx/client.go: -------------------------------------------------------------------------------- 1 | package ssx 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "net" 7 | "os" 8 | "sync" 9 | "time" 10 | 11 | "github.com/containerd/console" 12 | "golang.org/x/crypto/ssh" 13 | 14 | "github.com/vimiix/ssx/internal/lg" 15 | "github.com/vimiix/ssx/internal/terminal" 16 | "github.com/vimiix/ssx/ssx/entry" 17 | ) 18 | 19 | const ( 20 | NETWORK = "tcp" 21 | ) 22 | 23 | type Client struct { 24 | repo Repo 25 | entry *entry.Entry 26 | cli *ssh.Client 27 | closeOnce *sync.Once 28 | } 29 | 30 | func NewClient(e *entry.Entry, repo Repo) *Client { 31 | return &Client{ 32 | entry: e, 33 | repo: repo, 34 | closeOnce: &sync.Once{}, 35 | } 36 | } 37 | 38 | func (c *Client) touchEntry(e *entry.Entry) error { 39 | if e.Source != entry.SourceSSXStore { 40 | return nil 41 | } 42 | return c.repo.TouchEntry(e) 43 | } 44 | 45 | type ExecuteOption struct { 46 | Command string 47 | Stdout io.Writer 48 | Stderr io.Writer 49 | Timeout time.Duration 50 | } 51 | 52 | // Execute a command combined stdout and stderr output, then exit 53 | func (c *Client) Execute(ctx context.Context, opt *ExecuteOption) error { 54 | if opt.Timeout > 0 { 55 | var cancel context.CancelFunc 56 | ctx, cancel = context.WithTimeout(ctx, opt.Timeout) 57 | defer cancel() 58 | } 59 | 60 | if err := c.Login(ctx); err != nil { 61 | return err 62 | } 63 | defer c.close() 64 | 65 | sess, err := c.cli.NewSession() 66 | if err != nil { 67 | return err 68 | } 69 | defer sess.Close() 70 | 71 | sess.Stdout = opt.Stdout 72 | sess.Stderr = opt.Stderr 73 | return sess.Run(opt.Command) 74 | } 75 | 76 | // Interact Bind the current terminal to provide an interactive interface 77 | func (c *Client) Interact(ctx context.Context) error { 78 | if err := c.Login(ctx); err != nil { 79 | return err 80 | } 81 | defer c.close() 82 | 83 | lg.Info("connected server %s, version: %s", 84 | c.entry.String(), string(c.cli.ServerVersion())) 85 | session, err := c.cli.NewSession() 86 | if err != nil { 87 | return err 88 | } 89 | defer func() { 90 | _ = session.Close() 91 | }() 92 | 93 | return c.attach(ctx, session) 94 | } 95 | 96 | func (c *Client) attach(ctx context.Context, sess *ssh.Session) error { 97 | current := console.Current() 98 | defer func() { 99 | _ = current.Reset() 100 | }() 101 | 102 | if err := current.SetRaw(); err != nil { 103 | return err 104 | } 105 | 106 | w, h, err := terminal.GetAndWatchWindowSize(ctx, sess) 107 | if err != nil { 108 | return err 109 | } 110 | 111 | modes := ssh.TerminalModes{ 112 | ssh.ECHO: 1, // enable echoing 113 | ssh.TTY_OP_ISPEED: 14400, // input speed = 14.4kbaud 114 | ssh.TTY_OP_OSPEED: 14400, // output speed = 14.4kbaud 115 | } 116 | if err = sess.RequestPty("xterm", h, w, modes); err != nil { 117 | return err 118 | } 119 | 120 | var closeStdin sync.Once 121 | stdinPipe, err := sess.StdinPipe() 122 | if err != nil { 123 | return err 124 | } 125 | defer closeStdin.Do(func() { 126 | _ = stdinPipe.Close() 127 | }) 128 | sess.Stdout = os.Stdout 129 | sess.Stderr = os.Stderr 130 | go func() { 131 | defer closeStdin.Do(func() { 132 | _ = stdinPipe.Close() 133 | }) 134 | ioCopy(stdinPipe, os.Stdin) 135 | }() 136 | 137 | if err = sess.Shell(); err != nil { 138 | return err 139 | } 140 | 141 | go c.keepalive(ctx) 142 | 143 | _ = sess.Wait() // ignore *ExitError, always exit with code 130 144 | return nil 145 | } 146 | 147 | func ioCopy(dst io.Writer, src io.Reader) { 148 | if _, err := io.Copy(dst, src); err != nil { 149 | lg.Error(err.Error()) 150 | } 151 | } 152 | 153 | func (c *Client) keepalive(ctx context.Context) { 154 | ticker := time.NewTicker(time.Second * 10) 155 | for { 156 | select { 157 | case <-ticker.C: 158 | case <-ctx.Done(): 159 | ticker.Stop() 160 | return 161 | } 162 | _, _, err := c.cli.SendRequest("keepalive@openssh.com", false, nil) 163 | if err != nil { 164 | break 165 | } 166 | } 167 | } 168 | 169 | // code source: https://github.com/golang/go/issues/20288#issuecomment-832033017 170 | func dialContext(ctx context.Context, addr string, config *ssh.ClientConfig) (*ssh.Client, error) { 171 | d := net.Dialer{Timeout: config.Timeout} 172 | conn, err := d.DialContext(ctx, NETWORK, addr) 173 | if err != nil { 174 | return nil, err 175 | } 176 | c, chans, reqs, err := ssh.NewClientConn(conn, addr, config) 177 | if err != nil { 178 | return nil, err 179 | } 180 | return ssh.NewClient(c, chans, reqs), nil 181 | } 182 | 183 | func dialThroughProxy(ctx context.Context, proxy *entry.Proxy, parentProxyCli *ssh.Client, targetEntry *entry.Entry) (*ssh.Client, error) { 184 | var err error 185 | if parentProxyCli == nil { 186 | proxyConfig, err := proxy.GenSSHConfig(ctx) 187 | if err != nil { 188 | return nil, err 189 | } 190 | lg.Debug("dialing proxy: %s", proxy.String()) 191 | parentProxyCli, err = dialContext(ctx, proxy.Address(), proxyConfig) 192 | if err != nil { 193 | lg.Debug("dial proxy %s failed: %v", proxy.String(), err) 194 | return nil, err 195 | } 196 | lg.Debug("proxy client establised") 197 | } 198 | 199 | var ( 200 | tmpTargetAddr string 201 | tmpTargetConfig *ssh.ClientConfig 202 | tmpHostString string 203 | ) 204 | if proxy.Proxy != nil { 205 | tmpHostString = proxy.Proxy.String() 206 | tmpTargetAddr = proxy.Proxy.Address() 207 | tmpTargetConfig, err = proxy.Proxy.GenSSHConfig(ctx) 208 | if err != nil { 209 | return nil, err 210 | } 211 | } else { 212 | tmpHostString = targetEntry.String() 213 | tmpTargetAddr = targetEntry.Address() 214 | tmpTargetConfig, err = targetEntry.GenSSHConfig(ctx) 215 | if err != nil { 216 | return nil, err 217 | } 218 | } 219 | lg.Debug("dialing to %s", tmpHostString) 220 | conn, err := parentProxyCli.DialContext(ctx, NETWORK, tmpTargetAddr) 221 | if err != nil { 222 | return nil, err 223 | } 224 | nc, chans, reqs, err := ssh.NewClientConn(conn, tmpTargetAddr, tmpTargetConfig) 225 | if err != nil { 226 | return nil, err 227 | } 228 | targetCli := ssh.NewClient(nc, chans, reqs) 229 | if proxy.Proxy == nil { 230 | return targetCli, nil 231 | } 232 | return dialThroughProxy(ctx, proxy.Proxy, parentProxyCli, targetEntry) 233 | } 234 | 235 | // Login connect remote server and touch enrty in storage 236 | func (c *Client) Login(ctx context.Context) error { 237 | lg.Debug("connecting to %s", c.entry.String()) 238 | cli, err := c.dial(ctx) 239 | if err != nil { 240 | // try fix authentication 241 | if c.entry.ID != 0 { 242 | lg.Error("login failed with stored authentication, try login with interactive") 243 | cli, err = c.tryLoginAgainWithEmptyPassword(ctx) 244 | if err != nil { 245 | return err 246 | } 247 | } else { 248 | return err 249 | } 250 | } 251 | c.cli = cli 252 | if err := c.touchEntry(c.entry); err != nil { 253 | lg.Error("failed to touch entry: %s", err) 254 | } 255 | return nil 256 | } 257 | 258 | func (c *Client) tryLoginAgainWithEmptyPassword(ctx context.Context) (*ssh.Client, error) { 259 | c.entry.ClearPassword() 260 | return c.dial(ctx) 261 | } 262 | 263 | func (c *Client) dial(ctx context.Context) (*ssh.Client, error) { 264 | if c.entry.Proxy != nil { 265 | return dialThroughProxy(ctx, c.entry.Proxy, nil, c.entry) 266 | } 267 | // connect directly 268 | sshConfig, err := c.entry.GenSSHConfig(ctx) 269 | if err != nil { 270 | return nil, err 271 | } 272 | return dialContext(ctx, c.entry.Address(), sshConfig) 273 | } 274 | 275 | func (c *Client) close() { 276 | if c.cli == nil { 277 | return 278 | } 279 | c.closeOnce.Do(func() { 280 | _ = c.cli.Close() 281 | c.cli = nil 282 | }) 283 | } 284 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/agiledragon/gomonkey/v2 v2.11.0 h1:5oxSgA+tC1xuGsrIorR+sYiziYltmJyEZ9qA25b6l5U= 2 | github.com/agiledragon/gomonkey/v2 v2.11.0/go.mod h1:ap1AmDzcVOAz1YpeJ3TCzIgstoaWLA6jbbgxfB4w2iY= 3 | github.com/bramvdbogaerde/go-scp v1.5.0 h1:a9BinAjTfQh273eh7vd3qUgmBC+bx+3TRDtkZWmIpzM= 4 | github.com/bramvdbogaerde/go-scp v1.5.0/go.mod h1:on2aH5AxaFb2G0N5Vsdy6B0Ml7k9HuHSwfo1y0QzAbQ= 5 | github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= 6 | github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= 7 | github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8= 8 | github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= 9 | github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= 10 | github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= 11 | github.com/containerd/console v1.0.5 h1:R0ymNeydRqH2DmakFNdmjR2k0t7UPuiOV/N/27/qqsc= 12 | github.com/containerd/console v1.0.5/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= 13 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 14 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 15 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 16 | github.com/denisbrodbeck/machineid v1.0.1 h1:geKr9qtkB876mXguW2X6TU4ZynleN6ezuMSRhl4D7AQ= 17 | github.com/denisbrodbeck/machineid v1.0.1/go.mod h1:dJUwb7PTidGDeYyUBmXZ2GphQBbjJCrnectwCyxcUSI= 18 | github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= 19 | github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= 20 | github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 21 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 22 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 23 | github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8= 24 | github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg= 25 | github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 26 | github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ= 27 | github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= 28 | github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= 29 | github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= 30 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 31 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 32 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 33 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 34 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 35 | github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 36 | github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= 37 | github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 38 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 39 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 40 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 41 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 42 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 43 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 44 | github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= 45 | github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 46 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 47 | github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg= 48 | github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow= 49 | github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= 50 | github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= 51 | github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= 52 | github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= 53 | github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= 54 | github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 55 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 56 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 57 | github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= 58 | github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= 59 | github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= 60 | github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= 61 | github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= 62 | github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= 63 | github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= 64 | github.com/vimiix/tablewriter v0.0.0-20231207073205-aad9e2006284 h1:7o3B9eLdW6tgtWcgP0TVnq9QAUFu5Ii/RoaFOkEMYbc= 65 | github.com/vimiix/tablewriter v0.0.0-20231207073205-aad9e2006284/go.mod h1:uQpPcEuo28DE69kbtdWpMfeB+el/Kaeh2hCEdrz1iKI= 66 | go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= 67 | go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= 68 | go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 69 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 70 | golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= 71 | golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= 72 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 73 | golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= 74 | golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 75 | golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 76 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 77 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 78 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 79 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 80 | golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= 81 | golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 82 | golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= 83 | golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= 84 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 85 | golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 86 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 87 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 88 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 89 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 90 | -------------------------------------------------------------------------------- /e2e/cp_test.go: -------------------------------------------------------------------------------- 1 | package e2e 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "regexp" 7 | "strings" 8 | "testing" 9 | ) 10 | 11 | // TestCpUpload tests uploading a local file to remote 12 | func TestCpUpload(t *testing.T) { 13 | skipIfNoServer(t) 14 | cleanupDB(t) 15 | 16 | // Create a temporary local file 17 | tmpDir, err := os.MkdirTemp("", "ssx-cp-test-*") 18 | if err != nil { 19 | t.Fatalf("Failed to create temp dir: %v", err) 20 | } 21 | defer os.RemoveAll(tmpDir) 22 | 23 | localFile := filepath.Join(tmpDir, "upload_test.txt") 24 | testContent := "Hello from ssx e2e test - upload" 25 | if err := os.WriteFile(localFile, []byte(testContent), 0644); err != nil { 26 | t.Fatalf("Failed to create test file: %v", err) 27 | } 28 | 29 | // Upload file 30 | remotePath := "/tmp/ssx_e2e_upload_test.txt" 31 | args := []string{"cp", localFile, serverAddr() + ":" + remotePath} 32 | if cfg.KeyPath != "" { 33 | args = append(args, "-i", cfg.KeyPath) 34 | } 35 | 36 | stdout, stderr, err := runSSXWithInput(t, cfg.Password+"\n", args...) 37 | if err != nil { 38 | t.Fatalf("Failed to upload file: %v\nstdout: %s\nstderr: %s", err, stdout, stderr) 39 | } 40 | 41 | // Verify file was uploaded by reading it back 42 | args = []string{serverAddr(), "-c", "cat " + remotePath} 43 | if cfg.KeyPath != "" { 44 | args = append(args, "-i", cfg.KeyPath) 45 | } 46 | 47 | stdout, stderr, err = runSSXWithInput(t, cfg.Password+"\n", args...) 48 | if err != nil { 49 | t.Fatalf("Failed to verify upload: %v\nstderr: %s", err, stderr) 50 | } 51 | 52 | if !strings.Contains(stdout, testContent) { 53 | t.Errorf("Expected uploaded content %q, got: %s", testContent, stdout) 54 | } 55 | 56 | // Cleanup remote file 57 | args = []string{serverAddr(), "-c", "rm -f " + remotePath} 58 | if cfg.KeyPath != "" { 59 | args = append(args, "-i", cfg.KeyPath) 60 | } 61 | runSSXWithInput(t, cfg.Password+"\n", args...) 62 | } 63 | 64 | // TestCpDownload tests downloading a remote file to local 65 | func TestCpDownload(t *testing.T) { 66 | skipIfNoServer(t) 67 | cleanupDB(t) 68 | 69 | // Create a file on remote server 70 | remotePath := "/tmp/ssx_e2e_download_test.txt" 71 | testContent := "Hello from ssx e2e test - download" 72 | 73 | args := []string{serverAddr(), "-c", "echo '" + testContent + "' > " + remotePath} 74 | if cfg.KeyPath != "" { 75 | args = append(args, "-i", cfg.KeyPath) 76 | } 77 | 78 | _, stderr, err := runSSXWithInput(t, cfg.Password+"\n", args...) 79 | if err != nil { 80 | t.Fatalf("Failed to create remote file: %v\nstderr: %s", err, stderr) 81 | } 82 | 83 | // Create temp dir for download 84 | tmpDir, err := os.MkdirTemp("", "ssx-cp-test-*") 85 | if err != nil { 86 | t.Fatalf("Failed to create temp dir: %v", err) 87 | } 88 | defer os.RemoveAll(tmpDir) 89 | 90 | localFile := filepath.Join(tmpDir, "download_test.txt") 91 | 92 | // Download file 93 | args = []string{"cp", serverAddr() + ":" + remotePath, localFile} 94 | if cfg.KeyPath != "" { 95 | args = append(args, "-i", cfg.KeyPath) 96 | } 97 | 98 | stdout, stderr, err := runSSXWithInput(t, cfg.Password+"\n", args...) 99 | if err != nil { 100 | t.Fatalf("Failed to download file: %v\nstdout: %s\nstderr: %s", err, stdout, stderr) 101 | } 102 | 103 | // Verify downloaded content 104 | content, err := os.ReadFile(localFile) 105 | if err != nil { 106 | t.Fatalf("Failed to read downloaded file: %v", err) 107 | } 108 | 109 | if !strings.Contains(string(content), testContent) { 110 | t.Errorf("Expected downloaded content to contain %q, got: %s", testContent, string(content)) 111 | } 112 | 113 | // Cleanup remote file 114 | args = []string{serverAddr(), "-c", "rm -f " + remotePath} 115 | if cfg.KeyPath != "" { 116 | args = append(args, "-i", cfg.KeyPath) 117 | } 118 | runSSXWithInput(t, cfg.Password+"\n", args...) 119 | } 120 | 121 | // TestCpWithTag tests file copy using tag to reference remote host 122 | func TestCpWithTag(t *testing.T) { 123 | skipIfNoServer(t) 124 | cleanupDB(t) 125 | 126 | // First create an entry and tag it 127 | args := []string{serverAddr(), "-c", "echo setup"} 128 | if cfg.KeyPath != "" { 129 | args = append(args, "-i", cfg.KeyPath) 130 | } 131 | _, _, err := runSSXWithInput(t, cfg.Password+"\n", args...) 132 | if err != nil { 133 | t.Fatalf("Failed to setup entry: %v", err) 134 | } 135 | 136 | // Get entry ID and add tag 137 | stdout, _, _ := runSSX(t, "list") 138 | re := regexp.MustCompile(`\s+(\d+)\s+\|`) 139 | matches := re.FindStringSubmatch(stdout) 140 | if len(matches) < 2 { 141 | t.Fatalf("Could not find entry ID") 142 | } 143 | entryID := matches[1] 144 | 145 | tagName := "cp-test-server" 146 | _, _, err = runSSX(t, "tag", "--id", entryID, "-t", tagName) 147 | if err != nil { 148 | t.Fatalf("Failed to add tag: %v", err) 149 | } 150 | 151 | // Create local file 152 | tmpDir, err := os.MkdirTemp("", "ssx-cp-test-*") 153 | if err != nil { 154 | t.Fatalf("Failed to create temp dir: %v", err) 155 | } 156 | defer os.RemoveAll(tmpDir) 157 | 158 | localFile := filepath.Join(tmpDir, "tag_test.txt") 159 | testContent := "Tag-based copy test" 160 | if err := os.WriteFile(localFile, []byte(testContent), 0644); err != nil { 161 | t.Fatalf("Failed to create test file: %v", err) 162 | } 163 | 164 | // Upload using tag 165 | remotePath := "/tmp/ssx_e2e_tag_test.txt" 166 | args = []string{"cp", localFile, tagName + ":" + remotePath} 167 | 168 | stdout, stderr, err := runSSXWithInput(t, cfg.Password+"\n", args...) 169 | if err != nil { 170 | t.Fatalf("Failed to upload using tag: %v\nstdout: %s\nstderr: %s", err, stdout, stderr) 171 | } 172 | 173 | // Verify 174 | args = []string{serverAddr(), "-c", "cat " + remotePath} 175 | if cfg.KeyPath != "" { 176 | args = append(args, "-i", cfg.KeyPath) 177 | } 178 | stdout, _, err = runSSXWithInput(t, cfg.Password+"\n", args...) 179 | if err != nil { 180 | t.Fatalf("Failed to verify: %v", err) 181 | } 182 | 183 | if !strings.Contains(stdout, testContent) { 184 | t.Errorf("Expected content %q, got: %s", testContent, stdout) 185 | } 186 | 187 | // Cleanup 188 | args = []string{serverAddr(), "-c", "rm -f " + remotePath} 189 | if cfg.KeyPath != "" { 190 | args = append(args, "-i", cfg.KeyPath) 191 | } 192 | runSSXWithInput(t, cfg.Password+"\n", args...) 193 | } 194 | 195 | // TestCpRemoteToRemote tests copying file between two remote hosts 196 | func TestCpRemoteToRemote(t *testing.T) { 197 | skipIfNoSecondServer(t) 198 | cleanupDB(t) 199 | 200 | // Create a file on first remote server 201 | remotePath1 := "/tmp/ssx_e2e_r2r_source.txt" 202 | testContent := "Remote to remote test content" 203 | 204 | args := []string{serverAddr(), "-c", "echo '" + testContent + "' > " + remotePath1} 205 | if cfg.KeyPath != "" { 206 | args = append(args, "-i", cfg.KeyPath) 207 | } 208 | 209 | _, stderr, err := runSSXWithInput(t, cfg.Password+"\n", args...) 210 | if err != nil { 211 | t.Fatalf("Failed to create source file: %v\nstderr: %s", err, stderr) 212 | } 213 | 214 | // Copy from first server to second server 215 | remotePath2 := "/tmp/ssx_e2e_r2r_dest.txt" 216 | args = []string{"cp", serverAddr() + ":" + remotePath1, serverAddr2() + ":" + remotePath2} 217 | if cfg.KeyPath != "" { 218 | args = append(args, "-i", cfg.KeyPath) 219 | } 220 | 221 | stdout, stderr, err := runSSXWithInput(t, cfg.Password+"\n"+cfg.Password+"\n", args...) 222 | if err != nil { 223 | t.Fatalf("Failed remote-to-remote copy: %v\nstdout: %s\nstderr: %s", err, stdout, stderr) 224 | } 225 | 226 | // Verify file on second server 227 | args = []string{serverAddr2(), "-c", "cat " + remotePath2} 228 | if cfg.KeyPath != "" { 229 | args = append(args, "-i", cfg.KeyPath) 230 | } 231 | 232 | stdout, stderr, err = runSSXWithInput(t, cfg.Password+"\n", args...) 233 | if err != nil { 234 | t.Fatalf("Failed to verify on second server: %v\nstderr: %s", err, stderr) 235 | } 236 | 237 | if !strings.Contains(stdout, testContent) { 238 | t.Errorf("Expected content %q on second server, got: %s", testContent, stdout) 239 | } 240 | 241 | // Cleanup both servers 242 | args = []string{serverAddr(), "-c", "rm -f " + remotePath1} 243 | if cfg.KeyPath != "" { 244 | args = append(args, "-i", cfg.KeyPath) 245 | } 246 | runSSXWithInput(t, cfg.Password+"\n", args...) 247 | 248 | args = []string{serverAddr2(), "-c", "rm -f " + remotePath2} 249 | if cfg.KeyPath != "" { 250 | args = append(args, "-i", cfg.KeyPath) 251 | } 252 | runSSXWithInput(t, cfg.Password+"\n", args...) 253 | } 254 | 255 | // TestCpLocalToLocal tests that local-to-local copy is rejected 256 | func TestCpLocalToLocal(t *testing.T) { 257 | setupDB(t) 258 | 259 | stdout, stderr, err := runSSXWithDB(t, "cp", "/tmp/file1", "/tmp/file2") 260 | if err == nil { 261 | t.Error("Expected error for local-to-local copy") 262 | } 263 | 264 | combined := stdout + stderr 265 | if !strings.Contains(combined, "local to local") && !strings.Contains(combined, "local") { 266 | t.Errorf("Expected 'local to local' error message, got stdout: %s, stderr: %s", stdout, stderr) 267 | } 268 | } 269 | 270 | // TestCpMissingArgs tests cp command with missing arguments 271 | func TestCpMissingArgs(t *testing.T) { 272 | _, stderr, err := runSSX(t, "cp", "/tmp/file1") 273 | if err == nil { 274 | t.Error("Expected error for missing target argument") 275 | } 276 | 277 | if !strings.Contains(stderr, "requires") || !strings.Contains(stderr, "arg") { 278 | t.Logf("Expected argument error, got: %s", stderr) 279 | } 280 | } 281 | 282 | // TestCpNonExistentLocalFile tests uploading non-existent file 283 | func TestCpNonExistentLocalFile(t *testing.T) { 284 | skipIfNoServer(t) 285 | cleanupDB(t) 286 | 287 | args := []string{"cp", "/nonexistent/file/path.txt", serverAddr() + ":/tmp/dest.txt"} 288 | if cfg.KeyPath != "" { 289 | args = append(args, "-i", cfg.KeyPath) 290 | } 291 | 292 | _, stderr, err := runSSXWithInput(t, cfg.Password+"\n", args...) 293 | if err == nil { 294 | t.Error("Expected error for non-existent local file") 295 | } 296 | 297 | if !strings.Contains(stderr, "no such file") && !strings.Contains(stderr, "does not exist") && !strings.Contains(stderr, "failed") { 298 | t.Logf("Expected file not found error, got: %s", stderr) 299 | } 300 | } 301 | -------------------------------------------------------------------------------- /internal/utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "archive/tar" 5 | "archive/zip" 6 | "compress/gzip" 7 | "context" 8 | "crypto/sha256" 9 | "encoding/hex" 10 | "io" 11 | "net/http" 12 | "net/url" 13 | "os" 14 | "os/user" 15 | "path" 16 | "path/filepath" 17 | "regexp" 18 | "strings" 19 | 20 | "github.com/denisbrodbeck/machineid" 21 | "github.com/pkg/errors" 22 | "github.com/vimiix/ssx/internal/lg" 23 | "github.com/vimiix/ssx/ssx/env" 24 | ) 25 | 26 | // FileExists check given filename if exists 27 | func FileExists(filename string) bool { 28 | if filename == "" { 29 | return false 30 | } 31 | _, err := os.Stat(ExpandHomeDir(filename)) 32 | return !os.IsNotExist(err) 33 | } 34 | 35 | var getCurrentUserFunc = user.Current 36 | 37 | // ExpandHomeDir expands the path to include the home directory if the path is prefixed with `~`. 38 | // If it isn't prefixed with `~`, the path is returned as-is. 39 | func ExpandHomeDir(path string) string { 40 | if len(path) == 0 || path[0] != '~' { 41 | return path 42 | } 43 | 44 | path = filepath.Clean(path) 45 | 46 | u, err := getCurrentUserFunc() 47 | if err != nil || u.HomeDir == "" { 48 | return path 49 | } 50 | 51 | return filepath.Join(u.HomeDir, path[1:]) 52 | } 53 | 54 | func MaskString(s string) string { 55 | mask := "***" 56 | if len(s) == 0 { 57 | return s 58 | } else if len(s) <= 3 { 59 | return s[:1] + mask 60 | } else { 61 | return s[:2] + mask + s[len(s)-1:] 62 | } 63 | } 64 | 65 | // CurrentUserName returns the UserName of the current os user 66 | func CurrentUserName() (string, error) { 67 | user, err := user.Current() 68 | if err != nil { 69 | return "", err 70 | } 71 | return user.Username, nil 72 | } 73 | 74 | // ContainsI a case-insensitive strings contains 75 | func ContainsI(s, sub string) bool { 76 | return strings.Contains( 77 | strings.ToLower(s), 78 | strings.ToLower(sub), 79 | ) 80 | } 81 | 82 | type Address struct { 83 | User string 84 | Host string 85 | Port string 86 | } 87 | 88 | var addrRegex = regexp.MustCompile(`^(?:(?P[\w.\-_]+)@)?(?P[\w.-]+)(?::(?P\d+))?(?:/(?P[\w/.-]+))?$`) 89 | 90 | func MatchAddress(addr string) (*Address, error) { 91 | matches := addrRegex.FindStringSubmatch(addr) 92 | if len(matches) == 0 { 93 | return nil, errors.Errorf("invalid address: %q", addr) 94 | } 95 | username, host, port := matches[1], matches[2], matches[3] 96 | addrObj := &Address{ 97 | User: username, 98 | Host: host, 99 | Port: port, 100 | } 101 | return addrObj, nil 102 | } 103 | 104 | // GetDeviceID get secret key from env, if not set returns machine id 105 | // always returns 16 characters key 106 | func GetDeviceID() (string, error) { 107 | if os.Getenv(env.SSXDeviceID) != "" { 108 | return os.Getenv(env.SSXDeviceID), nil 109 | } 110 | if os.Getenv(env.SSXSecretKey) != "" { 111 | lg.Warn("env SSX_SECRET_KEY is deprecated, please use SSX_DEVICE_ID instead") 112 | return os.Getenv(env.SSXSecretKey), nil 113 | } 114 | // ref: https://man7.org/linux/man-pages/man5/machine-id.5.html 115 | machineID, err := machineid.ProtectedID("ssx") 116 | if err != nil { 117 | return "", errors.Wrap(err, "failed to get machine id") 118 | } 119 | return machineID, nil 120 | } 121 | 122 | func DownloadFile(ctx context.Context, urlStr string, saveFile string) error { 123 | _, err := url.Parse(urlStr) 124 | if err != nil { 125 | return err 126 | } 127 | fp, err := os.Create(saveFile) 128 | if err != nil { 129 | return err 130 | } 131 | defer fp.Close() 132 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, nil) 133 | if err != nil { 134 | return err 135 | } 136 | resp, err := http.DefaultClient.Do(req) 137 | if err != nil { 138 | return err 139 | } 140 | defer resp.Body.Close() 141 | if resp.StatusCode != 200 { 142 | return errors.Errorf("request failed:\n- url: %s\n- response: %s", urlStr, resp.Status) 143 | } 144 | _, err = io.Copy(fp, resp.Body) 145 | if err != nil { 146 | return err 147 | } 148 | return nil 149 | } 150 | 151 | type closeFunc func() 152 | 153 | func openTarball(tarball string) (*tar.Reader, closeFunc, error) { 154 | if tarball == "" { 155 | return nil, nil, errors.Errorf("no tarball specified") 156 | } 157 | 158 | f, err := os.Open(tarball) 159 | if err != nil { 160 | return nil, nil, err 161 | } 162 | closers := []io.Closer{f} 163 | var tr *tar.Reader 164 | gr, err := gzip.NewReader(f) 165 | if err != nil { 166 | f.Close() 167 | return nil, nil, err 168 | } 169 | closers = append(closers, gr) 170 | tr = tar.NewReader(gr) 171 | closeFunc := func() { 172 | for i := len(closers) - 1; i > -1; i-- { 173 | closers[i].Close() 174 | } 175 | } 176 | return tr, closeFunc, nil 177 | } 178 | 179 | func Untar(tarPath string, targetDir string, filenames ...string) error { 180 | specifiedUntar := false 181 | if len(filenames) > 0 { 182 | specifiedUntar = true 183 | } 184 | 185 | tr, closefunc, err := openTarball(tarPath) 186 | if err != nil { 187 | return err 188 | } 189 | defer closefunc() 190 | 191 | for { 192 | header, err := tr.Next() 193 | switch { 194 | // if no more files are found return 195 | case err == io.EOF: 196 | return nil 197 | // return any other error 198 | case err != nil: 199 | return err 200 | // if the header is nil, just skip it (not sure how this happens) 201 | case header == nil: 202 | continue 203 | } 204 | if strings.Contains(header.Name, "..") { 205 | // code scanning: https://github.com/vimiix/ssx/security/code-scanning/3 206 | lg.Warn("ignore file %s due to zip slip vulnerability", header.Name) 207 | continue 208 | } 209 | 210 | // the target location where the dir/file should be created 211 | target := filepath.Join(targetDir, filepath.FromSlash(header.Name)) 212 | switch header.Typeflag { 213 | // if it's a dir, and it doesn't exist create it 214 | case tar.TypeDir: 215 | if _, err := os.Stat(target); err != nil { 216 | if err := os.MkdirAll(target, 0700); err != nil { 217 | return err 218 | } 219 | } 220 | // if it's a file create it 221 | case tar.TypeReg: 222 | if specifiedUntar { 223 | if len(filenames) == 0 { 224 | // The specified files to be extracted have all been found, 225 | // and should be returned immediately. 226 | return nil 227 | } 228 | targetIdx := -1 229 | for idx, fn := range filenames { 230 | if strings.TrimPrefix(fn, "./") == strings.TrimPrefix(header.Name, "./") { 231 | targetIdx = idx 232 | } 233 | } 234 | if targetIdx == -1 { 235 | continue 236 | } 237 | filenames = append(filenames[:targetIdx], filenames[targetIdx+1:]...) 238 | } 239 | 240 | dirpath := path.Dir(target) 241 | if !FileExists(dirpath) { 242 | if err := os.MkdirAll(dirpath, 0700); err != nil { 243 | return err 244 | } 245 | } 246 | 247 | targetFile, err := os.OpenFile(target, os.O_CREATE|os.O_RDWR|os.O_TRUNC, os.FileMode(header.Mode)) 248 | if err != nil { 249 | return err 250 | } 251 | // copy over contents 252 | if _, err := io.Copy(targetFile, tr); err != nil { 253 | return err 254 | } 255 | // manually close here after each file operation; defering would cause each file close 256 | // to wait until all operations have completed. 257 | targetFile.Close() 258 | } 259 | } 260 | } 261 | 262 | func Unzip(zipPath string, targetDir string, filenames ...string) error { 263 | // Open the zip file 264 | reader, err := zip.OpenReader(zipPath) 265 | if err != nil { 266 | return err 267 | } 268 | defer reader.Close() 269 | 270 | // Check if specific files are specified 271 | specifiedUnzip := len(filenames) > 0 272 | 273 | // Iterate through all files in zip 274 | for _, file := range reader.File { 275 | // Prevent zip slip vulnerability 276 | if strings.Contains(file.Name, "..") { 277 | lg.Warn("ignore file %s due to zip slip vulnerability", file.Name) 278 | continue 279 | } 280 | 281 | // If files are specified, check if current file is in the list 282 | if specifiedUnzip { 283 | if len(filenames) == 0 { 284 | // All specified files have been extracted, return early 285 | return nil 286 | } 287 | targetIdx := -1 288 | for idx, fn := range filenames { 289 | if strings.TrimPrefix(fn, "./") == strings.TrimPrefix(file.Name, "./") { 290 | targetIdx = idx 291 | } 292 | } 293 | if targetIdx == -1 { 294 | continue 295 | } 296 | // Remove processed file from the pending list 297 | filenames = append(filenames[:targetIdx], filenames[targetIdx+1:]...) 298 | } 299 | 300 | // Build target path 301 | target := filepath.Join(targetDir, filepath.FromSlash(file.Name)) 302 | 303 | // Handle directories 304 | if file.FileInfo().IsDir() { 305 | if err := os.MkdirAll(target, 0700); err != nil { 306 | return err 307 | } 308 | continue 309 | } 310 | 311 | // Ensure parent directory exists 312 | if err := os.MkdirAll(filepath.Dir(target), 0700); err != nil { 313 | return err 314 | } 315 | 316 | // Create target file 317 | targetFile, err := os.OpenFile(target, os.O_CREATE|os.O_RDWR|os.O_TRUNC, file.Mode()) 318 | if err != nil { 319 | return err 320 | } 321 | 322 | // Open source file 323 | srcFile, err := file.Open() 324 | if err != nil { 325 | targetFile.Close() 326 | return err 327 | } 328 | 329 | // Copy contents 330 | _, err = io.Copy(targetFile, srcFile) 331 | 332 | // Close files 333 | srcFile.Close() 334 | targetFile.Close() 335 | 336 | if err != nil { 337 | return err 338 | } 339 | } 340 | 341 | return nil 342 | } 343 | 344 | func Extract(pkg string, targetDir string) error { 345 | if strings.HasSuffix(pkg, ".tar.gz") { 346 | return Untar(pkg, targetDir) 347 | } 348 | 349 | if strings.HasSuffix(pkg, ".zip") { 350 | return Unzip(pkg, targetDir) 351 | } 352 | 353 | return errors.New("unsupported archive format") 354 | } 355 | 356 | // CopyFile copies the contents of src to dst 357 | func CopyFile(src, dst string, perm os.FileMode) error { 358 | sf, err := os.Open(src) 359 | if err != nil { 360 | return err 361 | } 362 | defer sf.Close() 363 | tf, err := os.OpenFile(dst, os.O_RDWR|os.O_CREATE|os.O_TRUNC, perm) 364 | if err != nil { 365 | return err 366 | } 367 | defer tf.Close() 368 | _, err = io.Copy(tf, sf) 369 | return err 370 | } 371 | 372 | // HashWithSHA256 hashes the input string using SHA-256 and returns the hexadecimal representation of the hash 373 | func HashWithSHA256(input string) string { 374 | hash := sha256.New() 375 | hash.Write([]byte(input)) 376 | hashedBytes := hash.Sum(nil) 377 | return hex.EncodeToString(hashedBytes) 378 | } 379 | -------------------------------------------------------------------------------- /ssx/entry/entry.go: -------------------------------------------------------------------------------- 1 | package entry 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net" 8 | "os" 9 | "os/user" 10 | "path/filepath" 11 | "time" 12 | 13 | "github.com/jinzhu/copier" 14 | "github.com/pkg/errors" 15 | "github.com/skeema/knownhosts" 16 | "golang.org/x/crypto/ssh" 17 | 18 | "github.com/vimiix/ssx/internal/lg" 19 | "github.com/vimiix/ssx/internal/terminal" 20 | "github.com/vimiix/ssx/internal/utils" 21 | "github.com/vimiix/ssx/ssx/env" 22 | ) 23 | 24 | const ( 25 | SourceSSHConfig = "ssh_config" 26 | SourceSSXStore = "ssx_store" 27 | ) 28 | 29 | var ( 30 | defaultIdentityFile = "~/.ssh/id_rsa" 31 | defaultUser = "root" 32 | defaultPort = "22" 33 | ) 34 | 35 | const ( 36 | ModeUninit = "" 37 | ModeSafe = "safe" 38 | ModeUnsafe = "unsafe" 39 | ) 40 | 41 | // Entry represent a target server 42 | type Entry struct { 43 | ID uint64 `json:"id"` 44 | Host string `json:"host"` 45 | User string `json:"user"` 46 | Port string `json:"port"` 47 | VisitCount int `json:"visit_count"` // Perhaps I will support sorting by VisitCount in the future 48 | KeyPath string `json:"key_path"` 49 | Passphrase string `json:"passphrase"` 50 | Password string `json:"password"` 51 | Tags []string `json:"tags"` 52 | Source string `json:"source"` // Data source, used to distinguish that it is from ssx stored or local ssh configuration 53 | CreateAt time.Time `json:"create_at"` 54 | UpdateAt time.Time `json:"update_at"` 55 | Proxy *Proxy `json:"proxy"` 56 | } 57 | 58 | func (e *Entry) String() string { 59 | return fmt.Sprintf("%s@%s:%s", e.User, e.Host, e.Port) 60 | } 61 | 62 | func (e *Entry) Address() string { 63 | return net.JoinHostPort(e.Host, e.Port) 64 | } 65 | 66 | func (e *Entry) JSON() ([]byte, error) { 67 | entryCopy, err := e.Copy() 68 | if err != nil { 69 | return nil, err 70 | } 71 | 72 | entryCopy.Mask() 73 | return json.MarshalIndent(entryCopy, "", " ") 74 | } 75 | 76 | func (e *Entry) Copy() (*Entry, error) { 77 | entryCopy := &Entry{} 78 | if err := copier.Copy(entryCopy, e); err != nil { 79 | return nil, err 80 | } 81 | return entryCopy, nil 82 | } 83 | 84 | func (e *Entry) Mask() { 85 | e.Password = utils.MaskString(e.Password) 86 | e.Passphrase = utils.MaskString(e.Passphrase) 87 | if e.Proxy != nil { 88 | e.Proxy.Mask() 89 | } 90 | } 91 | 92 | func (e *Entry) ClearPassword() { 93 | e.Password = "" 94 | if e.Proxy != nil { 95 | e.Proxy.ClearPassword() 96 | } 97 | } 98 | 99 | func (e *Entry) KeyFileAbsPath() string { 100 | return utils.ExpandHomeDir(e.KeyPath) 101 | } 102 | 103 | func getConnectTimeout() time.Duration { 104 | var defaultTimeout = time.Second * 10 105 | val := os.Getenv(env.SSXConnectTimeout) 106 | if len(val) <= 0 { 107 | return defaultTimeout 108 | } 109 | d, err := time.ParseDuration(val) 110 | if err != nil { 111 | lg.Debug("invalid %q value: %q", env.SSXConnectTimeout, val) 112 | d = defaultTimeout 113 | } 114 | return d 115 | } 116 | 117 | func (e *Entry) GenSSHConfig(ctx context.Context) (*ssh.ClientConfig, error) { 118 | cb, err := sshHostKeyCallback() 119 | if err != nil { 120 | return nil, err 121 | } 122 | auths, err := e.AuthMethods(ctx) 123 | if err != nil { 124 | return nil, err 125 | } 126 | cfg := &ssh.ClientConfig{ 127 | User: e.User, 128 | Auth: auths, 129 | HostKeyCallback: cb, 130 | Timeout: getConnectTimeout(), 131 | } 132 | cfg.SetDefaults() 133 | return cfg, nil 134 | } 135 | 136 | func sshHostKeyCallback() (ssh.HostKeyCallback, error) { 137 | khPath := utils.ExpandHomeDir("~/.ssh/known_hosts") 138 | if !utils.FileExists(khPath) { 139 | f, err := os.OpenFile(khPath, os.O_RDWR|os.O_CREATE, 0600) 140 | if err != nil { 141 | return nil, err 142 | } 143 | _ = f.Close() 144 | } 145 | kh, err := knownhosts.New(khPath) 146 | if err != nil { 147 | lg.Error("failed to read known_hosts: %s", err) 148 | return nil, err 149 | } 150 | // Create a custom permissive hostkey callback which still errors on hosts 151 | // with changed keys, but allows unknown hosts and adds them to known_hosts 152 | cb := ssh.HostKeyCallback(func(hostname string, remote net.Addr, key ssh.PublicKey) error { 153 | err := kh(hostname, remote, key) 154 | if knownhosts.IsHostKeyChanged(err) { 155 | lg.Error("REMOTE HOST IDENTIFICATION HAS CHANGED for host %s! This may indicate a MitM attack.", hostname) 156 | return errors.Errorf("host key changed for host %s", hostname) 157 | } else if knownhosts.IsHostUnknown(err) { 158 | f, ferr := os.OpenFile(khPath, os.O_APPEND|os.O_WRONLY, 0600) 159 | if ferr == nil { 160 | defer f.Close() 161 | ferr = knownhosts.WriteKnownHost(f, hostname, remote, key) 162 | } 163 | if ferr == nil { 164 | lg.Info("added host %s to known_hosts", hostname) 165 | } else { 166 | lg.Warn("failed to add host %s to known_hosts: %v", hostname, ferr) 167 | } 168 | return nil 169 | } 170 | return err 171 | }) 172 | return cb, nil 173 | } 174 | 175 | // Tidy performs cleanup and validation on the Entry struct. 176 | func (e *Entry) Tidy() error { 177 | if len(e.User) <= 0 { 178 | e.User = defaultUser 179 | } 180 | if len(e.Port) <= 0 { 181 | e.Port = defaultPort 182 | } 183 | if e.KeyPath == "" && utils.FileExists(defaultIdentityFile) { 184 | e.KeyPath = defaultIdentityFile 185 | } 186 | if e.Proxy != nil { 187 | e.Proxy.tidy() 188 | } 189 | return nil 190 | } 191 | 192 | // AuthMethods all possible auth methods 193 | func (e *Entry) AuthMethods(ctx context.Context) ([]ssh.AuthMethod, error) { 194 | var authMethods []ssh.AuthMethod 195 | // password auth 196 | if e.Password != "" { 197 | authMethods = append(authMethods, ssh.Password(e.Password)) 198 | } 199 | 200 | // key file auth methods 201 | keyfileAuths, err := e.privateKeyAuthMethods(ctx) 202 | if err != nil { 203 | return nil, err 204 | } 205 | if len(keyfileAuths) > 0 { 206 | authMethods = append(authMethods, keyfileAuths...) 207 | } 208 | authMethods = append(authMethods, passwordCallback(ctx, e.User, e.Host, func(password string) { e.Password = password })) 209 | return authMethods, nil 210 | } 211 | 212 | func passwordCallback(ctx context.Context, user, host string, storePassFunc func(password string)) ssh.AuthMethod { 213 | prompt := func() (string, error) { 214 | lg.Debug("login through password callback") 215 | fmt.Printf("%s@%s's password:", user, host) 216 | bs, readErr := terminal.ReadPassword(ctx) 217 | fmt.Println() 218 | if readErr != nil { 219 | return "", readErr 220 | } 221 | p := string(bs) 222 | if storePassFunc != nil { 223 | storePassFunc(p) 224 | } 225 | return p, nil 226 | } 227 | return ssh.PasswordCallback(prompt) 228 | } 229 | 230 | // At present, I do not know how to correctly capture password information, 231 | // so I need to write promt by myself through passwordCallback to achieve it 232 | // func interactAuth(ctx context.Context, who string) ssh.AuthMethod { 233 | // return ssh.KeyboardInteractive(func(name, instruction string, questions []string, echos []bool) (answers []string, err error) { 234 | // answers = make([]string, 0, len(questions)) 235 | // for i, q := range questions { 236 | // fmt.Printf("[%s] %s", who, q) 237 | // if echos[i] { 238 | // scan := bufio.NewScanner(os.Stdin) 239 | // if scan.Scan() { 240 | // answers = append(answers, scan.Text()) 241 | // } 242 | // if err := scan.Err(); err != nil { 243 | // return nil, err 244 | // } 245 | // } else { 246 | // b, err := terminal.ReadPassword(ctx) 247 | // if err != nil { 248 | // return nil, err 249 | // } 250 | // fmt.Println() 251 | // answers = append(answers, string(b)) 252 | // } 253 | // } 254 | // return answers, nil 255 | // }) 256 | // } 257 | 258 | func (e *Entry) privateKeyAuthMethods(ctx context.Context) ([]ssh.AuthMethod, error) { 259 | keyfiles := e.collectKeyfiles() 260 | if len(keyfiles) == 0 { 261 | return nil, nil 262 | } 263 | var methods []ssh.AuthMethod 264 | for _, f := range keyfiles { 265 | if !utils.FileExists(f) { 266 | lg.Debug("keyfile %s not found, skip", f) 267 | continue 268 | } 269 | auth, err := e.keyfileAuth(ctx, f) 270 | if err != nil { 271 | lg.Debug("skip use keyfile: %s", f) 272 | continue 273 | } 274 | if auth != nil { 275 | methods = append(methods, auth) 276 | } 277 | } 278 | return methods, nil 279 | } 280 | 281 | func (e *Entry) keyfileAuth(ctx context.Context, keypath string) (ssh.AuthMethod, error) { 282 | lg.Debug("parsing key file: %s", keypath) 283 | pemBytes, err := os.ReadFile(keypath) 284 | if err != nil { 285 | lg.Error("failed to read file %q: %s", keypath, err) 286 | return nil, err 287 | } 288 | var signer ssh.Signer 289 | signer, err = ssh.ParsePrivateKey(pemBytes) 290 | passphraseMissingError := &ssh.PassphraseMissingError{} 291 | if err != nil { 292 | if keypath != e.KeyFileAbsPath() { 293 | lg.Debug("parse failed, ignore keyfile %q", keypath) 294 | return nil, err 295 | } 296 | if errors.As(err, &passphraseMissingError) { 297 | if e.Passphrase != "" { 298 | signer, err = ssh.ParsePrivateKeyWithPassphrase(pemBytes, []byte(e.Passphrase)) 299 | } else { 300 | fmt.Print("please enter passphrase of key file:") 301 | bs, readErr := terminal.ReadPassword(ctx) 302 | fmt.Println() 303 | if readErr != nil { 304 | return nil, readErr 305 | } 306 | // write back to entry instance 307 | e.Passphrase = string(bs) 308 | signer, err = ssh.ParsePrivateKeyWithPassphrase(pemBytes, bs) 309 | } 310 | } 311 | } 312 | if err != nil { 313 | lg.Error("failed to parse private key file: %s", err) 314 | return nil, err 315 | } 316 | return ssh.PublicKeys(signer), nil 317 | } 318 | 319 | // defaultRSAKeyFiles List of possible key files 320 | // The order of the list represents the priority 321 | var defaultRSAKeyFiles = []string{ 322 | "id_rsa", "id_ecdsa", "id_ecdsa_sk", 323 | "id_ed25519", "id_ed25519_sk", 324 | } 325 | 326 | func (e *Entry) collectKeyfiles() []string { 327 | var keypaths []string 328 | if e.KeyPath != "" && utils.FileExists(e.KeyPath) { 329 | keypaths = append(keypaths, e.KeyFileAbsPath()) 330 | } 331 | u, err := user.Current() 332 | if err != nil { 333 | lg.Debug("failed to get current user, ignore default rsa keys") 334 | return keypaths 335 | } 336 | for _, fn := range defaultRSAKeyFiles { 337 | fp := filepath.Join(u.HomeDir, ".ssh", fn) 338 | if fp == utils.ExpandHomeDir(e.KeyPath) || !utils.FileExists(fp) { 339 | continue 340 | } 341 | keypaths = append(keypaths, fp) 342 | } 343 | return keypaths 344 | } 345 | -------------------------------------------------------------------------------- /ssx/cp.go: -------------------------------------------------------------------------------- 1 | package ssx 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "os" 8 | "path/filepath" 9 | "regexp" 10 | "strings" 11 | 12 | scp "github.com/bramvdbogaerde/go-scp" 13 | "github.com/pkg/errors" 14 | "golang.org/x/crypto/ssh" 15 | 16 | "github.com/vimiix/ssx/internal/lg" 17 | "github.com/vimiix/ssx/internal/utils" 18 | "github.com/vimiix/ssx/ssx/entry" 19 | ) 20 | 21 | // CpPath represents a parsed path (local or remote) 22 | type CpPath struct { 23 | IsRemote bool 24 | User string 25 | Host string 26 | Port string 27 | Path string 28 | Entry *entry.Entry // resolved entry for remote path 29 | RawKeyword string // original keyword/tag used 30 | } 31 | 32 | // remotePathRegex matches [user@]host[:port]:/path format 33 | // Examples: root@192.168.1.1:/tmp/file, user@host:22:/path 34 | // Host must contain a dot (domain) or be an IP address to be recognized as a real host 35 | var remotePathRegex = regexp.MustCompile(`^(?:(?P[\w.\-_]+)@)?(?P[\w.\-]+)(?::(?P\d+))?:(?P.+)$`) 36 | 37 | // ipOrDomainRegex checks if a string looks like an IP address or domain name 38 | var ipOrDomainRegex = regexp.MustCompile(`^(\d{1,3}\.){1,3}\d{1,3}$|\.`) 39 | 40 | // ParseCpPath parses a path string into CpPath struct 41 | // It determines if the path is local or remote based on the format 42 | func ParseCpPath(pathStr string) *CpPath { 43 | // Try to match remote path format 44 | matches := remotePathRegex.FindStringSubmatch(pathStr) 45 | if len(matches) > 0 { 46 | user, host, port, path := matches[1], matches[2], matches[3], matches[4] 47 | 48 | // If host looks like an IP or domain (contains dot), treat as real host 49 | // Otherwise, if no user specified and no port, it might be a tag 50 | if ipOrDomainRegex.MatchString(host) || user != "" || port != "" { 51 | return &CpPath{ 52 | IsRemote: true, 53 | User: user, 54 | Host: host, 55 | Port: port, 56 | Path: path, 57 | } 58 | } 59 | 60 | // Looks like a tag/keyword (e.g., myserver:/path) 61 | return &CpPath{ 62 | IsRemote: true, 63 | RawKeyword: host, 64 | Path: path, 65 | } 66 | } 67 | 68 | // Check if it's a tag/keyword:path format (e.g., myserver:/path) 69 | if idx := strings.Index(pathStr, ":"); idx > 0 { 70 | // Make sure it's not a Windows absolute path like C:\ 71 | prefix := pathStr[:idx] 72 | suffix := pathStr[idx+1:] 73 | // If prefix doesn't look like a drive letter and suffix starts with / 74 | if len(prefix) > 1 && (strings.HasPrefix(suffix, "/") || strings.HasPrefix(suffix, "~")) { 75 | return &CpPath{ 76 | IsRemote: true, 77 | RawKeyword: prefix, 78 | Path: suffix, 79 | } 80 | } 81 | } 82 | 83 | // It's a local path 84 | return &CpPath{ 85 | IsRemote: false, 86 | Path: pathStr, 87 | } 88 | } 89 | 90 | // CpOption holds options for cp command 91 | type CpOption struct { 92 | Source string 93 | Target string 94 | IdentityFile string 95 | JumpServers string 96 | Port int 97 | Recursive bool 98 | } 99 | 100 | // Copy performs file copy between local and remote, or remote to remote 101 | func (s *SSX) Copy(ctx context.Context, opt *CpOption) error { 102 | srcPath := ParseCpPath(opt.Source) 103 | dstPath := ParseCpPath(opt.Target) 104 | 105 | // Local to local: not supported 106 | if !srcPath.IsRemote && !dstPath.IsRemote { 107 | return errors.New("local to local copy should use system cp command") 108 | } 109 | 110 | // Remote to remote: stream transfer through local 111 | if srcPath.IsRemote && dstPath.IsRemote { 112 | return s.copyRemoteToRemote(ctx, srcPath, dstPath, opt) 113 | } 114 | 115 | // Local to remote or remote to local 116 | var ( 117 | remotePath *CpPath 118 | localPath string 119 | isUpload bool 120 | ) 121 | 122 | if srcPath.IsRemote { 123 | remotePath = srcPath 124 | localPath = dstPath.Path 125 | isUpload = false 126 | } else { 127 | remotePath = dstPath 128 | localPath = srcPath.Path 129 | isUpload = true 130 | } 131 | 132 | // Resolve remote entry 133 | e, err := s.resolveRemotePath(remotePath, opt) 134 | if err != nil { 135 | return errors.Wrap(err, "failed to resolve remote path") 136 | } 137 | remotePath.Entry = e 138 | 139 | // Create SSH client and connect 140 | client := NewClient(e, s.repo) 141 | if err := client.Login(ctx); err != nil { 142 | return errors.Wrap(err, "failed to connect to remote host") 143 | } 144 | defer client.close() 145 | 146 | // Create SCP client from existing SSH connection 147 | scpClient, err := scp.NewClientBySSH(client.cli) 148 | if err != nil { 149 | return errors.Wrap(err, "failed to create SCP client") 150 | } 151 | 152 | if isUpload { 153 | return s.upload(ctx, scpClient, client.cli, localPath, remotePath) 154 | } 155 | return s.download(ctx, scpClient, remotePath, localPath) 156 | } 157 | 158 | // copyRemoteToRemote copies file from one remote host to another via streaming 159 | // The file is streamed through local without being stored on disk 160 | func (s *SSX) copyRemoteToRemote(ctx context.Context, srcPath, dstPath *CpPath, opt *CpOption) error { 161 | // Resolve source entry 162 | srcEntry, err := s.resolveRemotePath(srcPath, opt) 163 | if err != nil { 164 | return errors.Wrap(err, "failed to resolve source remote path") 165 | } 166 | srcPath.Entry = srcEntry 167 | 168 | // Resolve destination entry 169 | dstEntry, err := s.resolveRemotePath(dstPath, opt) 170 | if err != nil { 171 | return errors.Wrap(err, "failed to resolve destination remote path") 172 | } 173 | dstPath.Entry = dstEntry 174 | 175 | lg.Info("copying %s:%s -> %s:%s (streaming)", srcEntry.Address(), srcPath.Path, dstEntry.Address(), dstPath.Path) 176 | 177 | // Connect to source host 178 | srcClient := NewClient(srcEntry, s.repo) 179 | if err := srcClient.Login(ctx); err != nil { 180 | return errors.Wrap(err, "failed to connect to source host") 181 | } 182 | defer srcClient.close() 183 | 184 | // Connect to destination host 185 | dstClient := NewClient(dstEntry, s.repo) 186 | if err := dstClient.Login(ctx); err != nil { 187 | return errors.Wrap(err, "failed to connect to destination host") 188 | } 189 | defer dstClient.close() 190 | 191 | // Get file info from source (size and permissions) 192 | fileInfo, err := getRemoteFileInfo(srcClient.cli, srcPath.Path) 193 | if err != nil { 194 | return errors.Wrap(err, "failed to get source file info") 195 | } 196 | 197 | lg.Debug("source file size: %d, mode: %s", fileInfo.size, fileInfo.mode) 198 | 199 | // Resolve destination path (handle directory case) 200 | finalDstPath, err := resolveRemoteDestPath(dstClient.cli, dstPath.Path, filepath.Base(srcPath.Path)) 201 | if err != nil { 202 | return errors.Wrap(err, "failed to resolve destination path") 203 | } 204 | 205 | // Create pipe for streaming 206 | pr, pw := io.Pipe() 207 | 208 | // Error channels for goroutines 209 | downloadErrCh := make(chan error, 1) 210 | uploadErrCh := make(chan error, 1) 211 | 212 | // Start download goroutine (source -> pipe) 213 | go func() { 214 | defer pw.Close() 215 | scpSrc, err := scp.NewClientBySSH(srcClient.cli) 216 | if err != nil { 217 | downloadErrCh <- errors.Wrap(err, "failed to create source SCP client") 218 | return 219 | } 220 | err = scpSrc.CopyFromRemotePassThru(ctx, pw, srcPath.Path, nil) 221 | downloadErrCh <- err 222 | }() 223 | 224 | // Start upload goroutine (pipe -> destination) 225 | go func() { 226 | scpDst, err := scp.NewClientBySSH(dstClient.cli) 227 | if err != nil { 228 | uploadErrCh <- errors.Wrap(err, "failed to create destination SCP client") 229 | return 230 | } 231 | err = scpDst.CopyFile(ctx, pr, finalDstPath, fileInfo.mode) 232 | uploadErrCh <- err 233 | }() 234 | 235 | // Wait for both operations to complete 236 | var downloadErr, uploadErr error 237 | for i := 0; i < 2; i++ { 238 | select { 239 | case downloadErr = <-downloadErrCh: 240 | if downloadErr != nil { 241 | pr.Close() // Signal upload to stop 242 | } 243 | case uploadErr = <-uploadErrCh: 244 | if uploadErr != nil { 245 | pw.Close() // Signal download to stop 246 | } 247 | case <-ctx.Done(): 248 | return ctx.Err() 249 | } 250 | } 251 | 252 | if downloadErr != nil { 253 | return errors.Wrap(downloadErr, "failed to download from source") 254 | } 255 | if uploadErr != nil { 256 | return errors.Wrap(uploadErr, "failed to upload to destination") 257 | } 258 | 259 | lg.Info("remote to remote copy completed successfully") 260 | return nil 261 | } 262 | 263 | // remoteFileInfo holds basic file information from remote host 264 | type remoteFileInfo struct { 265 | size int64 266 | mode string 267 | } 268 | 269 | // getRemoteFileInfo gets file size and permissions from remote host via SSH 270 | func getRemoteFileInfo(client *ssh.Client, remotePath string) (*remoteFileInfo, error) { 271 | session, err := client.NewSession() 272 | if err != nil { 273 | return nil, err 274 | } 275 | defer session.Close() 276 | 277 | // Use stat command to get file info 278 | // Output format: size mode (e.g., "1234 0644") 279 | cmd := fmt.Sprintf(`stat -c '%%s %%a' '%s' 2>/dev/null || stat -f '%%z %%Lp' '%s'`, remotePath, remotePath) 280 | output, err := session.Output(cmd) 281 | if err != nil { 282 | return nil, errors.Wrapf(err, "failed to stat remote file %s", remotePath) 283 | } 284 | 285 | var size int64 286 | var mode string 287 | _, err = fmt.Sscanf(strings.TrimSpace(string(output)), "%d %s", &size, &mode) 288 | if err != nil { 289 | return nil, errors.Wrap(err, "failed to parse stat output") 290 | } 291 | 292 | // Ensure mode is 4 digits 293 | if len(mode) < 4 { 294 | mode = "0" + mode 295 | } 296 | 297 | return &remoteFileInfo{size: size, mode: mode}, nil 298 | } 299 | 300 | // isRemoteDir checks if a remote path is a directory 301 | func isRemoteDir(client *ssh.Client, remotePath string) (bool, error) { 302 | session, err := client.NewSession() 303 | if err != nil { 304 | return false, err 305 | } 306 | defer session.Close() 307 | 308 | // Use test -d to check if path is a directory 309 | cmd := fmt.Sprintf(`test -d '%s' && echo "dir" || echo "notdir"`, remotePath) 310 | output, err := session.Output(cmd) 311 | if err != nil { 312 | return false, nil // Path doesn't exist, treat as file 313 | } 314 | 315 | return strings.TrimSpace(string(output)) == "dir", nil 316 | } 317 | 318 | // resolveRemoteDestPath resolves the final destination path on remote host 319 | // If destPath is a directory, appends srcFileName to it 320 | func resolveRemoteDestPath(client *ssh.Client, destPath, srcFileName string) (string, error) { 321 | isDir, err := isRemoteDir(client, destPath) 322 | if err != nil { 323 | return "", err 324 | } 325 | 326 | if isDir { 327 | // Destination is a directory, append source filename 328 | return filepath.Join(destPath, srcFileName), nil 329 | } 330 | 331 | // Destination is a file path (or doesn't exist yet) 332 | return destPath, nil 333 | } 334 | 335 | // resolveRemotePath resolves a remote CpPath to an Entry 336 | func (s *SSX) resolveRemotePath(cp *CpPath, opt *CpOption) (*entry.Entry, error) { 337 | // If we have a raw keyword (tag/partial match), search for it 338 | if cp.RawKeyword != "" { 339 | lg.Debug("resolving remote path by keyword: %s", cp.RawKeyword) 340 | e, err := s.searchEntry(cp.RawKeyword) 341 | if err != nil { 342 | return nil, err 343 | } 344 | // Apply identity file if specified 345 | if opt.IdentityFile != "" { 346 | e.KeyPath = opt.IdentityFile 347 | } 348 | return e, nil 349 | } 350 | 351 | // Build address string for search 352 | addr := cp.Host 353 | if cp.User != "" { 354 | addr = cp.User + "@" + addr 355 | } 356 | if cp.Port != "" { 357 | addr = addr + ":" + cp.Port 358 | } 359 | 360 | lg.Debug("resolving remote path by address: %s", addr) 361 | 362 | // Try to find existing entry or create new one 363 | e, err := s.searchEntry(addr) 364 | if err != nil { 365 | return nil, err 366 | } 367 | 368 | // Apply identity file if specified 369 | if opt.IdentityFile != "" { 370 | e.KeyPath = opt.IdentityFile 371 | } 372 | 373 | return e, nil 374 | } 375 | 376 | // upload copies a local file to remote host 377 | func (s *SSX) upload(ctx context.Context, scpClient scp.Client, sshClient *ssh.Client, localPath string, remotePath *CpPath) error { 378 | localPath = utils.ExpandHomeDir(localPath) 379 | 380 | // Check if local file exists 381 | fileInfo, err := os.Stat(localPath) 382 | if err != nil { 383 | return errors.Wrapf(err, "failed to stat local file %s", localPath) 384 | } 385 | 386 | if fileInfo.IsDir() { 387 | return errors.New("directory upload is not supported yet, please use tar to archive first") 388 | } 389 | 390 | // Open local file 391 | f, err := os.Open(localPath) 392 | if err != nil { 393 | return errors.Wrapf(err, "failed to open local file %s", localPath) 394 | } 395 | defer f.Close() 396 | 397 | // Get file permission 398 | perm := fmt.Sprintf("%04o", fileInfo.Mode().Perm()) 399 | 400 | // Resolve destination path (handle directory case) 401 | finalRemotePath, err := resolveRemoteDestPath(sshClient, remotePath.Path, filepath.Base(localPath)) 402 | if err != nil { 403 | return errors.Wrap(err, "failed to resolve remote destination path") 404 | } 405 | 406 | lg.Info("uploading %s -> %s:%s", localPath, remotePath.Entry.Address(), finalRemotePath) 407 | 408 | // Copy file to remote 409 | err = scpClient.CopyFromFile(ctx, *f, finalRemotePath, perm) 410 | if err != nil { 411 | return errors.Wrap(err, "failed to upload file") 412 | } 413 | 414 | lg.Info("upload completed successfully") 415 | return nil 416 | } 417 | 418 | // download copies a remote file to local 419 | func (s *SSX) download(ctx context.Context, scpClient scp.Client, remotePath *CpPath, localPath string) error { 420 | localPath = utils.ExpandHomeDir(localPath) 421 | 422 | // Resolve local destination path (handle directory case) 423 | localInfo, err := os.Stat(localPath) 424 | if err == nil && localInfo.IsDir() { 425 | // Local path is a directory, append source filename 426 | localPath = filepath.Join(localPath, filepath.Base(remotePath.Path)) 427 | } 428 | 429 | // Create local file 430 | f, err := os.Create(localPath) 431 | if err != nil { 432 | return errors.Wrapf(err, "failed to create local file %s", localPath) 433 | } 434 | defer f.Close() 435 | 436 | lg.Info("downloading %s:%s -> %s", remotePath.Entry.Address(), remotePath.Path, localPath) 437 | 438 | // Copy file from remote 439 | err = scpClient.CopyFromRemote(ctx, f, remotePath.Path) 440 | if err != nil { 441 | // Clean up partial file on error 442 | f.Close() 443 | os.Remove(localPath) 444 | return errors.Wrap(err, "failed to download file") 445 | } 446 | 447 | lg.Info("download completed successfully") 448 | return nil 449 | } 450 | --------------------------------------------------------------------------------