Skip to content

Commit bed243b

Browse files
feat: snippets, proxy settings, tab colors, connection sorting/filtering
- Snippets library: save, organize, and quick-send commands to terminal - Proxy/Firewall per connection: SOCKS5, SOCKS4, HTTP CONNECT support - Tab color coding based on connection group color - Connection sorting (name, recent, protocol) in sidebar and HomeScreen - Connection filtering by protocol and status (connected/disconnected) - Hide Group field in ConnectionDialog when no groups exist - Improved bulk move popover with backdrop dismiss
1 parent e1f456f commit bed243b

20 files changed

Lines changed: 1053 additions & 54 deletions

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "netcopilot",
3-
"version": "0.11.6",
3+
"version": "0.11.7",
44
"description": "NetCopilot – AI-powered SSH/Telnet terminal for network engineers",
55
"main": "./out/main/index.js",
66
"author": {

src/main/db.ts

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import Database from 'better-sqlite3-multiple-ciphers'
22
import { app, safeStorage } from 'electron'
33
import path from 'path'
44
import { existsSync, readFileSync } from 'fs'
5-
import { Connection, ConnectionGroup, SSHKey } from '../types/shared'
5+
import { Connection, ConnectionGroup, SSHKey, Snippet, SnippetFolder } from '../types/shared'
66
import { getDbKey } from './dbKey'
77
import { DEFAULT_AI_BLACKLIST } from './aiDefaults'
88

@@ -61,6 +61,7 @@ function initSchema(db: Database.Database): void {
6161
serial_config TEXT,
6262
auto_reconnect INTEGER NOT NULL DEFAULT 1,
6363
reconnect_delay INTEGER NOT NULL DEFAULT 10,
64+
proxy_config TEXT,
6465
created_at INTEGER NOT NULL,
6566
updated_at INTEGER NOT NULL,
6667
last_connected_at INTEGER
@@ -92,9 +93,32 @@ function initSchema(db: Database.Database): void {
9293
last_used INTEGER NOT NULL,
9394
PRIMARY KEY (device_type, command)
9495
);
96+
97+
CREATE TABLE IF NOT EXISTS snippets (
98+
id TEXT PRIMARY KEY,
99+
name TEXT NOT NULL,
100+
command TEXT NOT NULL,
101+
description TEXT,
102+
folder_id TEXT,
103+
created_at INTEGER NOT NULL,
104+
updated_at INTEGER NOT NULL
105+
);
106+
107+
CREATE TABLE IF NOT EXISTS snippet_folders (
108+
id TEXT PRIMARY KEY,
109+
name TEXT NOT NULL
110+
);
95111
`)
96112

97113
// Seed default AI blacklist if missing or empty (handles both fresh installs and empty migrations)
114+
115+
// ── Schema migrations (add columns if missing) ──
116+
const cols = db.prepare("PRAGMA table_info(connections)").all() as { name: string }[]
117+
const colNames = new Set(cols.map(c => c.name))
118+
if (!colNames.has('proxy_config')) {
119+
db.exec("ALTER TABLE connections ADD COLUMN proxy_config TEXT")
120+
}
121+
98122
const blRow = db
99123
.prepare("SELECT value FROM settings WHERE key = 'ai.blacklist'")
100124
.get() as { value: string } | undefined
@@ -148,12 +172,12 @@ function migrateFromJson(db: Database.Database): void {
148172
INSERT OR IGNORE INTO connections
149173
(id, name, host, port, protocol, username, auth_type, ssh_key_id, group_id,
150174
tags, notes, device_type, color, jump_host_id, startup_commands,
151-
enable_password, serial_config, auto_reconnect, reconnect_delay,
175+
enable_password, serial_config, auto_reconnect, reconnect_delay, proxy_config,
152176
created_at, updated_at, last_connected_at)
153177
VALUES
154178
(@id, @name, @host, @port, @protocol, @username, @auth_type, @ssh_key_id, @group_id,
155179
@tags, @notes, @device_type, @color, @jump_host_id, @startup_commands,
156-
@enable_password, @serial_config, @auto_reconnect, @reconnect_delay,
180+
@enable_password, @serial_config, @auto_reconnect, @reconnect_delay, @proxy_config,
157181
@created_at, @updated_at, @last_connected_at)
158182
`)
159183
for (const c of data.connections ?? []) {
@@ -236,6 +260,7 @@ export function rowToConnection(row: Row): Connection {
236260
serialConfig: row.serial_config ? safeJsonParse(row.serial_config as string, undefined) : undefined,
237261
autoReconnect: Boolean(row.auto_reconnect),
238262
reconnectDelay: row.reconnect_delay as number,
263+
proxyConfig: row.proxy_config ? safeJsonParse(row.proxy_config as string, undefined) : undefined,
239264
createdAt: row.created_at as number,
240265
updatedAt: row.updated_at as number,
241266
lastConnectedAt: (row.last_connected_at as number) || undefined,
@@ -263,6 +288,7 @@ export function connToRow(c: Connection): Row {
263288
serial_config: c.serialConfig ? JSON.stringify(c.serialConfig) : null,
264289
auto_reconnect: c.autoReconnect ? 1 : 0,
265290
reconnect_delay: c.reconnectDelay ?? 10,
291+
proxy_config: c.proxyConfig ? JSON.stringify(c.proxyConfig) : null,
266292
created_at: c.createdAt,
267293
updated_at: c.updatedAt,
268294
last_connected_at: c.lastConnectedAt ?? null,
@@ -286,3 +312,22 @@ export function rowToSshKey(row: Row): SSHKey {
286312
createdAt: row.created_at as number,
287313
}
288314
}
315+
316+
export function rowToSnippet(row: Row): Snippet {
317+
return {
318+
id: row.id as string,
319+
name: row.name as string,
320+
command: row.command as string,
321+
description: (row.description as string) || undefined,
322+
folderId: (row.folder_id as string) || undefined,
323+
createdAt: row.created_at as number,
324+
updatedAt: row.updated_at as number,
325+
}
326+
}
327+
328+
export function rowToSnippetFolder(row: Row): SnippetFolder {
329+
return {
330+
id: row.id as string,
331+
name: row.name as string,
332+
}
333+
}

src/main/ssh.ts

Lines changed: 98 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,77 @@ function handleSocks4(sock: net.Socket, sshClient: Client, buf: Buffer): void {
152152
})
153153
}
154154

155+
function connectViaProxy(
156+
proxy: { type: 'socks5' | 'socks4' | 'http'; host: string; port: number; username?: string; password?: string },
157+
targetHost: string, targetPort: number
158+
): Promise<net.Socket> {
159+
return new Promise((resolve, reject) => {
160+
const sock = net.connect(proxy.port, proxy.host, () => {
161+
if (proxy.type === 'http') {
162+
const auth = proxy.username && proxy.password
163+
? `\r\nProxy-Authorization: Basic ${Buffer.from(`${proxy.username}:${proxy.password}`).toString('base64')}`
164+
: ''
165+
sock.write(`CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\nHost: ${targetHost}:${targetPort}${auth}\r\n\r\n`)
166+
sock.once('data', (chunk) => {
167+
const resp = chunk.toString()
168+
if (resp.includes('200')) resolve(sock)
169+
else { sock.destroy(); reject(new Error(`HTTP proxy rejected: ${resp.split('\r\n')[0]}`)) }
170+
})
171+
} else {
172+
// SOCKS4/5
173+
if (proxy.type === 'socks5') {
174+
const hasAuth = proxy.username && proxy.password
175+
const authMethods = hasAuth ? Buffer.from([0x05, 0x02, 0x00, 0x02]) : Buffer.from([0x05, 0x01, 0x00])
176+
sock.write(authMethods)
177+
sock.once('data', (greeting) => {
178+
if (greeting[1] === 0x02 && hasAuth) {
179+
const uBuf = Buffer.from(proxy.username!)
180+
const pBuf = Buffer.from(proxy.password!)
181+
const authBuf = Buffer.concat([Buffer.from([0x01, uBuf.length]), uBuf, Buffer.from([pBuf.length]), pBuf])
182+
sock.write(authBuf)
183+
sock.once('data', (authResp) => {
184+
if (authResp[1] !== 0x00) { sock.destroy(); return reject(new Error('SOCKS5 auth failed')) }
185+
sendSocks5Connect(sock, targetHost, targetPort, resolve, reject)
186+
})
187+
} else if (greeting[1] === 0x00) {
188+
sendSocks5Connect(sock, targetHost, targetPort, resolve, reject)
189+
} else {
190+
sock.destroy(); reject(new Error('SOCKS5 no acceptable auth method'))
191+
}
192+
})
193+
} else {
194+
// SOCKS4
195+
const portBuf = Buffer.alloc(2)
196+
portBuf.writeUInt16BE(targetPort, 0)
197+
const ipBuf = Buffer.from([0, 0, 0, 1]) // SOCKS4a: invalid IP
198+
const userBuf = Buffer.from(proxy.username ?? '')
199+
const hostBuf = Buffer.from(targetHost)
200+
const req = Buffer.concat([Buffer.from([0x04, 0x01]), portBuf, ipBuf, userBuf, Buffer.from([0x00]), hostBuf, Buffer.from([0x00])])
201+
sock.write(req)
202+
sock.once('data', (resp) => {
203+
if (resp[1] === 0x5a) resolve(sock)
204+
else { sock.destroy(); reject(new Error(`SOCKS4 rejected: code ${resp[1]}`)) }
205+
})
206+
}
207+
}
208+
})
209+
sock.on('error', (err) => reject(new Error(`Proxy connection failed: ${err.message}`)))
210+
sock.setTimeout(15000, () => { sock.destroy(); reject(new Error('Proxy connection timeout')) })
211+
})
212+
}
213+
214+
function sendSocks5Connect(sock: net.Socket, host: string, port: number, resolve: (s: net.Socket) => void, reject: (e: Error) => void) {
215+
const hostBuf = Buffer.from(host)
216+
const portBuf = Buffer.alloc(2)
217+
portBuf.writeUInt16BE(port, 0)
218+
const req = Buffer.concat([Buffer.from([0x05, 0x01, 0x00, 0x03, hostBuf.length]), hostBuf, portBuf])
219+
sock.write(req)
220+
sock.once('data', (resp) => {
221+
if (resp[1] === 0x00) { sock.setTimeout(0); resolve(sock) }
222+
else { sock.destroy(); reject(new Error(`SOCKS5 connect failed: code ${resp[1]}`)) }
223+
})
224+
}
225+
155226
export function setupSshHandlers(
156227
ipcMain: IpcMain,
157228
getWindow: () => BrowserWindow | null
@@ -180,6 +251,13 @@ export function setupSshHandlers(
180251
privateKey?: string
181252
passphrase?: string
182253
}
254+
proxy?: {
255+
type: 'socks5' | 'socks4' | 'http'
256+
host: string
257+
port: number
258+
username?: string
259+
password?: string
260+
}
183261
}
184262
) => {
185263
return new Promise((resolve, reject) => {
@@ -299,15 +377,26 @@ export function setupSshHandlers(
299377
))
300378

301379
} else {
302-
// ── Direct connection ─────────────────────────────────────────────────
303-
const client = new Client()
304-
client.on('error', (err) => settle({ success: false, error: err.message }))
305-
client.on('ready', () => openShell(client, null))
306-
client.connect(buildConnectConfig(
307-
payload.host, payload.port, payload.username,
308-
payload.password, payload.privateKey, payload.passphrase,
309-
payload.readyTimeout ?? 30000, payload.keepaliveInterval ?? 30000
310-
))
380+
// ── Direct connection (possibly through proxy) ─────────────────────
381+
const connectDirect = (sock?: net.Socket) => {
382+
const client = new Client()
383+
client.on('error', (err) => settle({ success: false, error: err.message }))
384+
client.on('ready', () => openShell(client, null))
385+
client.connect(buildConnectConfig(
386+
payload.host, payload.port, payload.username,
387+
payload.password, payload.privateKey, payload.passphrase,
388+
payload.readyTimeout ?? 30000, payload.keepaliveInterval ?? 30000,
389+
sock
390+
))
391+
}
392+
393+
if (payload.proxy) {
394+
connectViaProxy(payload.proxy, payload.host, payload.port)
395+
.then((proxySock) => connectDirect(proxySock))
396+
.catch((err) => settle({ success: false, error: `Proxy: ${err.message}` }))
397+
} else {
398+
connectDirect()
399+
}
311400
}
312401
})
313402
}

