Initial commit: sshkeeper v0.1.0
Console SSH connection manager for Linux. Features: - TUI (Bubble Tea) with server list, add/edit form, test/save - CLI commands: add, list, show, edit, delete, connect, test, search, import, export, run, group, template, vault, ssh-config - Encrypted vault (Argon2id + XChaCha20-Poly1305) for passwords - PTY-wrapper for password auth - SQLite (modernc, no CGO) for server profiles - XDG-compatible paths - OpenSSH config generation - Import from ~/.ssh/config
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsFS embed.FS
|
||||
|
||||
type DB struct {
|
||||
conn *sql.DB
|
||||
}
|
||||
|
||||
func Open(dataDir string) (*DB, error) {
|
||||
dbPath := filepath.Join(dataDir, "sshkeeper.db")
|
||||
|
||||
conn, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open database: %w", err)
|
||||
}
|
||||
|
||||
if err := conn.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("ping database: %w", err)
|
||||
}
|
||||
|
||||
db := &DB{conn: conn}
|
||||
|
||||
if err := db.migrate(); err != nil {
|
||||
return nil, fmt.Errorf("migrate: %w", err)
|
||||
}
|
||||
|
||||
os.Chmod(dbPath, 0600)
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func (db *DB) Close() error {
|
||||
return db.conn.Close()
|
||||
}
|
||||
|
||||
func (db *DB) migrate() error {
|
||||
entries, err := migrationsFS.ReadDir("migrations")
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migrations dir: %w", err)
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
content, err := migrationsFS.ReadFile("migrations/" + entry.Name())
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migration %s: %w", entry.Name(), err)
|
||||
}
|
||||
if _, err := db.conn.Exec(string(content)); err != nil {
|
||||
return fmt.Errorf("exec migration %s: %w", entry.Name(), err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
CREATE TABLE IF NOT EXISTS servers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
alias TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT NOT NULL DEFAULT '',
|
||||
host TEXT NOT NULL,
|
||||
port INTEGER NOT NULL DEFAULT 22,
|
||||
user TEXT NOT NULL DEFAULT '',
|
||||
auth_method TEXT NOT NULL DEFAULT 'key',
|
||||
identity_file TEXT NOT NULL DEFAULT '',
|
||||
proxy_jump TEXT NOT NULL DEFAULT '',
|
||||
group_name TEXT NOT NULL DEFAULT '',
|
||||
notes TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_connected_at DATETIME,
|
||||
last_test_at DATETIME,
|
||||
last_test_status TEXT NOT NULL DEFAULT 'unknown',
|
||||
last_test_error TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS server_tags (
|
||||
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (server_id, tag_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS forwards (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
type TEXT NOT NULL DEFAULT 'local',
|
||||
local_addr TEXT NOT NULL DEFAULT '',
|
||||
local_port INTEGER NOT NULL DEFAULT 0,
|
||||
remote_addr TEXT NOT NULL DEFAULT '',
|
||||
remote_port INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS command_templates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
command TEXT NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,248 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"github.com/mirivlad/sshkeeper/internal/model"
|
||||
)
|
||||
|
||||
func (db *DB) CreateServer(s *model.Server) error {
|
||||
result, err := db.conn.Exec(`
|
||||
INSERT INTO servers (alias, display_name, host, port, user, auth_method, identity_file, proxy_jump, group_name, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
s.Alias, s.DisplayName, s.Host, s.Port, s.User, s.AuthMethod, s.IdentityFile, s.ProxyJump, s.GroupName, s.Notes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.ID, _ = result.LastInsertId()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) UpdateServer(s *model.Server) error {
|
||||
_, err := db.conn.Exec(`
|
||||
UPDATE servers SET
|
||||
display_name=?, host=?, port=?, user=?, auth_method=?,
|
||||
identity_file=?, proxy_jump=?, group_name=?, notes=?, updated_at=CURRENT_TIMESTAMP
|
||||
WHERE alias=?`,
|
||||
s.DisplayName, s.Host, s.Port, s.User, s.AuthMethod,
|
||||
s.IdentityFile, s.ProxyJump, s.GroupName, s.Notes, s.Alias)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) DeleteServer(alias string) error {
|
||||
_, err := db.conn.Exec("DELETE FROM servers WHERE alias=?", alias)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) GetServer(alias string) (*model.Server, error) {
|
||||
var s model.Server
|
||||
var lastConnected, lastTest sql.NullTime
|
||||
err := db.conn.QueryRow(`
|
||||
SELECT id, alias, display_name, host, port, user, auth_method,
|
||||
identity_file, proxy_jump, group_name, notes,
|
||||
created_at, updated_at, last_connected_at,
|
||||
last_test_at, last_test_status, last_test_error
|
||||
FROM servers WHERE alias=?`, alias).Scan(
|
||||
&s.ID, &s.Alias, &s.DisplayName, &s.Host, &s.Port, &s.User, &s.AuthMethod,
|
||||
&s.IdentityFile, &s.ProxyJump, &s.GroupName, &s.Notes,
|
||||
&s.CreatedAt, &s.UpdatedAt, &lastConnected,
|
||||
&lastTest, &s.LastTestStatus, &s.LastTestError)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if lastConnected.Valid {
|
||||
s.LastConnectedAt = &lastConnected.Time
|
||||
}
|
||||
if lastTest.Valid {
|
||||
s.LastTestAt = &lastTest.Time
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func (db *DB) ListServers() ([]*model.Server, error) {
|
||||
rows, err := db.conn.Query(`
|
||||
SELECT id, alias, display_name, host, port, user, auth_method,
|
||||
identity_file, proxy_jump, group_name, notes,
|
||||
created_at, updated_at, last_connected_at,
|
||||
last_test_at, last_test_status, last_test_error
|
||||
FROM servers ORDER BY alias`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var servers []*model.Server
|
||||
for rows.Next() {
|
||||
var s model.Server
|
||||
var lastConnected, lastTest sql.NullTime
|
||||
err := rows.Scan(
|
||||
&s.ID, &s.Alias, &s.DisplayName, &s.Host, &s.Port, &s.User, &s.AuthMethod,
|
||||
&s.IdentityFile, &s.ProxyJump, &s.GroupName, &s.Notes,
|
||||
&s.CreatedAt, &s.UpdatedAt, &lastConnected,
|
||||
&lastTest, &s.LastTestStatus, &s.LastTestError)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if lastConnected.Valid {
|
||||
s.LastConnectedAt = &lastConnected.Time
|
||||
}
|
||||
if lastTest.Valid {
|
||||
s.LastTestAt = &lastTest.Time
|
||||
}
|
||||
servers = append(servers, &s)
|
||||
}
|
||||
return servers, rows.Err()
|
||||
}
|
||||
|
||||
func (db *DB) SearchServers(query string) ([]*model.Server, error) {
|
||||
pattern := "%" + query + "%"
|
||||
rows, err := db.conn.Query(`
|
||||
SELECT id, alias, display_name, host, port, user, auth_method,
|
||||
identity_file, proxy_jump, group_name, notes,
|
||||
created_at, updated_at, last_connected_at,
|
||||
last_test_at, last_test_status, last_test_error
|
||||
FROM servers
|
||||
WHERE alias LIKE ? OR display_name LIKE ? OR host LIKE ? OR user LIKE ? OR group_name LIKE ?
|
||||
ORDER BY alias`, pattern, pattern, pattern, pattern, pattern)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var servers []*model.Server
|
||||
for rows.Next() {
|
||||
var s model.Server
|
||||
var lastConnected, lastTest sql.NullTime
|
||||
err := rows.Scan(
|
||||
&s.ID, &s.Alias, &s.DisplayName, &s.Host, &s.Port, &s.User, &s.AuthMethod,
|
||||
&s.IdentityFile, &s.ProxyJump, &s.GroupName, &s.Notes,
|
||||
&s.CreatedAt, &s.UpdatedAt, &lastConnected,
|
||||
&lastTest, &s.LastTestStatus, &s.LastTestError)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if lastConnected.Valid {
|
||||
s.LastConnectedAt = &lastConnected.Time
|
||||
}
|
||||
if lastTest.Valid {
|
||||
s.LastTestAt = &lastTest.Time
|
||||
}
|
||||
servers = append(servers, &s)
|
||||
}
|
||||
return servers, rows.Err()
|
||||
}
|
||||
|
||||
func (db *DB) UpdateTestResult(alias string, status model.TestStatus, testErr string) error {
|
||||
_, err := db.conn.Exec(`
|
||||
UPDATE servers SET last_test_at=CURRENT_TIMESTAMP, last_test_status=?, last_test_error=?
|
||||
WHERE alias=?`, status, testErr, alias)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) UpdateLastConnected(alias string) error {
|
||||
_, err := db.conn.Exec("UPDATE servers SET last_connected_at=CURRENT_TIMESTAMP WHERE alias=?", alias)
|
||||
return err
|
||||
}
|
||||
|
||||
// Tag methods
|
||||
func (db *DB) AddTagToServer(serverID int64, tagName string) error {
|
||||
var tagID int64
|
||||
err := db.conn.QueryRow("SELECT id FROM tags WHERE name=?", tagName).Scan(&tagID)
|
||||
if err == sql.ErrNoRows {
|
||||
result, err := db.conn.Exec("INSERT INTO tags (name) VALUES (?)", tagName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tagID, _ = result.LastInsertId()
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = db.conn.Exec("INSERT OR IGNORE INTO server_tags (server_id, tag_id) VALUES (?, ?)", serverID, tagID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) GetServerTags(serverID int64) ([]string, error) {
|
||||
rows, err := db.conn.Query(`
|
||||
SELECT t.name FROM tags t
|
||||
JOIN server_tags st ON st.tag_id = t.id
|
||||
WHERE st.server_id = ?
|
||||
ORDER BY t.name`, serverID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var tags []string
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tags = append(tags, name)
|
||||
}
|
||||
return tags, rows.Err()
|
||||
}
|
||||
|
||||
// Forward methods
|
||||
func (db *DB) AddForward(serverID int64, fwdType model.ForwardType, localAddr string, localPort int, remoteAddr string, remotePort int) error {
|
||||
_, err := db.conn.Exec(`
|
||||
INSERT INTO forwards (server_id, type, local_addr, local_port, remote_addr, remote_port)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
serverID, fwdType, localAddr, localPort, remoteAddr, remotePort)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) GetForwards(serverID int64) ([]*model.Forward, error) {
|
||||
rows, err := db.conn.Query(`
|
||||
SELECT id, server_id, type, local_addr, local_port, remote_addr, remote_port
|
||||
FROM forwards WHERE server_id=?`, serverID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var forwards []*model.Forward
|
||||
for rows.Next() {
|
||||
var f model.Forward
|
||||
if err := rows.Scan(&f.ID, &f.ServerID, &f.Type, &f.LocalAddr, &f.LocalPort, &f.RemoteAddr, &f.RemotePort); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
forwards = append(forwards, &f)
|
||||
}
|
||||
return forwards, rows.Err()
|
||||
}
|
||||
|
||||
// Ensure time import is used
|
||||
var _ time.Time
|
||||
|
||||
// Command template methods
|
||||
func (db *DB) AddCommandTemplate(serverID int64, name, command string) error {
|
||||
_, err := db.conn.Exec(
|
||||
"INSERT INTO command_templates (server_id, name, command) VALUES (?, ?, ?)",
|
||||
serverID, name, command)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) GetCommandTemplates(serverAlias string) ([]*model.CommandTemplate, error) {
|
||||
rows, err := db.conn.Query(`
|
||||
SELECT ct.id, ct.server_id, ct.name, ct.command
|
||||
FROM command_templates ct
|
||||
JOIN servers s ON s.id = ct.server_id
|
||||
WHERE s.alias = ?
|
||||
ORDER BY ct.name`, serverAlias)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var templates []*model.CommandTemplate
|
||||
for rows.Next() {
|
||||
var t model.CommandTemplate
|
||||
if err := rows.Scan(&t.ID, &t.ServerID, &t.Name, &t.Command); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
templates = append(templates, &t)
|
||||
}
|
||||
return templates, rows.Err()
|
||||
}
|
||||
Reference in New Issue
Block a user