├── .github └── workflows │ └── build.yml ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── SECURITY.md ├── cmd └── ziina │ └── main.go ├── go.mod ├── go.sum └── main.go /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build and Release Ziina 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | tags: 7 | - 'v*' 8 | pull_request: 9 | workflow_dispatch: 10 | 11 | permissions: 12 | contents: write # Needed to create releases 13 | 14 | jobs: 15 | build: 16 | name: Build for ${{ matrix.goos }} ${{ matrix.goarch }} 17 | runs-on: ubuntu-latest 18 | 19 | strategy: 20 | matrix: 21 | goos: [linux, darwin] 22 | goarch: [amd64, arm64] 23 | 24 | steps: 25 | - name: Checkout code 26 | uses: actions/checkout@v4 27 | 28 | - name: Set up Go 29 | uses: actions/setup-go@v5 30 | with: 31 | go-version: '1.24.2' 32 | 33 | - name: Build binary 34 | env: 35 | GOOS: ${{ matrix.goos }} 36 | GOARCH: ${{ matrix.goarch }} 37 | run: | 38 | mkdir -p dist 39 | BIN_NAME="ziina-${GOOS}-${GOARCH}" 40 | GOOS=${GOOS} GOARCH=${GOARCH} go build -o dist/$BIN_NAME . 41 | echo "BIN_NAME=$BIN_NAME" >> $GITHUB_ENV 42 | 43 | - name: Upload artifact 44 | uses: actions/upload-artifact@v4 45 | with: 46 | name: ${{ env.BIN_NAME }} 47 | path: dist/${{ env.BIN_NAME }} 48 | 49 | release: 50 | name: Create GitHub Release 51 | needs: build 52 | if: startsWith(github.ref, 'refs/tags/') 53 | runs-on: ubuntu-latest 54 | 55 | steps: 56 | - name: Download artifacts 57 | uses: actions/download-artifact@v4 58 | with: 59 | path: artifacts 60 | 61 | - name: Create Release 62 | uses: softprops/action-gh-release@v2 63 | with: 64 | name: Ziina ${{ github.ref_name }} 65 | tag_name: ${{ github.ref_name }} 66 | files: artifacts/**/* 67 | env: 68 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 69 | 70 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Ziina 2 | 3 | Thanks for your interest in contributing to Ziina 🎉 4 | We welcome all kinds of contributions — code, documentation, bug reports, feature requests, and more. 5 | 6 | ## 🚀 How to Contribute 7 | 8 | 1. Fork the Repository 9 | 10 | Start by [forking the repository](https://github.com/ziinaio/ziina/fork). 11 | This will create your own copy where you can make changes. 12 | 13 | 2. Clone Your Fork 14 | 15 | ``` 16 | git clone https://github.com/your-username/ziina.git 17 | cd ziina 18 | ``` 19 | 20 | 3. Create a Branch 21 | 22 | Use a meaningful branch name related to your change. 23 | 24 | ``` 25 | git checkout -b fix/bug-name 26 | git checkout -b feature/bug-name 27 | ``` 28 | 29 | 4. Make Your Changes 30 | 31 | Edit the code, improve documentation, add tests — whatever your contribution is. 32 | 33 | 5. Run Usage-Tests (If Applicable) 34 | 35 | Make sure everything works. 36 | 37 | 6. Commit Changes 38 | 39 | Write clear and descriptive commit messages. 40 | Use [Gitmojis](https://gitmoji.dev/) to improve readability of your commit messages. 41 | 42 | ``` 43 | git add . 44 | git commit -m "🩹 Resolved issue with X" 45 | ``` 46 | 47 | 7. Push and Create a Pull Request 48 | 49 | ``` 50 | git push origin your-branch-name 51 | ``` 52 | 53 | Then create a [pull request (PR)](https://github.com/ziinaio/ziina/compare). 54 | Please fill out the PR template if one is provided. 55 | 56 | ## 🙌 Guidelines 57 | 58 | - Follow the code style of the project. 59 | - Use clear and concise commit messages. 60 | - If adding new features, please add or update relevant tests and documentation. 61 | - Be respectful and inclusive in all interactions. Basic human decency is key. 62 | 63 | ## 🐛 Found a Bug? 64 | 65 | - Check existing issues to see if it's already reported. 66 | - If not, open a new issue with as much detail as possible. 67 | 68 | ## 💡 Have an Idea? 69 | 70 | We'd love to hear it! Open a discussion or an issue and let's collaborate. 71 | 72 | ## 💬 Need Help? 73 | 74 | Feel free to ask questions in issues or discussions. We’re here to help. 75 | 76 | Thanks for helping make Ziina better! ✨ 77 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Zellij contributors 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 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ziina 2 | 3 | 💻 📤 👥 Instant terminal sharing; using Zellij. 4 | 5 | [![asciicast](https://asciinema.org/a/zW01wIIslDnGpIm1e03AEUpuY.svg)](https://asciinema.org/a/zW01wIIslDnGpIm1e03AEUpuY) 6 | 7 | Ziina lets you invite peers into a local [Zellij](https://github.com/zellij-org/zellij) session over untrusted networks, despite you being behind a NAT gateway. 8 | It is heavily inspired by [tmate](https://github.com/tmate-io/tmate). 9 | 10 | Ziina is (basically) server-less. 11 | You only need a standard OpenSSH server with a public IP that serves as an entry-point for your peers. 12 | Your peers only need a standard OpenSSH client. 13 | 14 | ## How does it work? 15 | 16 | Ziina configures an ephemeral SSH remote port-forwarding tunnel on your public SSH server, pointing back to a local high-port. 17 | It then starts a minimal SSH server on that local high-port, that throws connecting clients directly into a Zellij session. 18 | Peers connecting to the high-port on your server via SSH are forwarded through the tunnel directly into your local Zellij session. 19 | Once the host terminates Ziina (by closing the Zellij session), the remote port-forwarding tunnel and internal SSH server are terminated and all peers automatically kicked. 20 | 21 | > The host should always terminate the Zellij session by closing all tabs and panes. 22 | > Simply detaching will still close Ziina and therefor terminate the builtin SSH server and the tunnel. 23 | > However, it will leave behind a dangling Zellij session and also likely screw up your peers' terminal, because their connection gets terminated very disgracefully. 24 | 25 | ### Ziina Session Life-Cycle 26 | 27 | ```mermaid 28 | sequenceDiagram 29 | participant Host 30 | participant Ziina 31 | participant Zellij 32 | participant Builtin-SSHd 33 | participant Public-SSHd 34 | participant Peer 35 | 36 | Note over Host,Peer: Start Ziina 37 | Host->>Ziina: Start Ziina 38 | Ziina->>Builtin-SSHd: Start builtin SSHd (2222) 39 | Ziina->>Public-SSHd: Configure remote port-forwarding tunnel (2222) 40 | Public-SSHd-->>Builtin-SSHd: Forwards connections back to builtin SSHd (2222) 41 | 42 | Note over Host,Public-SSHd: Host connects to session 43 | Host->>Public-SSHd: Connect via SSH 44 | Public-SSHd->>Builtin-SSHd: Forward connecting host to builtin SSH 45 | Builtin-SSHd->>Zellij: Exec into Zellij session 46 | 47 | Note over Public-SSHd,Peer: Peer connects to session 48 | Peer->>Public-SSHd: Connect via SSH 49 | Public-SSHd->>Builtin-SSHd: Forward connecting peer to builtin SSHd 50 | Builtin-SSHd->>Zellij: Exec into Zellij session 51 | 52 | Note over Host,Peer: Zellij session closed or host detached 53 | Ziina->>Public-SSHd: Terminate remote port-forwarding tunnel 54 | Ziina->>Builtin-SSHd: Terminate builtin SSHd 55 | Ziina->>Host: Terminate Ziina 56 | ``` 57 | 58 | ## Security Model 59 | 60 | Both, the remote port-forwarding and the builtin minimal SSH server, are initiated and terminated with Ziina. 61 | While Ziina is not running, no listening-port will be bound, neither on your server, nor locally. 62 | You can choose the port on which to bind when you start Ziina; default is 2222. 63 | 64 | The builtin minimal SSH server implements authentication and authorization solely via the username. 65 | Connecting peers must know the correct username. 66 | Peers connecting with a wrong username are immediately disconnected. 67 | 68 | By default, Ziina will bind the builtin SSH server to `127.0.0.1:2222`. 69 | If you explicitly decide to bind it to `:2222`, you can make your Zellij session available on your LAN. 70 | Peers in your network can then connect to the high-port on your Zellij host, directly, effectively bypassing the round-trip through the tunnel. 71 | 72 | If you don't provide an SSH host-key, Ziina will generate a random one on every start. 73 | 74 | ## Installation 75 | 76 | ### Prerequisits 77 | 78 | You as the host: 79 | 80 | - [Zellij](https://zellij.dev/) 81 | - a standard [OpenSSH](https://github.com/openssh/openssh-portable) client 82 | - an SSH server with a public IP address, configured for password-less authentication (loaded ssh-agent with keys) 83 | 84 | Your peers: 85 | 86 | - a standard [OpenSSH](https://github.com/openssh/openssh-portable) client 87 | 88 | ### Install via Go 89 | 90 | ``` 91 | go install github.com/ziinaio/ziina@latest 92 | ``` 93 | 94 | ## Usage 95 | 96 | ``` 97 | NAME: 98 | ziina - 💻 📤 👥 Instant terminal sharing; using Zellij. 99 | 100 | ███████╗██╗██╗███╗ ██╗ █████╗ 101 | ╚══███╔╝██║██║████╗ ██║██╔══██╗ 102 | ███╔╝ ██║██║██╔██╗ ██║███████║ 103 | ███╔╝ ██║██║██║╚██╗██║██╔══██║ 104 | ███████╗██║██║██║ ╚████║██║ ██║ 105 | ╚══════╝╚═╝╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝ 106 | 107 | 108 | USAGE: 109 | ziina [global options] command [command options] 110 | 111 | COMMANDS: 112 | help, h Shows a list of commands or help for one command 113 | 114 | GLOBAL OPTIONS: 115 | --listen value, -l value Listen on this port. (default: "127.0.0.1:2222") 116 | --server value, -s value The SSH server to use as endpoint. 117 | --user value, -u value Username for SSH authentication. 118 | --host-key value, -k value Path to the private key for SSH authentication. (default: "ssh_host_rsa_key") 119 | --help, -h show help 120 | ``` 121 | 122 | ### Host 123 | 124 | ``` 125 | ziina -s myserver 126 | ``` 127 | 128 | This will generate a random 7 digit Zellij session-name. 129 | Use it as username when connecting as client. 130 | 131 | ### Peer 132 | 133 | ``` 134 | ssh -p 2222 @myserver 135 | ``` 136 | 137 | --- 138 | 139 | Made with :heart: at :artificial_satellite: c-base, Berlin. 140 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | | Version | Supported | 6 | | ------- | ------------------ | 7 | | 0.1.x | :white_check_mark: | 8 | 9 | ## Reporting a Vulnerability 10 | 11 | Create an advisory. 12 | PRs are welcome. 13 | -------------------------------------------------------------------------------- /cmd/ziina/main.go: -------------------------------------------------------------------------------- 1 | package ziina 2 | 3 | import ( 4 | "bufio" 5 | "crypto/rand" 6 | "fmt" 7 | "io" 8 | "log" 9 | "math/big" 10 | "net" 11 | "os" 12 | "os/exec" 13 | "os/signal" 14 | "os/user" 15 | "strconv" 16 | "strings" 17 | "sync" 18 | "syscall" 19 | 20 | "github.com/creack/pty" 21 | "github.com/giancarlosio/gorainbow" 22 | "github.com/gliderlabs/ssh" 23 | "github.com/urfave/cli/v2" 24 | sshcrypto "golang.org/x/crypto/ssh" 25 | sshagent "golang.org/x/crypto/ssh/agent" 26 | "golang.org/x/term" 27 | ) 28 | 29 | const banner = ` 30 | ███████╗██╗██╗███╗ ██╗ █████╗ 31 | ╚══███╔╝██║██║████╗ ██║██╔══██╗ 32 | ███╔╝ ██║██║██╔██╗ ██║███████║ 33 | ███╔╝ ██║██║██║╚██╗██║██╔══██║ 34 | ███████╗██║██║██║ ╚████║██║ ██║ 35 | ╚══════╝╚═╝╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝ 36 | ` 37 | 38 | var ( 39 | // userSessions stores the individual SSH sessions. 40 | userSessions = make(map[string][]ssh.Session) 41 | 42 | // sessionName contains the name of the Zellij session. 43 | // An empty string denotes that the host has not yet initiaed a session. 44 | sessionName = "" 45 | 46 | // mu holds the mutex. 47 | mu sync.Mutex 48 | ) 49 | 50 | // charset contains the list of available characters for random session-name generation. 51 | const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" 52 | 53 | // randomString returns a random string of characters of the given length. 54 | func randomString(length int) (string, error) { 55 | result := make([]byte, length) 56 | for i := range result { 57 | num, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset)))) 58 | if err != nil { 59 | return "", err 60 | } 61 | result[i] = charset[num.Int64()] 62 | } 63 | return string(result), nil 64 | } 65 | 66 | // App serves as entry-point for github.com/urfave/cli 67 | var App = &cli.App{ 68 | Name: "ziina", 69 | Usage: "💻 📤 👥 Instant terminal sharing; using Zellij." + "\n" + gorainbow.Rainbow(banner), 70 | Flags: []cli.Flag{ 71 | &cli.StringFlag{ 72 | Name: "listen", 73 | Aliases: []string{"l"}, 74 | Usage: "Listen on this port.", 75 | Value: "127.0.0.1:2222", 76 | }, 77 | &cli.StringFlag{ 78 | Name: "server", 79 | Aliases: []string{"s"}, 80 | Usage: "The SSH server to use as endpoint.", 81 | Required: true, 82 | }, 83 | &cli.StringFlag{ 84 | Name: "user", 85 | Aliases: []string{"u"}, 86 | Usage: "Username for SSH authentication.", 87 | }, 88 | &cli.StringFlag{ 89 | Name: "host-key", 90 | Aliases: []string{"k"}, 91 | Usage: "Path to the private key for SSH authentication.", 92 | Value: "ssh_host_rsa_key", 93 | }, 94 | }, 95 | Action: func(ctx *cli.Context) error { 96 | // Separate out the port from the listen-address. 97 | parts := strings.Split(ctx.String("listen"), ":") 98 | if len(parts) != 2 { 99 | return fmt.Errorf("invalid listen address: %s", ctx.String("listen")) 100 | } 101 | portStr := parts[1] 102 | port, err := strconv.Atoi(portStr) 103 | if err != nil { 104 | return err 105 | } 106 | 107 | // use current user's username if no username was specified by user. 108 | var u *user.User 109 | if ctx.String("user") == "" { 110 | var err error 111 | u, err = user.Current() 112 | if err != nil { 113 | return err 114 | } 115 | } 116 | 117 | // Generate a random Zellij session-name. 118 | sessionName, err := randomString(7) 119 | if err != nil { 120 | return err 121 | } 122 | 123 | chGuard := make(chan struct{}, 2) 124 | 125 | // Start the remote port-forwarding tunnel. 126 | go func() { 127 | if err := runReverseTunnel(chGuard, parts[0], ctx.String("server"), u.Username, port); err != nil { 128 | log.Fatalf("SSH remote port-forwarding tunnel terminated: %s\n", err) 129 | } 130 | }() 131 | 132 | <-chGuard 133 | 134 | // Start the SSH server 135 | go func() { 136 | if err := runServer(chGuard, ctx.String("listen"), sessionName, ctx.String("host-key")); err != nil { 137 | log.Fatalf("SSH server error: %v", err) 138 | } 139 | }() 140 | 141 | <-chGuard 142 | 143 | fmt.Println("") 144 | fmt.Printf("\tJoin via: ssh -p %d %s@%s\n", port, sessionName, ctx.String("server")) 145 | if parts[0] != "127.0.0.1" { 146 | fmt.Printf("\tDirect: ssh -p %d %s@%s\n", port, sessionName, parts[0]) 147 | } 148 | fmt.Println("\nPress Enter to continue...") 149 | bufio.NewReader(os.Stdin).ReadBytes('\n') 150 | 151 | // Start the reverse SSH tunnel 152 | return runZellij(ctx.String("server"), sessionName, port) 153 | }, 154 | } 155 | 156 | func runServer(chGuard chan struct{}, listenAddr string, sessionName string, hostKeyFile string) error { 157 | // Define the SSH server 158 | server := &ssh.Server{ 159 | Addr: listenAddr, 160 | Handler: func(s ssh.Session) { 161 | username := s.User() 162 | 163 | mu.Lock() 164 | // Disallow clients connecting with the wrong username. 165 | if sessionName == "" { 166 | sessionName = username 167 | } 168 | if username != sessionName { 169 | return 170 | } 171 | 172 | // Add session to the user pool 173 | userSessions[username] = append(userSessions[username], s) 174 | mu.Unlock() 175 | 176 | // The Zellij command. 177 | cmd := exec.Command("zellij", "-l", "compact", "attach", "--create", sessionName) 178 | 179 | // Zellij requires a PTY. 180 | ptyReq, winCh, isPty := s.Pty() 181 | if !isPty { 182 | io.WriteString(s, "No PTY requested. Zellij requires a PTY.\n") 183 | s.Exit(1) 184 | return 185 | } 186 | 187 | // Set TERM environment variable 188 | cmd.Env = append(cmd.Env, fmt.Sprintf("TERM=%s", ptyReq.Term)) 189 | cmd.Env = append(cmd.Env, fmt.Sprintf("SHELL=%s", os.Getenv("SHELL"))) 190 | 191 | // Start Zellij in a new PTY 192 | ptmx, err := pty.Start(cmd) 193 | if err != nil { 194 | log.Printf("Failed to start PTY: %v", err) 195 | s.Exit(1) 196 | return 197 | } 198 | defer ptmx.Close() 199 | 200 | // Handle window resize 201 | go func() { 202 | for win := range winCh { 203 | pty.Setsize(ptmx, &pty.Winsize{ 204 | Cols: uint16(win.Width), 205 | Rows: uint16(win.Height), 206 | }) 207 | } 208 | }() 209 | 210 | // Connect session input/output to the PTY 211 | go io.Copy(ptmx, s) 212 | io.Copy(s, ptmx) // blocks until Zellij exits 213 | }, 214 | } 215 | 216 | // Load the host key from a file using golang.org/x/crypto/ssh to parse 217 | privateKeyPath := hostKeyFile 218 | keyBytes, err := os.ReadFile(privateKeyPath) 219 | if err == nil { 220 | private, err := sshcrypto.ParsePrivateKey(keyBytes) 221 | if err == nil { 222 | server.AddHostKey(private) 223 | } 224 | } 225 | 226 | go func() { 227 | chGuard <- struct{}{} 228 | }() 229 | 230 | log.Printf("Starting Ziina server on %s...\n", listenAddr) 231 | return server.ListenAndServe() 232 | } 233 | 234 | func runReverseTunnel(chGuard chan struct{}, bindAddr, remoteHost, user string, port int) error { 235 | log.Println("Starting SSH reverse port-forwarding...") 236 | 237 | // Connect to the running SSH agent 238 | sshAgentSocket := os.Getenv("SSH_AUTH_SOCK") 239 | if sshAgentSocket == "" { 240 | log.Fatalf("SSH agent not found. Please ensure SSH agent is running and SSH_AUTH_SOCK is set.") 241 | } 242 | 243 | // Open the agent socket 244 | agentConn, err := net.Dial("unix", sshAgentSocket) 245 | if err != nil { 246 | log.Fatalf("Failed to connect to SSH agent: %s", err) 247 | } 248 | defer agentConn.Close() 249 | 250 | // Create a new agent client 251 | agentClient := sshagent.NewClient(agentConn) 252 | 253 | // SSH client configuration 254 | config := &sshcrypto.ClientConfig{ 255 | User: user, // Replace with your SSH username 256 | Auth: []sshcrypto.AuthMethod{ 257 | // Use the SSH agent to retrieve keys for authentication 258 | sshcrypto.PublicKeysCallback(agentClient.Signers), 259 | }, 260 | HostKeyCallback: sshcrypto.InsecureIgnoreHostKey(), // For development, replace with proper verification in production 261 | } 262 | 263 | client, err := sshcrypto.Dial("tcp", fmt.Sprintf("%s:22", remoteHost), config) 264 | if err != nil { 265 | return fmt.Errorf("failed to dial SSH server: %v", err) 266 | } 267 | 268 | // Request remote port forwarding 269 | listener, err := client.Listen("tcp", fmt.Sprintf("0.0.0.0:%d", port)) 270 | if err != nil { 271 | return fmt.Errorf("failed to set up remote port forwarding: %v", err) 272 | } 273 | 274 | log.Printf("Remote port forwarding established: %s:%d -> localhost:%d", remoteHost, port, port) 275 | 276 | // Handle incoming connections 277 | go func() { 278 | for { 279 | conn, err := listener.Accept() 280 | if err != nil { 281 | log.Printf("Listener accept error: %v", err) 282 | continue 283 | } 284 | 285 | // Connect to the local SSH server 286 | localConn, err := net.Dial("tcp", fmt.Sprintf("%s:%d", bindAddr, port)) 287 | if err != nil { 288 | log.Printf("Failed to connect to local service: %v", err) 289 | conn.Close() 290 | continue 291 | } 292 | 293 | // Start bidirectional copy 294 | go func() { 295 | defer conn.Close() 296 | defer localConn.Close() 297 | go io.Copy(localConn, conn) 298 | io.Copy(conn, localConn) 299 | }() 300 | } 301 | }() 302 | 303 | go func() { 304 | chGuard <- struct{}{} 305 | }() 306 | 307 | // Wait for interrupt signal to gracefully shutdown 308 | sigs := make(chan os.Signal, 1) 309 | signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) 310 | <-sigs 311 | log.Println("Shutting down...") 312 | 313 | return nil 314 | } 315 | 316 | func runZellij(server, sessionName string, port int) error { 317 | // Connect to SSH agent 318 | sshAgentSock := os.Getenv("SSH_AUTH_SOCK") 319 | if sshAgentSock == "" { 320 | return fmt.Errorf("SSH_AUTH_SOCK not set") 321 | } 322 | agentConn, err := net.Dial("unix", sshAgentSock) 323 | if err != nil { 324 | return fmt.Errorf("failed to connect to SSH agent: %w", err) 325 | } 326 | defer agentConn.Close() 327 | ag := sshagent.NewClient(agentConn) 328 | 329 | // SSH config 330 | config := &sshcrypto.ClientConfig{ 331 | User: sessionName, 332 | Auth: []sshcrypto.AuthMethod{ 333 | sshcrypto.PublicKeysCallback(ag.Signers), 334 | }, 335 | HostKeyCallback: sshcrypto.InsecureIgnoreHostKey(), // Don't use this in production 336 | } 337 | 338 | // Connect 339 | addr := fmt.Sprintf("%s:%d", server, port) 340 | client, err := sshcrypto.Dial("tcp", addr, config) 341 | if err != nil { 342 | return fmt.Errorf("failed to dial: %w", err) 343 | } 344 | defer client.Close() 345 | 346 | // Create session 347 | session, err := client.NewSession() 348 | if err != nil { 349 | return fmt.Errorf("failed to create session: %w", err) 350 | } 351 | defer session.Close() 352 | 353 | // Save current terminal state 354 | fd := int(os.Stdin.Fd()) 355 | oldState, err := term.MakeRaw(fd) 356 | if err != nil { 357 | return fmt.Errorf("failed to set terminal raw mode: %w", err) 358 | } 359 | defer term.Restore(fd, oldState) 360 | 361 | // Handle Ctrl+C gracefully 362 | sig := make(chan os.Signal, 1) 363 | signal.Notify(sig, os.Interrupt) 364 | go func() { 365 | <-sig 366 | term.Restore(fd, oldState) 367 | os.Exit(0) 368 | }() 369 | 370 | // Request PTY 371 | termType := os.Getenv("TERM") 372 | if termType == "" { 373 | termType = "xterm-256color" 374 | } 375 | width, height, err := term.GetSize(fd) 376 | if err != nil { 377 | width, height = 80, 24 // fallback 378 | } 379 | err = session.RequestPty(termType, height, width, sshcrypto.TerminalModes{ 380 | sshcrypto.ECHO: 1, 381 | }) 382 | if err != nil { 383 | return fmt.Errorf("request for PTY failed: %w", err) 384 | } 385 | 386 | // Set I/O 387 | session.Stdin = os.Stdin 388 | session.Stdout = os.Stdout 389 | session.Stderr = os.Stderr 390 | 391 | // Start Zellij 392 | if err := session.Start("zellij attach " + sessionName); err != nil { 393 | return fmt.Errorf("failed to start zellij: %w", err) 394 | } 395 | 396 | // Wait for session to end 397 | if err := session.Wait(); err != nil { 398 | return fmt.Errorf("zellij session ended with error: %w", err) 399 | } 400 | 401 | return nil 402 | } 403 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ziinaio/ziina 2 | 3 | go 1.24.2 4 | 5 | require ( 6 | github.com/creack/pty v1.1.24 7 | github.com/giancarlosio/gorainbow v1.0.1 8 | github.com/gliderlabs/ssh v0.3.8 9 | github.com/urfave/cli/v2 v2.27.6 10 | golang.org/x/crypto v0.38.0 11 | golang.org/x/term v0.32.0 12 | ) 13 | 14 | require ( 15 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect 16 | github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect 17 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 18 | github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect 19 | golang.org/x/sys v0.33.0 // indirect 20 | ) 21 | -------------------------------------------------------------------------------- /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/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc= 4 | github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 5 | github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= 6 | github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= 7 | github.com/giancarlosio/gorainbow v1.0.1 h1:RlxnAi7zy/8A5A+blL1/fTl/Sj+1Ehz3Yku7DIXo3a8= 8 | github.com/giancarlosio/gorainbow v1.0.1/go.mod h1:Ke08GLWiGm9O4dzLh/g/vPgc52SBVJpW4feYJuZC8o4= 9 | github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= 10 | github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= 11 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 12 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 13 | github.com/urfave/cli/v2 v2.27.6 h1:VdRdS98FNhKZ8/Az8B7MTyGQmpIr36O1EHybx/LaZ4g= 14 | github.com/urfave/cli/v2 v2.27.6/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= 15 | github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= 16 | github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= 17 | golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= 18 | golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= 19 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 20 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 21 | golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= 22 | golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= 23 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | 7 | "github.com/ziinaio/ziina/cmd/ziina" 8 | ) 9 | 10 | func main() { 11 | if err := ziina.App.Run(os.Args); err != nil { 12 | log.Fatal(err) 13 | } 14 | } 15 | 16 | --------------------------------------------------------------------------------