slash-port 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +209 -0
- package/dist/cli.js +238 -0
- package/dist/describe.js +264 -0
- package/dist/format.js +87 -0
- package/dist/kill.js +117 -0
- package/dist/scan/darwin.js +135 -0
- package/dist/scan/index.js +73 -0
- package/dist/scan/linux.js +239 -0
- package/dist/scan/shared.js +56 -0
- package/dist/scan/win32.js +148 -0
- package/dist/types.js +9 -0
- package/dist/ui/App.js +223 -0
- package/dist/ui/theme.js +86 -0
- package/package.json +57 -0
package/dist/describe.js
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { basename } from 'node:path';
|
|
2
|
+
export const SIGNATURES = [
|
|
3
|
+
// Node frameworks and dev servers, ahead of the runtime that hosts them.
|
|
4
|
+
{ pattern: /\bnext(-server)?\b|[/\\]next[/\\]dist[/\\]/i, label: 'Next.js' },
|
|
5
|
+
{ pattern: /\bnuxt\b/i, label: 'Nuxt' },
|
|
6
|
+
{ pattern: /[/\\]vite[/\\]|\bvite\b/i, label: 'Vite dev server' },
|
|
7
|
+
{ pattern: /\bastro\b/i, label: 'Astro' },
|
|
8
|
+
{ pattern: /\bremix\b/i, label: 'Remix' },
|
|
9
|
+
{ pattern: /\bsvelte-kit\b|\bsveltekit\b/i, label: 'SvelteKit' },
|
|
10
|
+
{ pattern: /\bgatsby\b/i, label: 'Gatsby' },
|
|
11
|
+
{ pattern: /\bnest\b|\bnestjs\b/i, label: 'NestJS' },
|
|
12
|
+
{ pattern: /\breact-scripts\b/i, label: 'Create React App' },
|
|
13
|
+
{ pattern: /\bwebpack(-dev-server)?\b/i, label: 'webpack dev server' },
|
|
14
|
+
{ pattern: /\bparcel\b/i, label: 'Parcel' },
|
|
15
|
+
{ pattern: /\bstorybook\b/i, label: 'Storybook' },
|
|
16
|
+
{ pattern: /\bexpo\b/i, label: 'Expo' },
|
|
17
|
+
{ pattern: /\bmetro\b/i, label: 'Metro bundler' },
|
|
18
|
+
{ pattern: /\bvitest\b/i, label: 'Vitest' },
|
|
19
|
+
{ pattern: /\bjest\b/i, label: 'Jest' },
|
|
20
|
+
{ pattern: /\bnodemon\b/i, label: 'nodemon' },
|
|
21
|
+
{ pattern: /\btsx\b|\bts-node\b/i, label: 'TypeScript dev process' },
|
|
22
|
+
// Deliberately narrow: a bare "serve" appears in half the daemons on a
|
|
23
|
+
// machine, so only the npm package's own paths count.
|
|
24
|
+
{ pattern: /\bhttp-server\b|npx\s+serve\b|node_modules[/\\]\.bin[/\\]serve\b/i, label: 'static file server' },
|
|
25
|
+
{ pattern: /\bpm2\b/i, label: 'PM2' },
|
|
26
|
+
// Python.
|
|
27
|
+
{ pattern: /\bgunicorn\b/i, label: 'Gunicorn' },
|
|
28
|
+
{ pattern: /\buvicorn\b/i, label: 'Uvicorn' },
|
|
29
|
+
{ pattern: /\bhypercorn\b/i, label: 'Hypercorn' },
|
|
30
|
+
{ pattern: /manage\.py\s+runserver/i, label: 'Django dev server' },
|
|
31
|
+
{ pattern: /\bdjango\b/i, label: 'Django' },
|
|
32
|
+
{ pattern: /\bflask\b/i, label: 'Flask' },
|
|
33
|
+
{ pattern: /\bfastapi\b/i, label: 'FastAPI' },
|
|
34
|
+
{ pattern: /\bstreamlit\b/i, label: 'Streamlit' },
|
|
35
|
+
{ pattern: /\bjupyter\b|\bipykernel\b/i, label: 'Jupyter' },
|
|
36
|
+
{ pattern: /\bcelery\b/i, label: 'Celery' },
|
|
37
|
+
{ pattern: /\bhttp\.server\b|SimpleHTTPServer/i, label: 'Python http.server' },
|
|
38
|
+
// Ruby, PHP, Java, Go, .NET.
|
|
39
|
+
{ pattern: /\bpuma\b/i, label: 'Puma' },
|
|
40
|
+
{ pattern: /\bunicorn\b/i, label: 'Unicorn' },
|
|
41
|
+
{ pattern: /\bsidekiq\b/i, label: 'Sidekiq' },
|
|
42
|
+
{ pattern: /\brails\b/i, label: 'Rails' },
|
|
43
|
+
{ pattern: /\bjekyll\b/i, label: 'Jekyll' },
|
|
44
|
+
{ pattern: /artisan\s+serve/i, label: 'Laravel dev server' },
|
|
45
|
+
{ pattern: /\bphp\b.*\s-S\s/i, label: 'PHP built-in server' },
|
|
46
|
+
{ pattern: /\bspring-boot\b|\borg\.springframework\b/i, label: 'Spring Boot' },
|
|
47
|
+
{ pattern: /\bgradle\b/i, label: 'Gradle' },
|
|
48
|
+
{ pattern: /\btomcat\b|\bcatalina\b/i, label: 'Tomcat' },
|
|
49
|
+
{ pattern: /(?:^|[/\\])air(?:\s|$)/i, label: 'Air (Go live reload)' },
|
|
50
|
+
{ pattern: /\bdotnet\b/i, label: '.NET' },
|
|
51
|
+
// Datastores and queues, matched on the binary name.
|
|
52
|
+
{ pattern: /\bpostgres(ql)?\b/i, label: 'PostgreSQL' },
|
|
53
|
+
{ pattern: /\bmysqld\b|\bmariadbd\b/i, label: 'MySQL / MariaDB' },
|
|
54
|
+
{ pattern: /\bmongod\b/i, label: 'MongoDB' },
|
|
55
|
+
{ pattern: /\bredis-(server|stack)\b/i, label: 'Redis' },
|
|
56
|
+
{ pattern: /\bvalkey\b/i, label: 'Valkey' },
|
|
57
|
+
{ pattern: /\bmemcached\b/i, label: 'Memcached' },
|
|
58
|
+
{ pattern: /\belasticsearch\b|\bopensearch\b/i, label: 'Elasticsearch / OpenSearch' },
|
|
59
|
+
{ pattern: /\bclickhouse\b/i, label: 'ClickHouse' },
|
|
60
|
+
{ pattern: /\brabbitmq\b|\bbeam\.smp\b/i, label: 'RabbitMQ' },
|
|
61
|
+
{ pattern: /\bkafka\b/i, label: 'Kafka' },
|
|
62
|
+
{ pattern: /\bzookeeper\b/i, label: 'ZooKeeper' },
|
|
63
|
+
{ pattern: /\betcd\b/i, label: 'etcd' },
|
|
64
|
+
{ pattern: /\bminio\b/i, label: 'MinIO' },
|
|
65
|
+
{ pattern: /\blocalstack\b/i, label: 'LocalStack' },
|
|
66
|
+
{ pattern: /\bollama\b/i, label: 'Ollama' },
|
|
67
|
+
{ pattern: /\blm-?studio\b/i, label: 'LM Studio' },
|
|
68
|
+
{ pattern: /\bsupabase\b/i, label: 'Supabase' },
|
|
69
|
+
// Containers, proxies, and system services.
|
|
70
|
+
{ pattern: /\bdocker-proxy\b/i, label: 'Docker published port' },
|
|
71
|
+
{ pattern: /com\.docker\./i, label: 'Docker Desktop' },
|
|
72
|
+
{ pattern: /\bcontainerd\b/i, label: 'containerd' },
|
|
73
|
+
{ pattern: /\bkubelet\b|\bk3s\b|\bminikube\b/i, label: 'Kubernetes' },
|
|
74
|
+
{ pattern: /\bnginx\b/i, label: 'nginx' },
|
|
75
|
+
{ pattern: /\bcaddy\b/i, label: 'Caddy' },
|
|
76
|
+
{ pattern: /\btraefik\b/i, label: 'Traefik' },
|
|
77
|
+
{ pattern: /\bhttpd\b|\bapache2\b/i, label: 'Apache' },
|
|
78
|
+
{ pattern: /\bngrok\b|\bcloudflared\b/i, label: 'tunnel client' },
|
|
79
|
+
{ pattern: /\bsshd\b/i, label: 'OpenSSH server' },
|
|
80
|
+
{ pattern: /\bmDNSResponder\b|\bavahi\b/i, label: 'mDNS / Bonjour' },
|
|
81
|
+
{ pattern: /\brapportd\b/i, label: 'macOS Handoff' },
|
|
82
|
+
{ pattern: /\bControlCe(nter|ntre)\b/i, label: 'macOS Control Center' },
|
|
83
|
+
{ pattern: /\bcupsd\b/i, label: 'CUPS printing' },
|
|
84
|
+
{ pattern: /\bsystemd-resolve/i, label: 'systemd-resolved' },
|
|
85
|
+
{ pattern: /\bdnsmasq\b/i, label: 'dnsmasq' },
|
|
86
|
+
// Runtimes last: they only win when nothing more specific matched.
|
|
87
|
+
{ pattern: /\bdeno\b/i, label: 'Deno' },
|
|
88
|
+
{ pattern: /\bbun\b/i, label: 'Bun' },
|
|
89
|
+
{ pattern: /\bnode(js)?\b/i, label: 'Node.js' },
|
|
90
|
+
{ pattern: /\bpython[\d.]*\b/i, label: 'Python' },
|
|
91
|
+
{ pattern: /\bruby\b/i, label: 'Ruby' },
|
|
92
|
+
{ pattern: /\bjava\b/i, label: 'Java' },
|
|
93
|
+
{ pattern: /\bphp\b/i, label: 'PHP' },
|
|
94
|
+
{ pattern: /\bperl\b/i, label: 'Perl' },
|
|
95
|
+
];
|
|
96
|
+
/**
|
|
97
|
+
* Well-known ports, biased towards the ones a developer actually meets rather
|
|
98
|
+
* than the full IANA list. Only consulted when the process could not be
|
|
99
|
+
* identified, which is mostly other users' processes.
|
|
100
|
+
*/
|
|
101
|
+
export const PORT_REGISTRY = {
|
|
102
|
+
20: { label: 'FTP data' },
|
|
103
|
+
21: { label: 'FTP' },
|
|
104
|
+
22: { label: 'SSH' },
|
|
105
|
+
25: { label: 'SMTP' },
|
|
106
|
+
53: { label: 'DNS' },
|
|
107
|
+
80: { label: 'HTTP' },
|
|
108
|
+
110: { label: 'POP3' },
|
|
109
|
+
111: { label: 'rpcbind' },
|
|
110
|
+
123: { label: 'NTP' },
|
|
111
|
+
143: { label: 'IMAP' },
|
|
112
|
+
389: { label: 'LDAP' },
|
|
113
|
+
443: { label: 'HTTPS' },
|
|
114
|
+
445: { label: 'SMB' },
|
|
115
|
+
465: { label: 'SMTPS' },
|
|
116
|
+
514: { label: 'syslog' },
|
|
117
|
+
548: { label: 'Apple Filing Protocol' },
|
|
118
|
+
587: { label: 'SMTP submission' },
|
|
119
|
+
631: { label: 'CUPS printing' },
|
|
120
|
+
853: { label: 'DNS over TLS' },
|
|
121
|
+
993: { label: 'IMAPS' },
|
|
122
|
+
1080: { label: 'SOCKS proxy' },
|
|
123
|
+
1433: { label: 'SQL Server' },
|
|
124
|
+
1521: { label: 'Oracle database' },
|
|
125
|
+
1883: { label: 'MQTT' },
|
|
126
|
+
1900: { label: 'SSDP / UPnP' },
|
|
127
|
+
2049: { label: 'NFS' },
|
|
128
|
+
2375: { label: 'Docker daemon (unencrypted)' },
|
|
129
|
+
2376: { label: 'Docker daemon (TLS)' },
|
|
130
|
+
3000: { label: 'dev server', generic: true },
|
|
131
|
+
3001: { label: 'dev server', generic: true },
|
|
132
|
+
3306: { label: 'MySQL / MariaDB' },
|
|
133
|
+
3478: { label: 'STUN / TURN' },
|
|
134
|
+
4200: { label: 'Angular dev server' },
|
|
135
|
+
4321: { label: 'Astro dev server' },
|
|
136
|
+
4873: { label: 'Verdaccio npm registry' },
|
|
137
|
+
5000: { label: 'AirPlay receiver or Flask' },
|
|
138
|
+
5173: { label: 'Vite dev server' },
|
|
139
|
+
5432: { label: 'PostgreSQL' },
|
|
140
|
+
5601: { label: 'Kibana' },
|
|
141
|
+
5672: { label: 'RabbitMQ (AMQP)' },
|
|
142
|
+
5900: { label: 'VNC / screen sharing' },
|
|
143
|
+
6006: { label: 'Storybook or TensorBoard' },
|
|
144
|
+
6379: { label: 'Redis' },
|
|
145
|
+
7000: { label: 'AirPlay' },
|
|
146
|
+
8000: { label: 'dev server', generic: true },
|
|
147
|
+
8025: { label: 'Mailpit / MailHog' },
|
|
148
|
+
8080: { label: 'HTTP alternate', generic: true },
|
|
149
|
+
8081: { label: 'HTTP alternate', generic: true },
|
|
150
|
+
8443: { label: 'HTTPS alternate' },
|
|
151
|
+
8888: { label: 'Jupyter' },
|
|
152
|
+
9000: { label: 'PHP-FPM or MinIO' },
|
|
153
|
+
9090: { label: 'Prometheus' },
|
|
154
|
+
9092: { label: 'Kafka' },
|
|
155
|
+
9200: { label: 'Elasticsearch' },
|
|
156
|
+
9229: { label: 'Node.js inspector' },
|
|
157
|
+
11211: { label: 'Memcached' },
|
|
158
|
+
15672: { label: 'RabbitMQ management' },
|
|
159
|
+
27017: { label: 'MongoDB' },
|
|
160
|
+
50000: { label: 'DB2 or SIP' },
|
|
161
|
+
54321: { label: 'Supabase / H2O' },
|
|
162
|
+
};
|
|
163
|
+
/** The text the heuristics run against: full command line, else process name. */
|
|
164
|
+
function searchable(socket) {
|
|
165
|
+
return socket.command?.trim() || socket.processName?.trim() || '';
|
|
166
|
+
}
|
|
167
|
+
export function matchSignature(text) {
|
|
168
|
+
if (!text)
|
|
169
|
+
return null;
|
|
170
|
+
for (const signature of SIGNATURES) {
|
|
171
|
+
if (signature.pattern.test(text))
|
|
172
|
+
return signature.label;
|
|
173
|
+
}
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* The project a dev server belongs to, taken as the directory above
|
|
178
|
+
* `node_modules` in its command line. Without it two Vite servers on 5173 and
|
|
179
|
+
* 5174 are indistinguishable, which is exactly when you need to tell them
|
|
180
|
+
* apart.
|
|
181
|
+
*/
|
|
182
|
+
export function projectHint(command) {
|
|
183
|
+
if (!command)
|
|
184
|
+
return null;
|
|
185
|
+
const modules = /([^\s"']+)[/\\]node_modules[/\\]/.exec(command);
|
|
186
|
+
if (modules?.[1]) {
|
|
187
|
+
const name = basename(modules[1]);
|
|
188
|
+
if (name && name !== '.' && name !== '/')
|
|
189
|
+
return name;
|
|
190
|
+
}
|
|
191
|
+
// Python and Ruby have no node_modules; fall back to the directory of the
|
|
192
|
+
// script argument when the command line names one.
|
|
193
|
+
const script = /(?:^|\s)([^\s"']*[/\\][^\s"']+\.(?:py|rb|js|ts|mjs|cjs))(?:\s|$)/.exec(command);
|
|
194
|
+
if (script?.[1]) {
|
|
195
|
+
const directory = script[1].replace(/[/\\][^/\\]+$/, '');
|
|
196
|
+
const name = basename(directory);
|
|
197
|
+
if (name && name !== '.' && name !== '/')
|
|
198
|
+
return name;
|
|
199
|
+
}
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Describe a port from three sources, in priority order: what the command line
|
|
204
|
+
* says it is, then the project it belongs to, then the well-known port
|
|
205
|
+
* registry as a fallback for processes that could not be identified at all.
|
|
206
|
+
*/
|
|
207
|
+
export function describe(socket) {
|
|
208
|
+
const text = searchable(socket);
|
|
209
|
+
const signature = matchSignature(text);
|
|
210
|
+
const hint = projectHint(socket.command);
|
|
211
|
+
if (signature)
|
|
212
|
+
return { label: signature, hint };
|
|
213
|
+
const registered = PORT_REGISTRY[socket.port];
|
|
214
|
+
if (registered && !registered.generic)
|
|
215
|
+
return { label: registered.label, hint };
|
|
216
|
+
if (socket.processName)
|
|
217
|
+
return { label: socket.processName, hint };
|
|
218
|
+
if (socket.pid === null)
|
|
219
|
+
return { label: 'owner not visible', hint };
|
|
220
|
+
return { label: 'unknown', hint };
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Processes that are never worth killing from here. Compared on the bare
|
|
224
|
+
* process name, lowercased and with any `.exe` removed.
|
|
225
|
+
*/
|
|
226
|
+
const PROTECTED_NAMES = {
|
|
227
|
+
init: 'the init process',
|
|
228
|
+
systemd: 'the init process',
|
|
229
|
+
launchd: 'the init process',
|
|
230
|
+
kernel_task: 'the kernel',
|
|
231
|
+
sshd: 'the SSH daemon — killing it locks you out of a remote machine',
|
|
232
|
+
'ssh-agent': 'the SSH agent',
|
|
233
|
+
loginwindow: 'the macOS session',
|
|
234
|
+
windowserver: 'the macOS window server',
|
|
235
|
+
systemuiserver: 'the macOS session',
|
|
236
|
+
dock: 'the macOS session',
|
|
237
|
+
finder: 'the macOS session',
|
|
238
|
+
csrss: 'a Windows session process',
|
|
239
|
+
wininit: 'a Windows session process',
|
|
240
|
+
winlogon: 'a Windows session process',
|
|
241
|
+
services: 'the Windows service controller',
|
|
242
|
+
lsass: 'the Windows security subsystem',
|
|
243
|
+
smss: 'a Windows session process',
|
|
244
|
+
svchost: 'a Windows service host — it runs many unrelated services',
|
|
245
|
+
system: 'the Windows kernel',
|
|
246
|
+
};
|
|
247
|
+
/**
|
|
248
|
+
* The reason killing this process is refused, or `null` when it is allowed.
|
|
249
|
+
* Refusal happens before any dialog is offered: there is no confirmation that
|
|
250
|
+
* lets you kill your own shell.
|
|
251
|
+
*/
|
|
252
|
+
export function guardReason(socket, context = { self: process.pid, parent: process.ppid }) {
|
|
253
|
+
const { pid } = socket;
|
|
254
|
+
if (pid === 1)
|
|
255
|
+
return 'the init process';
|
|
256
|
+
if (pid !== null && pid === context.self)
|
|
257
|
+
return 'slash-port itself';
|
|
258
|
+
if (pid !== null && pid === context.parent)
|
|
259
|
+
return 'the shell that launched slash-port';
|
|
260
|
+
const name = socket.processName?.trim().toLowerCase().replace(/\.exe$/, '');
|
|
261
|
+
if (name && name in PROTECTED_NAMES)
|
|
262
|
+
return PROTECTED_NAMES[name];
|
|
263
|
+
return null;
|
|
264
|
+
}
|
package/dist/format.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/** Shown wherever a value is genuinely absent, rather than an empty column. */
|
|
2
|
+
export const ABSENT = '-';
|
|
3
|
+
export function formatPort(entry) {
|
|
4
|
+
return `${entry.port}/${entry.protocol}`;
|
|
5
|
+
}
|
|
6
|
+
export function formatPid(entry) {
|
|
7
|
+
return entry.pid === null ? ABSENT : String(entry.pid);
|
|
8
|
+
}
|
|
9
|
+
export function formatUser(entry) {
|
|
10
|
+
return entry.user ?? ABSENT;
|
|
11
|
+
}
|
|
12
|
+
export function formatProcess(entry) {
|
|
13
|
+
return entry.processName ?? ABSENT;
|
|
14
|
+
}
|
|
15
|
+
export function formatAddresses(entry) {
|
|
16
|
+
return entry.addresses.join(', ');
|
|
17
|
+
}
|
|
18
|
+
/** The description column: what it is, which project, and whether it is protected. */
|
|
19
|
+
export function formatDescription(entry) {
|
|
20
|
+
const parts = [entry.label];
|
|
21
|
+
if (entry.hint)
|
|
22
|
+
parts.push(`(${entry.hint})`);
|
|
23
|
+
if (entry.guard)
|
|
24
|
+
parts.push('[protected]');
|
|
25
|
+
return parts.join(' ');
|
|
26
|
+
}
|
|
27
|
+
/** Everything a row can be matched on, lowercased once for filtering. */
|
|
28
|
+
export function searchText(entry) {
|
|
29
|
+
return [
|
|
30
|
+
String(entry.port),
|
|
31
|
+
entry.protocol,
|
|
32
|
+
formatPid(entry),
|
|
33
|
+
entry.user ?? '',
|
|
34
|
+
entry.processName ?? '',
|
|
35
|
+
entry.label,
|
|
36
|
+
entry.hint ?? '',
|
|
37
|
+
entry.addresses.join(' '),
|
|
38
|
+
entry.command ?? '',
|
|
39
|
+
]
|
|
40
|
+
.join(' ')
|
|
41
|
+
.toLowerCase();
|
|
42
|
+
}
|
|
43
|
+
export function matchesFilter(entry, filter) {
|
|
44
|
+
const needle = filter.trim().toLowerCase();
|
|
45
|
+
if (!needle)
|
|
46
|
+
return true;
|
|
47
|
+
return searchText(entry).includes(needle);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The plain-text table used whenever output is not a terminal. Aligned to the
|
|
51
|
+
* widest value in each column and containing no control codes at all, so it
|
|
52
|
+
* survives a pipe, a redirect, and a `grep`.
|
|
53
|
+
*/
|
|
54
|
+
export function plainTable(entries) {
|
|
55
|
+
const header = ['PORT', 'PID', 'USER', 'PROCESS', 'ADDRESS', 'DESCRIPTION'];
|
|
56
|
+
const rows = entries.map((entry) => [
|
|
57
|
+
formatPort(entry),
|
|
58
|
+
formatPid(entry),
|
|
59
|
+
formatUser(entry),
|
|
60
|
+
formatProcess(entry),
|
|
61
|
+
formatAddresses(entry),
|
|
62
|
+
formatDescription(entry),
|
|
63
|
+
]);
|
|
64
|
+
const widths = header.map((label, column) => Math.max(label.length, ...rows.map((row) => row[column].length), 0));
|
|
65
|
+
const render = (row) => row
|
|
66
|
+
.map((value, column) => (column === row.length - 1 ? value : value.padEnd(widths[column])))
|
|
67
|
+
.join(' ')
|
|
68
|
+
.trimEnd();
|
|
69
|
+
return [render(header), ...rows.map(render)].join('\n');
|
|
70
|
+
}
|
|
71
|
+
/** The `--json` shape. Stable, and a superset of what the table shows. */
|
|
72
|
+
export function toJson(entries) {
|
|
73
|
+
return entries.map((entry) => ({
|
|
74
|
+
port: entry.port,
|
|
75
|
+
protocol: entry.protocol,
|
|
76
|
+
addresses: entry.addresses,
|
|
77
|
+
families: entry.families,
|
|
78
|
+
pid: entry.pid,
|
|
79
|
+
user: entry.user,
|
|
80
|
+
process: entry.processName,
|
|
81
|
+
command: entry.command,
|
|
82
|
+
description: entry.label,
|
|
83
|
+
project: entry.hint,
|
|
84
|
+
protected: entry.guard !== null,
|
|
85
|
+
protectedReason: entry.guard,
|
|
86
|
+
}));
|
|
87
|
+
}
|
package/dist/kill.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { guardReason } from './describe.js';
|
|
2
|
+
const defaultWait = (ms) => new Promise((resolve) => {
|
|
3
|
+
setTimeout(resolve, ms);
|
|
4
|
+
});
|
|
5
|
+
/**
|
|
6
|
+
* Signal 0 asks the kernel whether the process exists and whether we may
|
|
7
|
+
* signal it, without delivering anything. `EPERM` means it exists but is
|
|
8
|
+
* someone else's, which deserves a different message from "gone".
|
|
9
|
+
*/
|
|
10
|
+
export function probe(pid, kill = process.kill) {
|
|
11
|
+
try {
|
|
12
|
+
kill(pid, 0);
|
|
13
|
+
return 'alive';
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
const failure = error;
|
|
17
|
+
if (failure.code === 'EPERM')
|
|
18
|
+
return 'denied';
|
|
19
|
+
return 'gone';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function describeTarget(entry) {
|
|
23
|
+
const name = entry.processName ?? entry.label;
|
|
24
|
+
return `${name} (pid ${entry.pid}) on port ${entry.port}`;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Kill whatever is holding a port.
|
|
28
|
+
*
|
|
29
|
+
* The order is fixed and every branch before the signal is a refusal, not a
|
|
30
|
+
* prompt: guardrails, then an unresolved owner, then a process that has
|
|
31
|
+
* already gone. SIGTERM is polled for up to `graceMs`; a process that outlives
|
|
32
|
+
* it is reported as `survived` rather than being escalated, because escalating
|
|
33
|
+
* is a second, deliberate action by the caller.
|
|
34
|
+
*
|
|
35
|
+
* On Windows there is no signal delivery: SIGTERM becomes TerminateProcess,
|
|
36
|
+
* which cannot be caught or ignored, so a process never gets the chance to
|
|
37
|
+
* shut down cleanly and `survived` is unreachable. The grace period still
|
|
38
|
+
* applies — it is how long the process is given to disappear.
|
|
39
|
+
*/
|
|
40
|
+
export async function killEntry(entry, options = {}) {
|
|
41
|
+
const { signal = 'SIGTERM', graceMs = 3000, pollMs = 50, kill = process.kill, wait = defaultWait } = options;
|
|
42
|
+
const guard = entry.guard ?? guardReason(entry);
|
|
43
|
+
if (guard) {
|
|
44
|
+
return {
|
|
45
|
+
status: 'refused',
|
|
46
|
+
signal: null,
|
|
47
|
+
message: `Refusing to kill ${guard}.`,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
if (entry.pid === null) {
|
|
51
|
+
return {
|
|
52
|
+
status: 'unresolved',
|
|
53
|
+
signal: null,
|
|
54
|
+
message: `Port ${entry.port} has an owner slash-port cannot see. Re-run with sudo to resolve it.`,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
const before = probe(entry.pid, kill);
|
|
58
|
+
if (before === 'gone') {
|
|
59
|
+
return {
|
|
60
|
+
status: 'gone',
|
|
61
|
+
signal: null,
|
|
62
|
+
message: `${describeTarget(entry)} had already exited. Nothing was signalled.`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
if (before === 'denied') {
|
|
66
|
+
return {
|
|
67
|
+
status: 'denied',
|
|
68
|
+
signal: null,
|
|
69
|
+
message: `${describeTarget(entry)} belongs to another user. Re-run with sudo to signal it.`,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
kill(entry.pid, signal);
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
const failure = error;
|
|
77
|
+
if (failure.code === 'ESRCH') {
|
|
78
|
+
return {
|
|
79
|
+
status: 'gone',
|
|
80
|
+
signal: null,
|
|
81
|
+
message: `${describeTarget(entry)} exited before the signal was delivered.`,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
if (failure.code === 'EPERM') {
|
|
85
|
+
return {
|
|
86
|
+
status: 'denied',
|
|
87
|
+
signal: null,
|
|
88
|
+
message: `${describeTarget(entry)} belongs to another user. Re-run with sudo to signal it.`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
status: 'failed',
|
|
93
|
+
signal: null,
|
|
94
|
+
message: `Could not signal ${describeTarget(entry)}: ${failure.message}`,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
const deadline = graceMs;
|
|
98
|
+
let waited = 0;
|
|
99
|
+
while (waited <= deadline) {
|
|
100
|
+
if (probe(entry.pid, kill) !== 'alive') {
|
|
101
|
+
return {
|
|
102
|
+
status: 'terminated',
|
|
103
|
+
signal,
|
|
104
|
+
message: `${describeTarget(entry)} exited after ${signal}.`,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
await wait(pollMs);
|
|
108
|
+
waited += pollMs;
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
status: 'survived',
|
|
112
|
+
signal,
|
|
113
|
+
message: signal === 'SIGKILL'
|
|
114
|
+
? `${describeTarget(entry)} did not exit, even after SIGKILL. It is probably stuck in the kernel.`
|
|
115
|
+
: `${describeTarget(entry)} ignored SIGTERM after ${graceMs}ms. Force it with SIGKILL to escalate.`,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { run, splitHostPort } from './shared.js';
|
|
2
|
+
import { ScanError } from '../types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Parse lsof field output (`-F`), which emits one tagged field per line:
|
|
5
|
+
* `p`/`c`/`L` open a process set, `f` opens a file set within it, and `n`/`t`
|
|
6
|
+
* describe that file. Far more robust than parsing lsof's aligned columns,
|
|
7
|
+
* which shift with the width of the values in them.
|
|
8
|
+
*/
|
|
9
|
+
export function parseLsof(output) {
|
|
10
|
+
const records = [];
|
|
11
|
+
let pid = null;
|
|
12
|
+
let command = null;
|
|
13
|
+
let user = null;
|
|
14
|
+
let name = null;
|
|
15
|
+
let type = null;
|
|
16
|
+
const flushFile = () => {
|
|
17
|
+
if (pid === null || name === null)
|
|
18
|
+
return;
|
|
19
|
+
// `a:b->c:d` is a connected socket, not something listening.
|
|
20
|
+
if (!name.includes('->')) {
|
|
21
|
+
const split = splitHostPort(name);
|
|
22
|
+
if (split && split.port > 0) {
|
|
23
|
+
records.push({
|
|
24
|
+
pid,
|
|
25
|
+
command,
|
|
26
|
+
user,
|
|
27
|
+
address: split.address,
|
|
28
|
+
port: split.port,
|
|
29
|
+
family: type === 'IPv6' ? 6 : 4,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
name = null;
|
|
34
|
+
type = null;
|
|
35
|
+
};
|
|
36
|
+
for (const line of output.split('\n')) {
|
|
37
|
+
if (!line)
|
|
38
|
+
continue;
|
|
39
|
+
const tag = line[0];
|
|
40
|
+
const value = line.slice(1);
|
|
41
|
+
switch (tag) {
|
|
42
|
+
case 'p': {
|
|
43
|
+
flushFile();
|
|
44
|
+
const parsed = Number.parseInt(value, 10);
|
|
45
|
+
pid = Number.isInteger(parsed) ? parsed : null;
|
|
46
|
+
command = null;
|
|
47
|
+
user = null;
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
case 'c':
|
|
51
|
+
command = value || null;
|
|
52
|
+
break;
|
|
53
|
+
case 'L':
|
|
54
|
+
user = value || null;
|
|
55
|
+
break;
|
|
56
|
+
case 'f':
|
|
57
|
+
flushFile();
|
|
58
|
+
break;
|
|
59
|
+
case 'n':
|
|
60
|
+
name = value;
|
|
61
|
+
break;
|
|
62
|
+
case 't':
|
|
63
|
+
type = value;
|
|
64
|
+
break;
|
|
65
|
+
default:
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
flushFile();
|
|
70
|
+
return records;
|
|
71
|
+
}
|
|
72
|
+
/** Parse `ps -axww -o pid=,user=,args=` into pid → user and full command line. */
|
|
73
|
+
export function parsePs(output) {
|
|
74
|
+
const processes = new Map();
|
|
75
|
+
for (const line of output.split('\n')) {
|
|
76
|
+
const match = /^\s*(\d+)\s+(\S+)\s+(.*)$/.exec(line);
|
|
77
|
+
if (!match)
|
|
78
|
+
continue;
|
|
79
|
+
processes.set(Number.parseInt(match[1], 10), { user: match[2], command: match[3].trim() });
|
|
80
|
+
}
|
|
81
|
+
return processes;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* lsof exits 1 when it matched nothing, which is a normal outcome rather than
|
|
85
|
+
* a failure, so its stdout is used either way.
|
|
86
|
+
*/
|
|
87
|
+
async function runLsof(args) {
|
|
88
|
+
try {
|
|
89
|
+
const { stdout } = await run('lsof', args);
|
|
90
|
+
return stdout;
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
const failure = error;
|
|
94
|
+
if (failure.code === 'ENOENT') {
|
|
95
|
+
throw new ScanError('lsof is not installed, and macOS needs it to read the socket table.', 'Install it with `brew install lsof`, or use the system copy at /usr/sbin/lsof.');
|
|
96
|
+
}
|
|
97
|
+
if (typeof failure.stdout === 'string')
|
|
98
|
+
return failure.stdout;
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
export async function scanDarwin(options = {}) {
|
|
103
|
+
const jobs = [
|
|
104
|
+
runLsof(['-nP', '-iTCP', '-sTCP:LISTEN', '-FpcLfnt']).then((stdout) => ({
|
|
105
|
+
protocol: 'tcp',
|
|
106
|
+
records: parseLsof(stdout),
|
|
107
|
+
})),
|
|
108
|
+
];
|
|
109
|
+
if (options.udp) {
|
|
110
|
+
jobs.push(runLsof(['-nP', '-iUDP', '-FpcLfnt']).then((stdout) => ({
|
|
111
|
+
protocol: 'udp',
|
|
112
|
+
records: parseLsof(stdout),
|
|
113
|
+
})));
|
|
114
|
+
}
|
|
115
|
+
// lsof reports a truncated command name; ps supplies the full command line
|
|
116
|
+
// the description heuristics need. One extra spawn, and it can fail safely.
|
|
117
|
+
const [groups, psOutput] = await Promise.all([
|
|
118
|
+
Promise.all(jobs),
|
|
119
|
+
run('ps', ['-axww', '-o', 'pid=,user=,args=']).then((result) => result.stdout, () => ''),
|
|
120
|
+
]);
|
|
121
|
+
const details = parsePs(psOutput);
|
|
122
|
+
return groups.flatMap(({ protocol, records }) => records.map((record) => {
|
|
123
|
+
const detail = details.get(record.pid);
|
|
124
|
+
return {
|
|
125
|
+
protocol,
|
|
126
|
+
family: record.family,
|
|
127
|
+
address: record.address,
|
|
128
|
+
port: record.port,
|
|
129
|
+
pid: record.pid,
|
|
130
|
+
processName: record.command,
|
|
131
|
+
command: detail?.command ?? null,
|
|
132
|
+
user: detail?.user ?? record.user,
|
|
133
|
+
};
|
|
134
|
+
}));
|
|
135
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, guardReason } from '../describe.js';
|
|
2
|
+
import { ScanError } from '../types.js';
|
|
3
|
+
import { scanDarwin } from './darwin.js';
|
|
4
|
+
import { scanLinux } from './linux.js';
|
|
5
|
+
import { normaliseAddress } from './shared.js';
|
|
6
|
+
import { scanWin32 } from './win32.js';
|
|
7
|
+
export { ScanError } from '../types.js';
|
|
8
|
+
const PROTOCOL_ORDER = { tcp: 0, udp: 1 };
|
|
9
|
+
/**
|
|
10
|
+
* Collapse the sockets of one server into one row.
|
|
11
|
+
*
|
|
12
|
+
* A process bound to both `0.0.0.0` and `::` on port 3000 is one dev server,
|
|
13
|
+
* not two, and listing it twice makes a busy machine unreadable. Rows with an
|
|
14
|
+
* unresolved pid stay separate from owned ones, because there is no evidence
|
|
15
|
+
* they are the same process.
|
|
16
|
+
*/
|
|
17
|
+
export function collapse(sockets) {
|
|
18
|
+
const groups = new Map();
|
|
19
|
+
for (const socket of sockets) {
|
|
20
|
+
const id = `${socket.protocol}:${socket.port}:${socket.pid ?? 'unowned'}`;
|
|
21
|
+
const group = groups.get(id);
|
|
22
|
+
if (group)
|
|
23
|
+
group.sockets.push(socket);
|
|
24
|
+
else
|
|
25
|
+
groups.set(id, { sockets: [socket] });
|
|
26
|
+
}
|
|
27
|
+
const entries = [];
|
|
28
|
+
for (const [id, group] of groups) {
|
|
29
|
+
const first = group.sockets[0];
|
|
30
|
+
// Prefer whichever socket carried the most process detail; on Linux the
|
|
31
|
+
// IPv4 and IPv6 rows of one server can resolve differently.
|
|
32
|
+
const richest = group.sockets.find((socket) => socket.command) ?? first;
|
|
33
|
+
// Normalised here as well as in the scanners, so this funnel is the one
|
|
34
|
+
// place that decides how an address is displayed.
|
|
35
|
+
const addresses = [...new Set(group.sockets.map((socket) => normaliseAddress(socket.address)))].sort();
|
|
36
|
+
const families = [...new Set(group.sockets.map((socket) => socket.family))].sort();
|
|
37
|
+
const base = {
|
|
38
|
+
id,
|
|
39
|
+
protocol: first.protocol,
|
|
40
|
+
port: first.port,
|
|
41
|
+
addresses,
|
|
42
|
+
families,
|
|
43
|
+
pid: first.pid,
|
|
44
|
+
processName: richest.processName ?? first.processName,
|
|
45
|
+
command: richest.command,
|
|
46
|
+
user: richest.user ?? first.user,
|
|
47
|
+
};
|
|
48
|
+
const description = describe(base);
|
|
49
|
+
entries.push({
|
|
50
|
+
...base,
|
|
51
|
+
label: description.label,
|
|
52
|
+
hint: description.hint,
|
|
53
|
+
guard: guardReason(base),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return sortEntries(entries);
|
|
57
|
+
}
|
|
58
|
+
export function sortEntries(entries) {
|
|
59
|
+
return entries.sort((a, b) => a.port - b.port || PROTOCOL_ORDER[a.protocol] - PROTOCOL_ORDER[b.protocol] || (a.pid ?? 0) - (b.pid ?? 0));
|
|
60
|
+
}
|
|
61
|
+
/** Read the local socket table. Nothing in this path touches the network. */
|
|
62
|
+
export async function scan(options = {}) {
|
|
63
|
+
switch (process.platform) {
|
|
64
|
+
case 'linux':
|
|
65
|
+
return collapse(await scanLinux(options));
|
|
66
|
+
case 'darwin':
|
|
67
|
+
return collapse(await scanDarwin(options));
|
|
68
|
+
case 'win32':
|
|
69
|
+
return collapse(await scanWin32(options));
|
|
70
|
+
default:
|
|
71
|
+
throw new ScanError(`slash-port has no scanner for ${process.platform}.`, 'Supported platforms are Linux, macOS, and Windows.');
|
|
72
|
+
}
|
|
73
|
+
}
|