src/main/store.ts

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { IpcMain } from 'electron'
22
import * as net from 'net'
3-
import { Connection, ConnectionGroup, SSHKey } from '../types/shared'
4-
import { getDb, rowToConnection, connToRow, rowToGroup, rowToSshKey } from './db'
3+
import { Connection, ConnectionGroup, SSHKey, Snippet, SnippetFolder } from '../types/shared'
4+
import { getDb, rowToConnection, connToRow, rowToGroup, rowToSshKey, rowToSnippet, rowToSnippetFolder } from './db'
55

66
type Row = Record<string, unknown>
77

@@ -116,6 +116,59 @@ export function setupStoreHandlers(ipcMain: IpcMain): void {
116116
return true
117117
})
118118

119+
// ── Snippets ────────────────────────────────────────────────────────────────
120+
121+
ipcMain.handle('store:get-snippets', () => {
122+
const rows = getDb().prepare('SELECT * FROM snippets ORDER BY name ASC').all() as Row[]
123+
return rows.map(rowToSnippet)
124+
})
125+
126+
ipcMain.handle('store:save-snippet', (_, s: Snippet) => {
127+
getDb().prepare(`
128+
INSERT INTO snippets (id, name, command, description, folder_id, created_at, updated_at)
129+
VALUES (@id, @name, @command, @description, @folder_id, @created_at, @updated_at)
130+
ON CONFLICT(id) DO UPDATE SET
131+
name = excluded.name,
132+
command = excluded.command,
133+
description = excluded.description,
134+
folder_id = excluded.folder_id,
135+
updated_at = excluded.updated_at
136+
`).run({
137+
id: s.id,
138+
name: s.name,
139+
command: s.command,
140+
description: s.description ?? null,
141+
folder_id: s.folderId ?? null,
142+
created_at: s.createdAt,
143+
updated_at: s.updatedAt,
144+
})
145+
return s
146+
})
147+
148+
ipcMain.handle('store:delete-snippet', (_, id: string) => {
149+
getDb().prepare('DELETE FROM snippets WHERE id = ?').run(id)
150+
return true
151+
})
152+
153+
ipcMain.handle('store:get-snippet-folders', () => {
154+
const rows = getDb().prepare('SELECT * FROM snippet_folders ORDER BY name ASC').all() as Row[]
155+
return rows.map(rowToSnippetFolder)
156+
})
157+
158+
ipcMain.handle('store:save-snippet-folder', (_, f: SnippetFolder) => {
159+
getDb().prepare(`
160+
INSERT INTO snippet_folders (id, name) VALUES (@id, @name)
161+
ON CONFLICT(id) DO UPDATE SET name = excluded.name
162+
`).run({ id: f.id, name: f.name })
163+
return f
164+
})
165+
166+
ipcMain.handle('store:delete-snippet-folder', (_, id: string) => {
167+
getDb().prepare('DELETE FROM snippet_folders WHERE id = ?').run(id)
168+
getDb().prepare('UPDATE snippets SET folder_id = NULL WHERE folder_id = ?').run(id)
169+
return true
170+
})
171+
119172
// ── Settings ────────────────────────────────────────────────────────────────
120173

