├── .gitignore ├── LICENSE ├── README.md ├── cmd └── cli2ssh │ └── main.go ├── go.mod ├── go.sum └── internal ├── args └── args.go ├── path └── path.go ├── server └── server.go ├── set └── set.go └── utils └── key.go /.gitignore: -------------------------------------------------------------------------------- 1 | /.vscode 2 | cli2ssh 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024 PeronGH 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cli2ssh 2 | 3 | Turn any CLI program into a SSH server. 4 | 5 | ## Installation 6 | 7 | ```bash 8 | go install github.com/PeronGH/cli2ssh/cmd/cli2ssh@latest 9 | ``` 10 | 11 | ## Usage 12 | 13 | ```bash 14 | # Check usage 15 | cli2ssh --help 16 | 17 | # Basic example: echo the username 18 | cli2ssh -c 'echo Hello, {{ .User }}.' 19 | 20 | # More practical example: serve oterm publicly 21 | cli2ssh -h 0.0.0.0 -e 'OTERM_DATA_DIR=userdata/{{ .User }}' -c $(which oterm) 22 | ``` 23 | 24 | ## Use Cases 25 | 26 | - Share a CLI program with someone who doesn't have it installed. 27 | - Publicly host a TUI program, allowing it to be accessed like a web page. 28 | - Let me know if you have any other ideas! 29 | 30 | ```bash 31 | 32 | ## TODO 33 | 34 | - [ ] Authentication 35 | - [ ] Add tests 36 | - [ ] Integrate with GitHub Actions 37 | -------------------------------------------------------------------------------- /cmd/cli2ssh/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "os" 6 | "os/exec" 7 | 8 | "github.com/PeronGH/cli2ssh/internal/args" 9 | "github.com/PeronGH/cli2ssh/internal/path" 10 | "github.com/PeronGH/cli2ssh/internal/server" 11 | "github.com/PeronGH/cli2ssh/internal/utils" 12 | "github.com/charmbracelet/log" 13 | "github.com/charmbracelet/ssh" 14 | ) 15 | 16 | func main() { 17 | command := flag.String("c", "", "Set the command to run for each SSH session.") 18 | var env args.ArrayArg 19 | flag.Var(&env, "e", "Set environment variables for each SSH session.") 20 | useOsEnv := flag.Bool("os-env", false, "Use the OS environment variables for the command, ignoring env passed by user.") 21 | host := flag.String("h", "localhost", "Set the host for the server.") 22 | port := flag.String("p", "2222", "Set the port for the server.") 23 | hostKeyPath := flag.String("k", path.GetDefaultHostKeyPath(), "Set the path to the host key.") 24 | 25 | flag.Parse() 26 | 27 | if *command == "" { 28 | log.Fatal("No command provided.") 29 | } 30 | 31 | srv, err := server.CreateServer(server.CreateServerOptions{ 32 | CommandProvider: func(s ssh.Session) *exec.Cmd { 33 | argSession := args.NewSession(s) 34 | fmtCmd := argSession.FormatArg(*command) 35 | fmtEnv := argSession.FormatArgs(env) 36 | 37 | cmd := exec.CommandContext(s.Context(), "sh", "-c", fmtCmd) 38 | if *useOsEnv { 39 | cmd.Env = os.Environ() 40 | } else { 41 | cmd.Env = s.Environ() 42 | } 43 | cmd.Env = append(cmd.Env, fmtEnv...) 44 | return cmd 45 | }, 46 | 47 | Host: *host, 48 | Port: *port, 49 | HostKeyPath: *hostKeyPath, 50 | 51 | PublicKeyAuth: func(ctx ssh.Context, key ssh.PublicKey) bool { 52 | log.Info("Public key auth", "remote", ctx.RemoteAddr(), "user", ctx.User(), "key", utils.StringifyPublicKey(key)) 53 | return true 54 | }, 55 | PasswordAuth: func(ctx ssh.Context, password string) bool { 56 | log.Info("Password auth", "remote", ctx.RemoteAddr(), "user", ctx.User(), "password", password) 57 | return true 58 | }, 59 | }) 60 | 61 | if err != nil { 62 | log.Fatalf("could not create server: %v", err) 63 | } 64 | 65 | log.Info("Starting server...", "address", srv.Addr) 66 | log.Fatal(srv.ListenAndServe()) 67 | } 68 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/PeronGH/cli2ssh 2 | 3 | go 1.22.0 4 | 5 | require ( 6 | github.com/charmbracelet/log v0.3.1 7 | github.com/charmbracelet/ssh v0.0.0-20240202115812-f4ab1009799a 8 | github.com/charmbracelet/wish v1.3.1 9 | ) 10 | 11 | require ( 12 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect 13 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 14 | github.com/charmbracelet/bubbletea v0.25.0 // indirect 15 | github.com/charmbracelet/keygen v0.5.0 // indirect 16 | github.com/charmbracelet/lipgloss v0.9.1 // indirect 17 | github.com/charmbracelet/x/errors v0.0.0-20240229115032-4b79243a3516 // indirect 18 | github.com/charmbracelet/x/exp/term v0.0.0-20240229115032-4b79243a3516 // indirect 19 | github.com/containerd/console v1.0.4 // indirect 20 | github.com/creack/pty v1.1.21 // indirect 21 | github.com/go-logfmt/logfmt v0.6.0 // indirect 22 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 23 | github.com/mattn/go-isatty v0.0.20 // indirect 24 | github.com/mattn/go-localereader v0.0.1 // indirect 25 | github.com/mattn/go-runewidth v0.0.15 // indirect 26 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 27 | github.com/muesli/cancelreader v0.2.2 // indirect 28 | github.com/muesli/reflow v0.3.0 // indirect 29 | github.com/muesli/termenv v0.15.2 // indirect 30 | github.com/rivo/uniseg v0.4.7 // indirect 31 | golang.org/x/crypto v0.20.0 // indirect 32 | golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 // indirect 33 | golang.org/x/sync v0.6.0 // indirect 34 | golang.org/x/sys v0.17.0 // indirect 35 | golang.org/x/term v0.17.0 // indirect 36 | golang.org/x/text v0.14.0 // indirect 37 | ) 38 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= 2 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= 3 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 4 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 5 | github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM= 6 | github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg= 7 | github.com/charmbracelet/keygen v0.5.0 h1:XY0fsoYiCSM9axkrU+2ziE6u6YjJulo/b9Dghnw6MZc= 8 | github.com/charmbracelet/keygen v0.5.0/go.mod h1:DfvCgLHxZ9rJxdK0DGw3C/LkV4SgdGbnliHcObV3L+8= 9 | github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg= 10 | github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I= 11 | github.com/charmbracelet/log v0.3.1 h1:TjuY4OBNbxmHWSwO3tosgqs5I3biyY8sQPny/eCMTYw= 12 | github.com/charmbracelet/log v0.3.1/go.mod h1:OR4E1hutLsax3ZKpXbgUqPtTjQfrh1pG3zwHGWuuq8g= 13 | github.com/charmbracelet/ssh v0.0.0-20240202115812-f4ab1009799a h1:ryXQeBfu7DN77RFiKLa/VA9VRkMsinpkv4qYparR//k= 14 | github.com/charmbracelet/ssh v0.0.0-20240202115812-f4ab1009799a/go.mod h1:GPT/bjXsVDf5TKq2P1n4zl79ZnGwt2lWr19DomWm7zw= 15 | github.com/charmbracelet/wish v1.3.1 h1:HXrHadxu6qbZOfwjEuu2OWpGWuhpKVd26Ecpv+QEc58= 16 | github.com/charmbracelet/wish v1.3.1/go.mod h1:4GbN5YK/Qmip0k/nT+dOuYVpdKgj3oZBnsOeEJlG9fE= 17 | github.com/charmbracelet/x/errors v0.0.0-20240117030013-d31dba354651/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= 18 | github.com/charmbracelet/x/errors v0.0.0-20240229115032-4b79243a3516 h1:t6MBEAWHeZRMFEIHm5F9I1tzEB69ocEajtgR1SsAWUk= 19 | github.com/charmbracelet/x/errors v0.0.0-20240229115032-4b79243a3516/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= 20 | github.com/charmbracelet/x/exp/term v0.0.0-20240202113029-6ff29cf0473e/go.mod h1:8NVO/XlUZbcJU5g0gVE7K1YiNnRFqYA3nZzGS/0lBRk= 21 | github.com/charmbracelet/x/exp/term v0.0.0-20240229115032-4b79243a3516 h1:wL/PiybPudKHv/LDgAUqS9eoPQr3pOAmzShMPG99cXA= 22 | github.com/charmbracelet/x/exp/term v0.0.0-20240229115032-4b79243a3516/go.mod h1:ntNL6rRIDmBHKUmo6ZKt344wCTcrPsSrfVj72qT8A5U= 23 | github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= 24 | github.com/containerd/console v1.0.4 h1:F2g4+oChYvBTsASRTz8NP6iIAi97J3TtSAsLbIFn4ro= 25 | github.com/containerd/console v1.0.4/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= 26 | github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0= 27 | github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= 28 | github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= 29 | github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= 30 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 31 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 32 | github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 36 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 37 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 38 | github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= 39 | github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 40 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= 41 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 42 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 43 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 44 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 45 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 46 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 47 | github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= 48 | github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= 49 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 50 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 51 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 52 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 53 | golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= 54 | golang.org/x/crypto v0.20.0 h1:jmAMJJZXr5KiCw05dfYK9QnqaqKLYXijU23lsEdcQqg= 55 | golang.org/x/crypto v0.20.0/go.mod h1:Xwo95rrVNIoSMx9wa1JroENMToLWn3RNVrTBpLHgZPQ= 56 | golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= 57 | golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 h1:LfspQV/FYTatPTr/3HzIcmiUFH7PGP+OQ6mgDYo3yuQ= 58 | golang.org/x/exp v0.0.0-20240222234643-814bf88cf225/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc= 59 | golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= 60 | golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 61 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 62 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 63 | golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 64 | golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= 65 | golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 66 | golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= 67 | golang.org/x/term v0.17.0 h1:mkTF7LCd6WGJNL3K1Ad7kwxNfYAW6a8a8QqtMblp/4U= 68 | golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= 69 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 70 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 71 | -------------------------------------------------------------------------------- /internal/args/args.go: -------------------------------------------------------------------------------- 1 | package args 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "net" 7 | "text/template" 8 | 9 | "github.com/PeronGH/cli2ssh/internal/utils" 10 | "github.com/charmbracelet/ssh" 11 | ) 12 | 13 | type Session struct { 14 | User string 15 | Host string 16 | Port string 17 | Command string 18 | Subsystem string 19 | PublicKey string 20 | RemoteAddr string 21 | } 22 | 23 | func NewSession(s ssh.Session) *Session { 24 | user := s.User() 25 | host, port, _ := net.SplitHostPort(s.RemoteAddr().String()) 26 | var publicKey string 27 | if pk := s.PublicKey(); pk != nil { 28 | publicKey = utils.StringifyPublicKey(pk) 29 | } 30 | return &Session{ 31 | User: user, 32 | Host: host, 33 | Port: port, 34 | Command: s.RawCommand(), 35 | Subsystem: s.Subsystem(), 36 | PublicKey: publicKey, 37 | RemoteAddr: s.RemoteAddr().String(), 38 | } 39 | } 40 | 41 | func (s *Session) FormatArg(arg string) string { 42 | return formatTemplate(arg, s) 43 | } 44 | 45 | type ArrayArg []string 46 | 47 | func (a *ArrayArg) String() string { 48 | return fmt.Sprintf("%v", *a) 49 | } 50 | 51 | func (a *ArrayArg) Set(value string) error { 52 | *a = append(*a, value) 53 | return nil 54 | } 55 | 56 | func (s *Session) FormatArgs(args []string) []string { 57 | formatted := make([]string, len(args)) 58 | for i, arg := range args { 59 | formatted[i] = formatTemplate(arg, s) 60 | } 61 | return formatted 62 | } 63 | 64 | func formatTemplate(templateStr string, data any) string { 65 | template, err := template.New("").Parse(templateStr) 66 | if err != nil { 67 | return templateStr 68 | } 69 | 70 | var buf bytes.Buffer 71 | err = template.Execute(&buf, data) 72 | if err != nil { 73 | return templateStr 74 | } 75 | return buf.String() 76 | } 77 | -------------------------------------------------------------------------------- /internal/path/path.go: -------------------------------------------------------------------------------- 1 | package path 2 | 3 | import ( 4 | "os" 5 | "path" 6 | ) 7 | 8 | func GetDefaultHostKeyPath() string { 9 | homeDir, err := os.UserHomeDir() 10 | if err != nil { 11 | return "" 12 | } 13 | 14 | dataDirPath := path.Join(homeDir, ".cli2ssh") 15 | err = os.MkdirAll(dataDirPath, 0700) 16 | if err != nil { 17 | return "" 18 | } 19 | 20 | return path.Join(dataDirPath, "id_ed25519") 21 | } 22 | -------------------------------------------------------------------------------- /internal/server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "net" 8 | "os/exec" 9 | "syscall" 10 | 11 | "github.com/PeronGH/cli2ssh/internal/path" 12 | "github.com/charmbracelet/log" 13 | "github.com/charmbracelet/ssh" 14 | "github.com/charmbracelet/wish" 15 | "github.com/charmbracelet/wish/logging" 16 | ) 17 | 18 | var ( 19 | ErrCommandProviderRequired = errors.New("command provider is required") 20 | ) 21 | 22 | type CreateServerOptions struct { 23 | // Required 24 | 25 | // Always use `exec.CommandContext` to create the command. 26 | CommandProvider func(s ssh.Session) *exec.Cmd 27 | 28 | // Optional 29 | Host string 30 | Port string 31 | HostKeyPath string 32 | 33 | PublicKeyAuth func(ctx ssh.Context, key ssh.PublicKey) bool 34 | PasswordAuth func(ctx ssh.Context, password string) bool 35 | } 36 | 37 | func CreateServer(opts CreateServerOptions) (*ssh.Server, error) { 38 | // Required options 39 | if opts.CommandProvider == nil { 40 | return nil, ErrCommandProviderRequired 41 | } 42 | 43 | // Optional options 44 | if opts.Host == "" { 45 | opts.Host = "localhost" 46 | } 47 | if opts.Port == "" { 48 | opts.Port = "2222" 49 | } 50 | if opts.HostKeyPath == "" { 51 | opts.HostKeyPath = path.GetDefaultHostKeyPath() 52 | } 53 | 54 | return wish.NewServer( 55 | wish.WithAddress(net.JoinHostPort(opts.Host, opts.Port)), 56 | wish.WithHostKeyPath(opts.HostKeyPath), 57 | ssh.PublicKeyAuth(opts.PublicKeyAuth), 58 | ssh.PasswordAuth(opts.PasswordAuth), 59 | ssh.AllocatePty(), 60 | wish.WithMiddleware( 61 | func(next ssh.Handler) ssh.Handler { 62 | return func(s ssh.Session) { 63 | defer next(s) 64 | 65 | cmd := opts.CommandProvider(s) 66 | if cmd == nil { 67 | wish.Fatalln(s, "your session has no command to execute.") 68 | return 69 | } 70 | 71 | pty, _, hasPty := s.Pty() 72 | 73 | log.Info("Executing command", "remote", s.RemoteAddr(), "command", cmd, "pty", hasPty) 74 | if hasPty { 75 | cmd.Env = append(cmd.Env, fmt.Sprintf("TERM=%s", pty.Term)) 76 | cmd.Stdin = pty.Slave 77 | cmd.Stdout = pty.Slave 78 | cmd.Stderr = pty.Slave 79 | cmd.SysProcAttr = &syscall.SysProcAttr{ 80 | Setctty: true, 81 | Setsid: true, 82 | } 83 | } else { 84 | cmd.Env = append(cmd.Env, "TERM=dumb") 85 | 86 | if err := pipeStdio(cmd, s, s, s.Stderr()); err != nil { 87 | log.Error("Failed to pipe stdio", "remote", s.RemoteAddr(), "error", err) 88 | wish.Fatalln(s, "Failed to pipe stdio:", err) 89 | return 90 | } 91 | } 92 | 93 | defer s.Exit(cmd.ProcessState.ExitCode()) 94 | if err := cmd.Run(); err != nil { 95 | if exitErr, ok := err.(*exec.ExitError); ok { 96 | log.Warn("Command exited with status", "remote", s.RemoteAddr(), "command", cmd, "status", exitErr.ExitCode()) 97 | } else { 98 | log.Error("Failed to run the command", "remote", s.RemoteAddr(), "command", cmd, "error", err) 99 | wish.Fatalln(s, "Failed to run the command:", err) 100 | } 101 | } 102 | } 103 | }, 104 | logging.Middleware(), 105 | ), 106 | ) 107 | } 108 | 109 | // workaround for command hanging 110 | func pipeStdio(cmd *exec.Cmd, stdin io.Reader, stdout, stderr io.Writer) error { 111 | cmdStdin, err := cmd.StdinPipe() 112 | if err != nil { 113 | return err 114 | } 115 | 116 | cmdStdout, err := cmd.StdoutPipe() 117 | if err != nil { 118 | return err 119 | } 120 | 121 | cmdStderr, err := cmd.StderrPipe() 122 | if err != nil { 123 | return err 124 | } 125 | 126 | go io.Copy(cmdStdin, stdin) 127 | go io.Copy(stdout, cmdStdout) 128 | go io.Copy(stderr, cmdStderr) 129 | 130 | return nil 131 | } 132 | -------------------------------------------------------------------------------- /internal/set/set.go: -------------------------------------------------------------------------------- 1 | package set 2 | 3 | type Set[T comparable] struct { 4 | items map[T]struct{} 5 | } 6 | 7 | func New[T comparable]() *Set[T] { 8 | return &Set[T]{items: make(map[T]struct{})} 9 | } 10 | 11 | // Adds an element to the set 12 | func (s *Set[T]) Add(item T) { 13 | s.items[item] = struct{}{} 14 | } 15 | 16 | // Create a new set from a slice 17 | func NewFromSlice[T comparable](items []T) *Set[T] { 18 | s := New[T]() 19 | for _, item := range items { 20 | s.Add(item) 21 | } 22 | return s 23 | } 24 | 25 | // Checks if an element exists in the set 26 | func (s *Set[T]) Has(item T) bool { 27 | _, exists := s.items[item] 28 | return exists 29 | } 30 | 31 | // Removes an element from the set 32 | func (s *Set[T]) Remove(item T) { 33 | delete(s.items, item) 34 | } 35 | 36 | // Returns the size of the set 37 | func (s *Set[T]) Size() int { 38 | return len(s.items) 39 | } 40 | 41 | // Returns true if the set is empty 42 | func (s *Set[T]) IsEmpty() bool { 43 | return s.Size() == 0 44 | } 45 | -------------------------------------------------------------------------------- /internal/utils/key.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "encoding/base64" 5 | 6 | "github.com/charmbracelet/ssh" 7 | ) 8 | 9 | func StringifyPublicKey(key ssh.PublicKey) string { 10 | return key.Type() + " " + base64.StdEncoding.EncodeToString(key.Marshal()) 11 | } 12 | --------------------------------------------------------------------------------