unplugin-https-reverse-proxy 4.0.4 → 4.1.0-beta.10
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/README.md +30 -6
- package/dist/{chunk-2P7SUCQ3.js → chunk-EY3HXBJ4.js} +0 -26
- package/dist/chunk-RKHJN6KY.js +53 -0
- package/dist/{chunk-5R33PUJP.js → chunk-U7KAIPZC.js} +981 -796
- package/dist/chunk-WOMR3ZLM.js +17 -0
- package/dist/esbuild.cjs +1077 -864
- package/dist/esbuild.js +4 -2
- package/dist/index.cjs +1077 -864
- package/dist/index.js +4 -2
- package/dist/nuxt.cjs +1049 -850
- package/dist/nuxt.js +6 -9
- package/dist/privilege-state.cjs +44 -0
- package/dist/privilege-state.d.cts +7 -0
- package/dist/privilege-state.d.ts +7 -0
- package/dist/privilege-state.js +10 -0
- package/dist/privileged-helper.cjs +824 -0
- package/dist/privileged-helper.d.cts +2 -0
- package/dist/privileged-helper.d.ts +2 -0
- package/dist/privileged-helper.js +797 -0
- package/dist/rollup.cjs +1077 -864
- package/dist/rollup.js +4 -2
- package/dist/rspack.cjs +1077 -864
- package/dist/rspack.js +4 -2
- package/dist/utils.cjs +0 -28
- package/dist/utils.d.cts +1 -3
- package/dist/utils.d.ts +1 -3
- package/dist/utils.js +1 -5
- package/dist/vite.cjs +1077 -864
- package/dist/vite.js +4 -2
- package/dist/webpack.cjs +1077 -864
- package/dist/webpack.js +4 -2
- package/package.json +1 -4
- package/src/caddy/caddyfile.ts +3 -0
- package/src/caddy/config.ts +195 -53
- package/src/caddy/constants.ts +30 -1
- package/src/caddy/index.ts +239 -206
- package/src/caddy/privileged.ts +124 -0
- package/src/caddy/utils.ts +0 -43
- package/src/index.ts +49 -22
- package/src/nuxt.ts +1 -6
- package/src/privilege-state.ts +23 -0
- package/src/privileged/bootstrap.ts +289 -0
- package/src/privileged/client.ts +288 -0
- package/src/privileged/entry.ts +73 -0
- package/src/privileged/hosts-file.ts +142 -0
- package/src/privileged/launch.ts +332 -0
- package/src/privileged/path-guard.ts +74 -0
- package/src/privileged/ports.ts +45 -0
- package/src/privileged/protocol.ts +111 -0
- package/src/privileged/server.ts +578 -0
- package/src/privileged/session.ts +169 -0
- package/src/privileged/validation.ts +38 -0
- package/src/privileged/windows-elevation.ts +80 -0
- package/src/privileged-helper.ts +123 -0
- package/src/utils.ts +0 -28
- package/src/host/index.ts +0 -52
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import type { Buffer } from 'node:buffer'
|
|
2
|
+
import type { Socket } from 'node:net'
|
|
3
|
+
import type {
|
|
4
|
+
ClientHandshake,
|
|
5
|
+
HelperEvent,
|
|
6
|
+
HelperMessage,
|
|
7
|
+
HelperRequest,
|
|
8
|
+
RpcId,
|
|
9
|
+
ServerHandshake,
|
|
10
|
+
} from './protocol'
|
|
11
|
+
import { connect } from 'node:net'
|
|
12
|
+
import process from 'node:process'
|
|
13
|
+
import { decodeMessage, serializeMessage, splitLines } from './protocol'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Environment variables the privileged helper needs so Caddy behaves like the
|
|
17
|
+
* user's own process (PKI location, proxy settings) without `sudo -E`.
|
|
18
|
+
*/
|
|
19
|
+
export const FORWARDED_ENV_KEYS = [
|
|
20
|
+
'HOME',
|
|
21
|
+
'USER',
|
|
22
|
+
'LOGNAME',
|
|
23
|
+
'TMPDIR',
|
|
24
|
+
'HTTP_PROXY',
|
|
25
|
+
'HTTPS_PROXY',
|
|
26
|
+
'http_proxy',
|
|
27
|
+
'https_proxy',
|
|
28
|
+
'NO_PROXY',
|
|
29
|
+
'no_proxy',
|
|
30
|
+
] as const
|
|
31
|
+
|
|
32
|
+
export function collectForwardedEnv(source: NodeJS.ProcessEnv = process.env): Record<string, string> {
|
|
33
|
+
const result: Record<string, string> = {}
|
|
34
|
+
for (const key of FORWARDED_ENV_KEYS) {
|
|
35
|
+
const value = source[key]
|
|
36
|
+
if (typeof value === 'string' && value !== '')
|
|
37
|
+
result[key] = value
|
|
38
|
+
}
|
|
39
|
+
return result
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface HelperResults {
|
|
43
|
+
'ping': { pid: number }
|
|
44
|
+
'hosts.add': void
|
|
45
|
+
'hosts.remove': void
|
|
46
|
+
'port.free': { killed: number[] }
|
|
47
|
+
/** `started: false` means Caddy was already running and was reused. */
|
|
48
|
+
'caddy.start': { pid?: number, started?: boolean }
|
|
49
|
+
'caddy.stop': void
|
|
50
|
+
'ca.trust': { trusted: boolean, output: string }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class HelperConnectionError extends Error {
|
|
54
|
+
constructor(message: string) {
|
|
55
|
+
super(message)
|
|
56
|
+
this.name = 'HelperConnectionError'
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface PrivilegedClient {
|
|
61
|
+
readonly connected: boolean
|
|
62
|
+
call: <M extends keyof HelperResults>(method: M, ...params: HelperParams<M>) => Promise<HelperResults[M]>
|
|
63
|
+
onEvent: (listener: (event: HelperEvent) => void) => () => void
|
|
64
|
+
close: () => Promise<void>
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
type HelperParams<M extends keyof HelperResults>
|
|
68
|
+
= M extends 'hosts.add' | 'hosts.remove' ? [params: { ip: string, host: string }]
|
|
69
|
+
: M extends 'port.free' ? [params: { port: number }]
|
|
70
|
+
: []
|
|
71
|
+
|
|
72
|
+
interface PendingCall {
|
|
73
|
+
resolve: (value: unknown) => void
|
|
74
|
+
reject: (error: Error) => void
|
|
75
|
+
timer: ReturnType<typeof setTimeout>
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface ConnectOptions {
|
|
79
|
+
socketPath: string
|
|
80
|
+
token: string
|
|
81
|
+
/** Total time to wait for the helper to accept the connection. */
|
|
82
|
+
timeoutMs?: number
|
|
83
|
+
retryIntervalMs?: number
|
|
84
|
+
callTimeoutMs?: number
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function openSocket(socketPath: string): Promise<Socket> {
|
|
88
|
+
return new Promise<Socket>((resolve, reject) => {
|
|
89
|
+
const socket = connect(socketPath)
|
|
90
|
+
|
|
91
|
+
function onConnect(): void {
|
|
92
|
+
socket.removeListener('error', onError)
|
|
93
|
+
resolve(socket)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function onError(error: Error): void {
|
|
97
|
+
socket.removeListener('connect', onConnect)
|
|
98
|
+
reject(error)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
socket.once('error', onError)
|
|
102
|
+
socket.once('connect', onConnect)
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Connects to an already-running helper and performs the token handshake.
|
|
108
|
+
* Retries because the helper may still be starting while sudo asks for a password.
|
|
109
|
+
*/
|
|
110
|
+
export async function connectClient(options: ConnectOptions): Promise<PrivilegedClient> {
|
|
111
|
+
const {
|
|
112
|
+
socketPath,
|
|
113
|
+
token,
|
|
114
|
+
timeoutMs = 120_000,
|
|
115
|
+
retryIntervalMs = 100,
|
|
116
|
+
callTimeoutMs = 30_000,
|
|
117
|
+
} = options
|
|
118
|
+
|
|
119
|
+
const deadline = Date.now() + timeoutMs
|
|
120
|
+
let lastError: Error | undefined
|
|
121
|
+
|
|
122
|
+
while (Date.now() < deadline) {
|
|
123
|
+
try {
|
|
124
|
+
return await handshake(socketPath, token, callTimeoutMs)
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
lastError = error as Error
|
|
128
|
+
|
|
129
|
+
// A permission or protocol error will not resolve by waiting, and
|
|
130
|
+
// retrying hides the real cause behind a silent hang.
|
|
131
|
+
const code = (lastError as NodeJS.ErrnoException).code
|
|
132
|
+
if (code === 'EACCES' || lastError instanceof HelperConnectionError)
|
|
133
|
+
throw lastError
|
|
134
|
+
|
|
135
|
+
await new Promise(resolve => setTimeout(resolve, retryIntervalMs))
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
throw new HelperConnectionError(
|
|
140
|
+
`privileged helper is not reachable at ${socketPath}: ${lastError?.message ?? 'timed out'}`,
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function handshake(socketPath: string, token: string, callTimeoutMs: number): Promise<PrivilegedClient> {
|
|
145
|
+
return new Promise<PrivilegedClient>((resolve, reject) => {
|
|
146
|
+
void openSocket(socketPath).then((socket) => {
|
|
147
|
+
let buffer = ''
|
|
148
|
+
let settled = false
|
|
149
|
+
|
|
150
|
+
const fail = (error: Error): void => {
|
|
151
|
+
if (settled)
|
|
152
|
+
return
|
|
153
|
+
settled = true
|
|
154
|
+
socket.destroy()
|
|
155
|
+
reject(error)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const onData = (chunk: Buffer): void => {
|
|
159
|
+
if (settled)
|
|
160
|
+
return
|
|
161
|
+
|
|
162
|
+
const split = splitLines(buffer, chunk.toString('utf8'))
|
|
163
|
+
buffer = split.rest
|
|
164
|
+
const line = split.lines[0]
|
|
165
|
+
if (line === undefined)
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
let hello: ServerHandshake
|
|
169
|
+
try {
|
|
170
|
+
hello = JSON.parse(line) as ServerHandshake
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
return fail(new HelperConnectionError(`invalid handshake reply: ${(error as Error).message}`))
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (hello.type !== 'handshake')
|
|
177
|
+
return fail(new HelperConnectionError('helper did not answer the handshake'))
|
|
178
|
+
|
|
179
|
+
if (!hello.ok)
|
|
180
|
+
return fail(new HelperConnectionError(hello.reason ?? 'helper rejected the handshake'))
|
|
181
|
+
|
|
182
|
+
settled = true
|
|
183
|
+
socket.removeListener('data', onData)
|
|
184
|
+
socket.removeListener('error', onError)
|
|
185
|
+
const remaining = split.lines.slice(1)
|
|
186
|
+
resolve(createClient(socket, [split.rest, ...remaining].filter(Boolean).join('\n'), callTimeoutMs))
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function onError(error: Error): void {
|
|
190
|
+
fail(error)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
socket.on('data', onData)
|
|
194
|
+
socket.on('error', onError)
|
|
195
|
+
|
|
196
|
+
const greeting: ClientHandshake = { type: 'handshake', token }
|
|
197
|
+
socket.write(serializeMessage(greeting))
|
|
198
|
+
}, reject)
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function createClient(socket: Socket, initialBuffer: string, callTimeoutMs: number): PrivilegedClient {
|
|
203
|
+
let buffer = initialBuffer
|
|
204
|
+
let connected = true
|
|
205
|
+
let nextId: RpcId = 1
|
|
206
|
+
|
|
207
|
+
const pending = new Map<RpcId, PendingCall>()
|
|
208
|
+
const listeners = new Set<(event: HelperEvent) => void>()
|
|
209
|
+
|
|
210
|
+
socket.on('data', (chunk: Buffer) => {
|
|
211
|
+
const split = splitLines(buffer, chunk.toString('utf8'))
|
|
212
|
+
buffer = split.rest
|
|
213
|
+
|
|
214
|
+
for (const line of split.lines) {
|
|
215
|
+
const decoded = decodeMessage(line)
|
|
216
|
+
if (!decoded.ok)
|
|
217
|
+
continue
|
|
218
|
+
|
|
219
|
+
const message: HelperMessage = decoded.message
|
|
220
|
+
if (!('id' in message)) {
|
|
221
|
+
for (const listener of listeners)
|
|
222
|
+
listener(message)
|
|
223
|
+
continue
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const entry = pending.get(message.id)
|
|
227
|
+
if (!entry)
|
|
228
|
+
continue
|
|
229
|
+
|
|
230
|
+
pending.delete(message.id)
|
|
231
|
+
clearTimeout(entry.timer)
|
|
232
|
+
|
|
233
|
+
if (message.ok)
|
|
234
|
+
entry.resolve(message.result)
|
|
235
|
+
else
|
|
236
|
+
entry.reject(new Error(message.error.message))
|
|
237
|
+
}
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
const disconnect = (): void => {
|
|
241
|
+
if (!connected)
|
|
242
|
+
return
|
|
243
|
+
connected = false
|
|
244
|
+
for (const [id, entry] of pending) {
|
|
245
|
+
clearTimeout(entry.timer)
|
|
246
|
+
pending.delete(id)
|
|
247
|
+
entry.reject(new HelperConnectionError('privileged helper connection closed'))
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
socket.on('close', disconnect)
|
|
252
|
+
socket.on('error', disconnect)
|
|
253
|
+
|
|
254
|
+
function call(method: keyof HelperResults, params?: unknown): Promise<unknown> {
|
|
255
|
+
if (!connected)
|
|
256
|
+
return Promise.reject(new HelperConnectionError('privileged helper is not connected'))
|
|
257
|
+
|
|
258
|
+
const id = nextId++
|
|
259
|
+
const request = { id, method, params } as unknown as HelperRequest
|
|
260
|
+
|
|
261
|
+
return new Promise<unknown>((resolve, reject) => {
|
|
262
|
+
const timer = setTimeout(() => {
|
|
263
|
+
pending.delete(id)
|
|
264
|
+
reject(new HelperConnectionError(`privileged helper did not answer "${method}" in time`))
|
|
265
|
+
}, callTimeoutMs)
|
|
266
|
+
|
|
267
|
+
pending.set(id, { resolve, reject, timer })
|
|
268
|
+
socket.write(serializeMessage(request))
|
|
269
|
+
})
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return {
|
|
273
|
+
get connected() {
|
|
274
|
+
return connected
|
|
275
|
+
},
|
|
276
|
+
call: call as PrivilegedClient['call'],
|
|
277
|
+
onEvent(listener) {
|
|
278
|
+
listeners.add(listener)
|
|
279
|
+
return () => {
|
|
280
|
+
listeners.delete(listener)
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
async close() {
|
|
284
|
+
disconnect()
|
|
285
|
+
socket.destroy()
|
|
286
|
+
},
|
|
287
|
+
}
|
|
288
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
import process from 'node:process'
|
|
4
|
+
|
|
5
|
+
export interface HelperEntry {
|
|
6
|
+
/**
|
|
7
|
+
* Executable started through sudo. This is the node runtime, so the caller
|
|
8
|
+
* only has to append `args` and the helper's own flags.
|
|
9
|
+
*/
|
|
10
|
+
entry: string
|
|
11
|
+
/** Script (and loader) arguments that follow the runtime. */
|
|
12
|
+
args: string[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ResolveHelperEntryOptions {
|
|
16
|
+
/** Directory of the running bundle: `dist` in published builds, `src` from source. */
|
|
17
|
+
moduleDir: string
|
|
18
|
+
/** Node executable used to run a compiled helper. */
|
|
19
|
+
nodeBin?: string
|
|
20
|
+
exists?: (path: string) => boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Locates the helper entry next to the running module.
|
|
25
|
+
*
|
|
26
|
+
* Published builds ship a compiled `privileged-helper.js` and start it with
|
|
27
|
+
* node; running from the repository uses `tsx` so the TypeScript source works
|
|
28
|
+
* without a build step.
|
|
29
|
+
*
|
|
30
|
+
* The running module sits in a subdirectory (`src/caddy`, `src/privileged`),
|
|
31
|
+
* while the helper lives one level up in `src`, so both lookups walk up instead
|
|
32
|
+
* of assuming a fixed depth.
|
|
33
|
+
*/
|
|
34
|
+
export function resolveHelperEntry(options: ResolveHelperEntryOptions): HelperEntry {
|
|
35
|
+
const { moduleDir, nodeBin = process.execPath, exists = existsSync } = options
|
|
36
|
+
|
|
37
|
+
for (const name of ['privileged-helper.js', 'privileged-helper.cjs', 'privileged-helper.mjs']) {
|
|
38
|
+
const candidate = findUp(moduleDir, name, exists)
|
|
39
|
+
if (candidate)
|
|
40
|
+
return { entry: nodeBin, args: [candidate] }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const source = findUp(moduleDir, 'privileged-helper.ts', exists)
|
|
44
|
+
const tsxCli = findUp(moduleDir, 'node_modules/tsx/dist/cli.mjs', exists)
|
|
45
|
+
if (source && tsxCli)
|
|
46
|
+
return { entry: nodeBin, args: [tsxCli, source] }
|
|
47
|
+
|
|
48
|
+
throw new Error(`privileged helper entry not found in ${moduleDir}. Build the package first.`)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Walks up from `from` looking for a relative path.
|
|
53
|
+
*
|
|
54
|
+
* Stops at the filesystem root so a helper outside the project is never picked
|
|
55
|
+
* up by accident.
|
|
56
|
+
*/
|
|
57
|
+
function findUp(
|
|
58
|
+
from: string,
|
|
59
|
+
relative: string,
|
|
60
|
+
exists: (path: string) => boolean,
|
|
61
|
+
): string | undefined {
|
|
62
|
+
let current = from
|
|
63
|
+
while (true) {
|
|
64
|
+
const candidate = join(current, relative)
|
|
65
|
+
if (exists(candidate))
|
|
66
|
+
return candidate
|
|
67
|
+
|
|
68
|
+
const parent = dirname(current)
|
|
69
|
+
if (parent === current)
|
|
70
|
+
return undefined
|
|
71
|
+
current = parent
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { isIP } from 'node:net'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `/etc/hosts` edits are modelled as pure string transformations so the helper
|
|
5
|
+
* can be tested without touching the real file.
|
|
6
|
+
*
|
|
7
|
+
* `/etc/hosts` is user data: a line may map several names at once
|
|
8
|
+
* (`127.0.0.1 a.test b.test`). Editing one name must never drop the others, and
|
|
9
|
+
* entries this plugin did not write must survive every operation.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const HOSTS_MARKER = '# uhrp'
|
|
13
|
+
export const HOSTS_MARKER_PATTERN = /#\s*uhrp\b/
|
|
14
|
+
|
|
15
|
+
export interface HostsEdit {
|
|
16
|
+
content: string
|
|
17
|
+
changed: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface ParsedLine {
|
|
21
|
+
ip: string
|
|
22
|
+
hosts: string[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function detectEol(content: string): string {
|
|
26
|
+
return content.includes('\r\n') ? '\r\n' : '\n'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function toBodyLines(content: string): string[] {
|
|
30
|
+
const lines = content.split(/\r?\n/)
|
|
31
|
+
if (lines.length > 0 && lines[lines.length - 1] === '')
|
|
32
|
+
lines.pop()
|
|
33
|
+
return lines
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parseLine(line: string): ParsedLine | undefined {
|
|
37
|
+
const withoutComment = line.replace(/#.*/, '').trim()
|
|
38
|
+
if (!withoutComment)
|
|
39
|
+
return undefined
|
|
40
|
+
const [ip, ...hosts] = withoutComment.split(/\s+/)
|
|
41
|
+
if (!ip || hosts.length === 0)
|
|
42
|
+
return undefined
|
|
43
|
+
return { ip, hosts }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isOwned(line: string): boolean {
|
|
47
|
+
return HOSTS_MARKER_PATTERN.test(line)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function mentionsHost(entry: ParsedLine, host: string): boolean {
|
|
51
|
+
return entry.hosts.some(candidate => candidate.toLowerCase() === host.toLowerCase())
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function sameIpFamily(left: string, right: string): boolean {
|
|
55
|
+
const leftVersion = isIP(left)
|
|
56
|
+
return leftVersion !== 0 && leftVersion === isIP(right)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function rebuild(lines: string[], original: string, eol: string): HostsEdit {
|
|
60
|
+
const content = lines.length > 0 ? lines.join(eol) + eol : ''
|
|
61
|
+
return { content, changed: content !== original }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Drops `host` from its mapping and appends a marked entry for it.
|
|
66
|
+
*
|
|
67
|
+
* A user-written line that also maps other names is rewritten without `host`
|
|
68
|
+
* instead of being deleted, so `127.0.0.1 a.test b.test` keeps `b.test`.
|
|
69
|
+
*/
|
|
70
|
+
export function addHost(content: string, ip: string, host: string): HostsEdit {
|
|
71
|
+
const eol = detectEol(content)
|
|
72
|
+
const lines: string[] = []
|
|
73
|
+
|
|
74
|
+
for (const line of toBodyLines(content)) {
|
|
75
|
+
const entry = parseLine(line)
|
|
76
|
+
if (!entry || !mentionsHost(entry, host) || !sameIpFamily(entry.ip, ip)) {
|
|
77
|
+
lines.push(line)
|
|
78
|
+
continue
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const remaining = entry.hosts.filter(candidate => candidate.toLowerCase() !== host.toLowerCase())
|
|
82
|
+
if (remaining.length === 0)
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
// Preserve the original comment when one exists so a user's annotation is
|
|
86
|
+
// not silently dropped by our edit.
|
|
87
|
+
const comment = line.match(/#.*/)?.[0] ?? ''
|
|
88
|
+
const rebuilt = `${entry.ip} ${remaining.join(' ')}${comment ? ` ${comment}` : ''}`
|
|
89
|
+
lines.push(rebuilt)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
lines.push(`${ip} ${host} ${HOSTS_MARKER}`)
|
|
93
|
+
return rebuild(lines, content, eol)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Removes `host` from the mapping to `ip`. Only lines this plugin wrote are
|
|
98
|
+
* removed outright; a user's line keeps its other names.
|
|
99
|
+
*/
|
|
100
|
+
export function removeHost(content: string, ip: string, host: string): HostsEdit {
|
|
101
|
+
const eol = detectEol(content)
|
|
102
|
+
const lines: string[] = []
|
|
103
|
+
|
|
104
|
+
for (const line of toBodyLines(content)) {
|
|
105
|
+
const entry = parseLine(line)
|
|
106
|
+
if (!entry || entry.ip !== ip || !mentionsHost(entry, host)) {
|
|
107
|
+
lines.push(line)
|
|
108
|
+
continue
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const remaining = entry.hosts.filter(candidate => candidate.toLowerCase() !== host.toLowerCase())
|
|
112
|
+
if (remaining.length === 0)
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
const comment = line.match(/#.*/)?.[0] ?? ''
|
|
116
|
+
lines.push(`${entry.ip} ${remaining.join(' ')}${comment ? ` ${comment}` : ''}`)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return rebuild(lines, content, eol)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Drops every entry this plugin wrote, used to clean up after a crashed session. */
|
|
123
|
+
export function removeMarkedHosts(content: string): HostsEdit {
|
|
124
|
+
const eol = detectEol(content)
|
|
125
|
+
const lines = toBodyLines(content).filter(line => !isOwned(line))
|
|
126
|
+
return rebuild(lines, content, eol)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Hosts entries this plugin owns, without the marker. */
|
|
130
|
+
export function listMarkedHosts(content: string): Array<{ ip: string, host: string }> {
|
|
131
|
+
const result: Array<{ ip: string, host: string }> = []
|
|
132
|
+
for (const line of toBodyLines(content)) {
|
|
133
|
+
if (!isOwned(line))
|
|
134
|
+
continue
|
|
135
|
+
const entry = parseLine(line)
|
|
136
|
+
if (!entry)
|
|
137
|
+
continue
|
|
138
|
+
for (const host of entry.hosts)
|
|
139
|
+
result.push({ ip: entry.ip, host })
|
|
140
|
+
}
|
|
141
|
+
return result
|
|
142
|
+
}
|