Skip to content

Commit 735c4d3

Browse files
committed
Merge dev into main: stable v2.0.0 - ssh_config/SQLite/both modes
1 parent c26d9fd commit 735c4d3

36 files changed

+2840
-192
lines changed

.github/workflows/release.yml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ jobs:
3333
"darwin/amd64"
3434
"darwin/arm64"
3535
"windows/amd64"
36-
"windows/arm64"
3736
"freebsd/amd64"
3837
"freebsd/arm64"
3938
)
@@ -43,7 +42,7 @@ jobs:
4342
GOARCH="${target#*/}"
4443
output="dist/dssh-${GOOS}-${GOARCH}"
4544
if [ "$GOOS" = "windows" ]; then
46-
output="${output}.exe"
45+
output="dist/dssh.exe"
4746
fi
4847
echo "Building ${GOOS}/${GOARCH}..."
4948
CGO_ENABLED=0 GOOS="$GOOS" GOARCH="$GOARCH" \

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,5 @@ vendor/
1111
.DS_Store
1212
Thumbs.db
1313

14-
push-full.sh
14+
push-full.sh
15+
dssh

README.md

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@
77
[![SQLite](https://img.shields.io/badge/storage-SQLite-003B57?logo=sqlite&logoColor=white)](https://www.sqlite.org)
88
[![AES-256-GCM](https://img.shields.io/badge/crypto-AES--256--GCM%20%2B%20Argon2id-22C55E?logo=letsencrypt&logoColor=white)](#password-encryption)
99

10-
The only SSH connection management tool you'll ever need. No dependencies, no more editing `/etc/hosts`.
10+
The only SSH connection management tool you'll ever need. **TUI & CLI**. No dependencies, no more file editing.
1111

12-
Four core features: **Create, Connect, Edit, Delete**. Dead-simple and cross-platform for every CLI.
12+
Four core features: **Create, Connect, Edit, Delete**. Dead-simple and cross-platform.
13+
14+
Store connections in **SQLite**, your **ssh_config** file, or **both** — your choice.
1315

1416
Passwords are encrypted using a master passphrase (_you should consider using pubkeys only tho ;))_.
1517

@@ -32,6 +34,7 @@ Passwords are encrypted using a master passphrase (_you should consider using pu
3234

3335
- [Features](#features)
3436
- [How it works](#how-it-works)
37+
- [Connection Modes](#connection-modes)
3538
- [Usage](#usage)
3639
- [Command Reference](#command-reference)
3740
- [TUI Navigation](#tui-navigation)
@@ -43,6 +46,7 @@ Passwords are encrypted using a master passphrase (_you should consider using pu
4346
- [Delete (TUI)](#delete-tui)
4447
- [List connections (CLI)](#list-connections-cli)
4548
- [Remove a connection (CLI)](#remove-a-connection-cli)
49+
- [Configure mode](#configure-mode)
4650
- [Reset everything](#reset-everything)
4751
- [Installation](#installation)
4852
- [Install & Update script (recommended)](#install--update-script-recommended)
@@ -63,6 +67,7 @@ Passwords are encrypted using a master passphrase (_you should consider using pu
6367

6468
Also:
6569

70+
- **Multiple storage backends** 🗄️ — use SQLite (`~/.dssh/dssh.db`), your `ssh_config` file, or both
6671
- **Launch into a directory** 📂 — optionally land in a specific remote directory on connect
6772
- **Password encryption** 🔒 — AES-256-GCM + Argon2id, protected by a master passphrase
6873
- **Cross-platform** 💻 — Linux, macOS, Windows, FreeBSD (amd64 + arm64)
@@ -74,9 +79,30 @@ dssh is a thin wrapper around your system's `ssh` binary:
7479

7580
- **Key auth**`syscall.Exec` replaces the dssh process with ssh (zero overhead, full terminal control)
7681
- **Password auth** — ssh runs as a child process with `SSH_ASKPASS` to supply the decrypted password (no `sshpass` needed)
77-
- **Data** — connections stored in SQLite at `~/.dssh/dssh.db`, no config files
82+
- **Data** — connections stored in SQLite (`~/.dssh/dssh.db`), your `ssh_config` file, or both
7883
- **Crypto** — AES-256-GCM encryption with Argon2id key derivation for stored passwords
7984

85+
## Connection Modes
86+
87+
On first launch, dssh asks you to choose a connection mode:
88+
89+
| Mode | Description |
90+
|---|---|
91+
| **SQLite only** | Connections stored in `~/.dssh/dssh.db` (default) |
92+
| **ssh_config only** | Connections read from and written to your `ssh_config` file |
93+
| **Both** | Use SQLite and ssh_config side by side, toggle with `CTRL+L` |
94+
95+
When using **ssh_config** or **both**, you pick a destination file:
96+
- **Main file**`~/.ssh/config`
97+
- **Directive**`~/.ssh/config.d/dssh`
98+
- **Custom path** — any file you choose
99+
100+
If the file doesn't exist, dssh offers to create it for you.
101+
102+
Change your mode anytime with `dssh config`. View current settings with `dssh config get` (or `dssh config show`).
103+
104+
> **Note:** Password auth is only available when saving to SQLite. ssh_config entries always use key auth.
105+
80106
## Usage
81107

82108
### Command Reference
@@ -92,6 +118,8 @@ dssh is a thin wrapper around your system's `ssh` binary:
92118
| `dssh create` / `dssh new` | Interactive form to create a connection |
93119
| `dssh edit` | Edit an existing connection |
94120
| `dssh delete` | Delete a connection (TUI, triple-confirm) |
121+
| `dssh config` | Configure connection mode (SQLite / ssh_config / both) |
122+
| `dssh config get` / `dssh config show` | Show current configuration |
95123
| `dssh reset` | Delete all data (double confirmation) |
96124
| `dssh --version` | Print version |
97125

@@ -102,6 +130,8 @@ dssh is a thin wrapper around your system's `ssh` binary:
102130
| `Tab` / `Shift+Tab` | Switch between tabs |
103131
| `` / `` | Navigate lists |
104132
| `Enter` | Select / confirm |
133+
| `Ctrl+L` | Toggle SQLite / ssh_config list (both mode) |
134+
| `Ctrl+T` | Toggle key / password auth (create/edit) |
105135
| `ESC` / `Q` | Quit |
106136

107137
### Quick start - Let's Go!
@@ -207,6 +237,29 @@ dssh rm myserver
207237
```
208238
Remove a connection instantly. No confirmation asked.
209239

240+
### Configure mode
241+
242+
Switch between SQLite, ssh_config, or both at any time.
243+
244+
```bash
245+
dssh config
246+
```
247+
248+
![dssh config](demo_config.gif)
249+
250+
View current settings:
251+
252+
```bash
253+
dssh config get
254+
```
255+
256+
```
257+
parse_mode: both
258+
ssh_config_parse_destination: ~/.ssh/config.d/dssh
259+
parse_both_view_mode: sqlite
260+
parse_both_default_save_target: sqlite
261+
```
262+
210263
### Reset everything
211264

212265
Wipe all saved connections, encrypted passwords, and settings (deletes the SQLite database). Requires two confirmations to prevent accidents.
@@ -223,7 +276,7 @@ All data has been reset
223276

224277
## Installation
225278

226-
### Install & Update script (recommended)
279+
### Install & Update script (recommended for now)
227280

228281
**Linux / macOS / FreeBSD:**
229282

demo_1.gif

974 Bytes
Loading

demo_config.gif

123 KB
Loading

demo_instant_connect.gif

168 KB
Loading

demo_tabs.gif

40 KB
Loading

demo_wizard.gif

38 KB
Loading

internal/cli/add.go

Lines changed: 112 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,15 @@ import (
88

99
"github.com/madLinux7/dssh/internal/db"
1010
"github.com/madLinux7/dssh/internal/model"
11+
"github.com/madLinux7/dssh/internal/sshconfig"
1112
"github.com/spf13/cobra"
1213
)
1314

1415
func newAddCmd() *cobra.Command {
1516
var port int
1617
var directory string
18+
var addToSQLite bool
19+
var addToSSHConfig bool
1720

1821
cmd := &cobra.Command{
1922
Use: "add [-p PORT] [-d DIR] NAME target [password]",
@@ -28,6 +31,7 @@ and the password is encrypted with your master passphrase.`,
2831
return err
2932
}
3033
target := args[1]
34+
hasPassword := len(args) == 3
3135

3236
user, host, parsedPort, err := parseTarget(target)
3337
if err != nil {
@@ -41,12 +45,6 @@ and the password is encrypted with your master passphrase.`,
4145
parsedPort = 22
4246
}
4347

44-
d, err := db.Open()
45-
if err != nil {
46-
return err
47-
}
48-
defer d.Close()
49-
5048
conn := &model.Connection{
5149
Name: name,
5250
User: user,
@@ -56,24 +54,52 @@ and the password is encrypted with your master passphrase.`,
5654
AuthType: model.AuthKey,
5755
}
5856

59-
// Optional password argument → password auth.
60-
if len(args) == 3 {
57+
// Determine save target.
58+
saveTarget := resolveAddTarget(addToSQLite, addToSSHConfig, hasPassword)
59+
if saveTarget == "" {
60+
return nil // user aborted
61+
}
62+
63+
// Password handling.
64+
if hasPassword {
65+
if saveTarget == model.SaveTargetSSHConfig {
66+
errMsg("Passwords can only be saved to SQLite. Use %s or %s mode.",
67+
magentaText("--sqlite"), magentaText("sqlite_only"))
68+
return nil
69+
}
6170
password := args[2]
6271
conn.AuthType = model.AuthPassword
63-
64-
encPass, nonce, err := encryptPassword(d, password)
72+
encPass, nonce, err := encryptPassword(sharedDB, password)
6573
if err != nil {
6674
return err
6775
}
6876
conn.EncryptedPass = encPass
6977
conn.PassNonce = nonce
7078
}
7179

72-
if err := db.Insert(d, conn); err != nil {
73-
return err
80+
// Cross-source duplicate check in "both" mode.
81+
if runtimeCfg.ParseMode == model.ParseModeBoth {
82+
if err := checkCrossSourceDuplicate(name, saveTarget); err != nil {
83+
return err
84+
}
7485
}
7586

76-
success("Added connection %q (%s@%s:%d)", name, user, host, parsedPort)
87+
// Save to the chosen target.
88+
if saveTarget == model.SaveTargetSSHConfig {
89+
p, err := sshConfigPath()
90+
if err != nil {
91+
return err
92+
}
93+
if err := sshconfig.Insert(p, conn); err != nil {
94+
return err
95+
}
96+
success("Added connection %q to ssh_config (%s@%s:%d)", name, user, host, parsedPort)
97+
} else {
98+
if err := db.Insert(sharedDB, conn); err != nil {
99+
return err
100+
}
101+
success("Added connection %q to SQLite (%s@%s:%d)", name, user, host, parsedPort)
102+
}
77103
return nil
78104
},
79105
}
@@ -82,9 +108,82 @@ and the password is encrypted with your master passphrase.`,
82108
cmd.Flags().StringVarP(&directory, "directory", "d", "", "Remote directory to cd into on connect")
83109
cmd.Flags().StringVar(&directory, "cd", "", "Alias for --directory")
84110
cmd.Flags().MarkHidden("cd")
111+
cmd.Flags().BoolVar(&addToSQLite, "sqlite", false, "Save to SQLite")
112+
cmd.Flags().BoolVar(&addToSSHConfig, "sshconfig", false, "Save to ssh_config")
85113
return cmd
86114
}
87115

116+
// resolveAddTarget determines where to save based on flags and mode.
117+
// Returns empty string if user aborted.
118+
func resolveAddTarget(flagSQL, flagSSH, hasPassword bool) model.SaveTarget {
119+
mode := runtimeCfg.ParseMode
120+
121+
// Explicit flags take priority.
122+
if flagSQL {
123+
return model.SaveTargetSQLite
124+
}
125+
if flagSSH {
126+
return model.SaveTargetSSHConfig
127+
}
128+
129+
switch mode {
130+
case model.ParseModeSSHConfigOnly:
131+
return model.SaveTargetSSHConfig
132+
case model.ParseModeBoth:
133+
if hasPassword {
134+
fmt.Println("Passwords can only be saved to SQLite.")
135+
choice := radioPrompt("Save to SQLite?", []string{"Yes", "Abort"})
136+
if choice == 0 {
137+
return model.SaveTargetSQLite
138+
}
139+
return ""
140+
}
141+
// Prompt with radio buttons, pre-select the default.
142+
options := []string{"SQLite", "ssh_config"}
143+
choice := radioPrompt("Save to:", options)
144+
switch choice {
145+
case 0:
146+
return model.SaveTargetSQLite
147+
case 1:
148+
return model.SaveTargetSSHConfig
149+
default:
150+
return ""
151+
}
152+
default: // sqlite_only
153+
return model.SaveTargetSQLite
154+
}
155+
}
156+
157+
// checkCrossSourceDuplicate warns if the name exists in the other source.
158+
func checkCrossSourceDuplicate(name string, target model.SaveTarget) error {
159+
sqlConn, sshConn, err := getConnectionSources(name)
160+
if err != nil {
161+
return err
162+
}
163+
if sqlConn != nil && sshConn != nil {
164+
return fmt.Errorf("connection %q already exists in both SQLite and ssh_config", name)
165+
}
166+
if target == model.SaveTargetSQLite && sshConn != nil {
167+
fmt.Printf("Connection %q already exists in ssh_config.\n", name)
168+
choice := radioPrompt("Save to SQLite anyway?", []string{"Yes", "Abort"})
169+
if choice != 0 {
170+
return fmt.Errorf("aborted")
171+
}
172+
}
173+
if target == model.SaveTargetSSHConfig && sqlConn != nil {
174+
fmt.Printf("Connection %q already exists in SQLite.\n", name)
175+
choice := radioPrompt("Save to ssh_config anyway?", []string{"Yes", "Abort"})
176+
if choice != 0 {
177+
return fmt.Errorf("aborted")
178+
}
179+
}
180+
return nil
181+
}
182+
183+
func magentaText(s string) string {
184+
return fmt.Sprintf("\033[35m%s\033[0m", s)
185+
}
186+
88187
// parseTarget parses user@host or ssh://user@host:port into components.
89188
func parseTarget(target string) (user, host string, port int, err error) {
90189
// Try ssh:// URI first.

internal/cli/config.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package cli
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/madLinux7/dssh/internal/model"
7+
"github.com/madLinux7/dssh/internal/tui"
8+
"github.com/spf13/cobra"
9+
)
10+
11+
func newConfigCmd() *cobra.Command {
12+
cmd := &cobra.Command{
13+
Use: "config",
14+
Short: "Configure dssh connection mode",
15+
Args: cobra.NoArgs,
16+
RunE: func(cmd *cobra.Command, args []string) error {
17+
cfg := tui.RunConfigDialog(appVersion)
18+
if cfg == nil {
19+
fmt.Println("Configuration cancelled.")
20+
return nil
21+
}
22+
23+
if err := saveRuntimeConfig(sharedDB, cfg); err != nil {
24+
return err
25+
}
26+
27+
runtimeCfg = cfg
28+
success("Mode set to %s", model.ParseModeLabel(cfg.ParseMode))
29+
30+
if cfg.SSHConfigDest != "" {
31+
success("ssh_config file set to: %s", cfg.SSHConfigDest)
32+
}
33+
return nil
34+
},
35+
}
36+
37+
cmd.AddCommand(newConfigGetCmd())
38+
return cmd
39+
}
40+
41+
func newConfigGetCmd() *cobra.Command {
42+
return &cobra.Command{
43+
Use: "get",
44+
Aliases: []string{"show"},
45+
Short: "Show the current configuration",
46+
Args: cobra.NoArgs,
47+
RunE: func(cmd *cobra.Command, args []string) error {
48+
if runtimeCfg == nil {
49+
fmt.Println("Not configured yet. Run 'dssh config' to set up.")
50+
return nil
51+
}
52+
53+
fmt.Printf("parse_mode: %s\n", runtimeCfg.ParseMode)
54+
if runtimeCfg.SSHConfigDest != "" {
55+
56+
fmt.Printf("ssh_config_parse_destination: %s\n", runtimeCfg.SSHConfigDest)
57+
}
58+
if runtimeCfg.ParseMode == model.ParseModeBoth {
59+
fmt.Printf("parse_both_view_mode: %s\n", runtimeCfg.BothViewMode)
60+
fmt.Printf("parse_both_default_save_target: %s\n", runtimeCfg.DefaultSaveTarget)
61+
}
62+
63+
return nil
64+
},
65+
}
66+
}

0 commit comments

Comments
 (0)