121174
ipcMain.handle('store:get-setting', (_, key: string) => {

src/preload/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ const api = {
1212
getSshKeys: () => ipcRenderer.invoke('store:get-ssh-keys'),
1313
saveSshKey: (key: unknown) => ipcRenderer.invoke('store:save-ssh-key', key),
1414
deleteSshKey: (id: string) => ipcRenderer.invoke('store:delete-ssh-key', id),
15+
getSnippets: () => ipcRenderer.invoke('store:get-snippets'),
16+
saveSnippet: (snippet: unknown) => ipcRenderer.invoke('store:save-snippet', snippet),
17+
deleteSnippet: (id: string) => ipcRenderer.invoke('store:delete-snippet', id),
18+
getSnippetFolders: () => ipcRenderer.invoke('store:get-snippet-folders'),
19+
saveSnippetFolder: (folder: unknown) => ipcRenderer.invoke('store:save-snippet-folder', folder),
20+
deleteSnippetFolder: (id: string) => ipcRenderer.invoke('store:delete-snippet-folder', id),
1521
getSetting: (key: string) => ipcRenderer.invoke('store:get-setting', key),
1622
setSetting: (key: string, value: unknown) => ipcRenderer.invoke('store:set-setting', key, value)
1723
},

src/renderer/src/App.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { cn, getInstallerUrl } from './lib/utils'
1616

1717
export default function App(): JSX.Element {
1818
const {
19-
loadConnections, loadGroups, loadSshKeys, loadSettings,
19+
loadConnections, loadGroups, loadSshKeys, loadSettings, loadSnippets, loadSnippetFolders,
2020
connections, sessions, activeSessionId,
2121
setQuickConnectOpen, setSettingsOpen, setAiPanelOpen, aiPanelOpen,
2222
setActiveSession, closeSession, setSplitSession, splitSessionId,
@@ -54,6 +54,8 @@ export default function App(): JSX.Element {
5454
loadGroups()
5555
loadSshKeys()
5656
loadSettings()
57+
loadSnippets()
58+
loadSnippetFolders()
5759
}
5860
}, [masterLocked])
5961

@@ -203,7 +205,7 @@ export default function App(): JSX.Element {
203205
if (masterLocked) {
204206
return <MasterPasswordLock onUnlocked={() => {
205207
setMasterLocked(false)
206-
loadConnections(); loadGroups(); loadSshKeys(); loadSettings()
208+
loadConnections(); loadGroups(); loadSshKeys(); loadSettings(); loadSnippets(); loadSnippetFolders()
207209
}} />
208210
}
209211

0 commit comments

Comments
 (0)