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.
Files changed (56) hide show
  1. package/README.md +30 -6
  2. package/dist/{chunk-2P7SUCQ3.js → chunk-EY3HXBJ4.js} +0 -26
  3. package/dist/chunk-RKHJN6KY.js +53 -0
  4. package/dist/{chunk-5R33PUJP.js → chunk-U7KAIPZC.js} +981 -796
  5. package/dist/chunk-WOMR3ZLM.js +17 -0
  6. package/dist/esbuild.cjs +1077 -864
  7. package/dist/esbuild.js +4 -2
  8. package/dist/index.cjs +1077 -864
  9. package/dist/index.js +4 -2
  10. package/dist/nuxt.cjs +1049 -850
  11. package/dist/nuxt.js +6 -9
  12. package/dist/privilege-state.cjs +44 -0
  13. package/dist/privilege-state.d.cts +7 -0
  14. package/dist/privilege-state.d.ts +7 -0
  15. package/dist/privilege-state.js +10 -0
  16. package/dist/privileged-helper.cjs +824 -0
  17. package/dist/privileged-helper.d.cts +2 -0
  18. package/dist/privileged-helper.d.ts +2 -0
  19. package/dist/privileged-helper.js +797 -0
  20. package/dist/rollup.cjs +1077 -864
  21. package/dist/rollup.js +4 -2
  22. package/dist/rspack.cjs +1077 -864
  23. package/dist/rspack.js +4 -2
  24. package/dist/utils.cjs +0 -28
  25. package/dist/utils.d.cts +1 -3
  26. package/dist/utils.d.ts +1 -3
  27. package/dist/utils.js +1 -5
  28. package/dist/vite.cjs +1077 -864
  29. package/dist/vite.js +4 -2
  30. package/dist/webpack.cjs +1077 -864
  31. package/dist/webpack.js +4 -2
  32. package/package.json +1 -4
  33. package/src/caddy/caddyfile.ts +3 -0
  34. package/src/caddy/config.ts +195 -53
  35. package/src/caddy/constants.ts +30 -1
  36. package/src/caddy/index.ts +239 -206
  37. package/src/caddy/privileged.ts +124 -0
  38. package/src/caddy/utils.ts +0 -43
  39. package/src/index.ts +49 -22
  40. package/src/nuxt.ts +1 -6
  41. package/src/privilege-state.ts +23 -0
  42. package/src/privileged/bootstrap.ts +289 -0
  43. package/src/privileged/client.ts +288 -0
  44. package/src/privileged/entry.ts +73 -0
  45. package/src/privileged/hosts-file.ts +142 -0
  46. package/src/privileged/launch.ts +332 -0
  47. package/src/privileged/path-guard.ts +74 -0
  48. package/src/privileged/ports.ts +45 -0
  49. package/src/privileged/protocol.ts +111 -0
  50. package/src/privileged/server.ts +578 -0
  51. package/src/privileged/session.ts +169 -0
  52. package/src/privileged/validation.ts +38 -0
  53. package/src/privileged/windows-elevation.ts +80 -0
  54. package/src/privileged-helper.ts +123 -0
  55. package/src/utils.ts +0 -28
  56. package/src/host/index.ts +0 -52
@@ -0,0 +1,169 @@
1
+ import type { PrivilegedClient } from './client'
2
+ import { Buffer } from 'node:buffer'
3
+ import { existsSync } from 'node:fs'
4
+ import { readFile, writeFile } from 'node:fs/promises'
5
+ import { platform } from 'node:os'
6
+ import { join } from 'node:path'
7
+ import * as lockfile from 'proper-lockfile'
8
+ import { connectExistingHelper, createHelperToken, startPrivilegedHelper } from './launch'
9
+
10
+ export interface SessionScope {
11
+ /** Runtime directory shared by the plugin, e.g. `TEMP_DIR`. */
12
+ runtimeDir: string
13
+ /** Directory of the running bundle; used to locate the helper entry. */
14
+ helperEntry: string
15
+ helperArgs?: string[]
16
+ env?: NodeJS.ProcessEnv
17
+ sudoCommand?: string
18
+ /** Skips the interactive credential check; used by tests. */
19
+ skipAuth?: boolean
20
+ /** Runs the helper as the current user; used by tests without privileges. */
21
+ direct?: boolean
22
+ /** Caddy storage directory; the helper hands it back to the user on stop. */
23
+ caddyStoragePath?: string
24
+ /** Caddy binary the helper may run; the client cannot change it later. */
25
+ caddyBin: string
26
+ /** Caddyfile the helper may load. */
27
+ caddyConfigPath: string
28
+ /** Admin API socket; used by `ca.trust` instead of the TCP default. */
29
+ caddyAdminSocket?: string
30
+ /** Shown before the password prompt so the request is not a surprise. */
31
+ reason?: { target: string, https: boolean }
32
+ log?: (message: string) => void
33
+ }
34
+
35
+ export interface OpenedSession {
36
+ client: PrivilegedClient
37
+ /** True when an already-running helper was reused. */
38
+ reused: boolean
39
+ close: () => Promise<void>
40
+ }
41
+
42
+ function tokenPath(runtimeDir: string): string {
43
+ return join(runtimeDir, 'helper.token')
44
+ }
45
+
46
+ export function sessionSocketPath(runtimeDir: string): string {
47
+ if (platform() === 'win32')
48
+ return `\\\\.\\pipe\\uhrp-${Buffer.from(runtimeDir).toString('hex').slice(0, 40)}`
49
+ return join(runtimeDir, 'helper.sock')
50
+ }
51
+
52
+ /**
53
+ * Connects to the helper already serving this runtime directory, or starts a
54
+ * new one. Reuse matters for multi-instance dev servers: the second instance
55
+ * attaches to the running Caddy instead of asking for a password again.
56
+ *
57
+ * The check-then-start sequence runs under a lock. Two projects starting at the
58
+ * same moment would otherwise both fail to find a socket, both spawn a helper,
59
+ * and the second would delete the first one's socket while it was still
60
+ * serving.
61
+ */
62
+ export async function openHelperSession(scope: SessionScope): Promise<OpenedSession> {
63
+ const socketPath = sessionSocketPath(scope.runtimeDir)
64
+ const release = await acquireStartLock(scope.runtimeDir)
65
+
66
+ try {
67
+ const reused = await tryReuse(socketPath, scope)
68
+ if (reused)
69
+ return reused
70
+
71
+ return await startNew(socketPath, scope)
72
+ }
73
+ finally {
74
+ release()
75
+ }
76
+ }
77
+
78
+ async function tryReuse(socketPath: string, scope: SessionScope): Promise<OpenedSession | undefined> {
79
+ if (!existsSync(socketPath) && platform() !== 'win32')
80
+ return undefined
81
+
82
+ const token = await readToken(scope.runtimeDir)
83
+ if (!token)
84
+ return undefined
85
+
86
+ const reused = await connectExistingHelper(socketPath, token)
87
+ if (!reused)
88
+ return undefined
89
+
90
+ return {
91
+ client: reused,
92
+ reused: true,
93
+ close: async () => {
94
+ await reused.close()
95
+ },
96
+ }
97
+ }
98
+
99
+ async function startNew(socketPath: string, scope: SessionScope): Promise<OpenedSession> {
100
+ const token = createHelperToken()
101
+ const started = await startPrivilegedHelper({
102
+ socketPath,
103
+ token,
104
+ helperEntry: scope.helperEntry,
105
+ helperArgs: scope.helperArgs,
106
+ sudoCommand: scope.sudoCommand,
107
+ skipAuth: scope.skipAuth,
108
+ direct: scope.direct,
109
+ env: scope.env,
110
+ caddyStoragePath: scope.caddyStoragePath,
111
+ caddyBin: scope.caddyBin,
112
+ caddyConfigPath: scope.caddyConfigPath,
113
+ caddyAdminSocket: scope.caddyAdminSocket,
114
+ reason: scope.reason,
115
+ log: scope.log,
116
+ })
117
+
118
+ await writeToken(scope.runtimeDir, token)
119
+
120
+ return {
121
+ client: started.client,
122
+ reused: false,
123
+ close: async () => {
124
+ await started.close()
125
+ },
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Serialises "is a helper running? if not, start one" across processes.
131
+ *
132
+ * The lock file is separate from the socket: the socket only exists once a
133
+ * helper is listening, while this lock guards the window before that.
134
+ */
135
+ async function acquireStartLock(runtimeDir: string): Promise<() => void> {
136
+ const lockPath = join(runtimeDir, 'helper.start.lock')
137
+ try {
138
+ await writeFile(lockPath, '', { flag: 'a' })
139
+ }
140
+ catch {}
141
+
142
+ const release = await lockfile.lock(lockPath, {
143
+ realpath: false,
144
+ stale: 30_000,
145
+ retries: { retries: 100, minTimeout: 50, maxTimeout: 200 },
146
+ })
147
+
148
+ return () => {
149
+ void release().catch(() => {})
150
+ }
151
+ }
152
+
153
+ async function readToken(runtimeDir: string): Promise<string | undefined> {
154
+ try {
155
+ const content = await readFile(tokenPath(runtimeDir), 'utf8')
156
+ const token = content.trim()
157
+ return token === '' ? undefined : token
158
+ }
159
+ catch {
160
+ return undefined
161
+ }
162
+ }
163
+
164
+ async function writeToken(runtimeDir: string, token: string): Promise<void> {
165
+ try {
166
+ await writeFile(tokenPath(runtimeDir), token, { mode: 0o600 })
167
+ }
168
+ catch {}
169
+ }
@@ -0,0 +1,38 @@
1
+ import { isIP } from 'node:net'
2
+
3
+ /**
4
+ * Validates values that cross the socket. Everything reaching the privileged
5
+ * helper is untrusted, and a hosts line in particular is a plain-text file: an
6
+ * unvalidated hostname containing a newline could inject entries this plugin
7
+ * does not own and therefore never cleans up.
8
+ */
9
+
10
+ /** Ports the plugin may free. Wide enough for the two the proxy binds. */
11
+ export const ALLOWED_FREE_PORTS = new Set([80, 443])
12
+
13
+ const HOSTNAME_PATTERN = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i
14
+ const IPV6_IN_HOSTS = /^[0-9a-f:.]+$/i
15
+
16
+ export function isSafeHost(host: unknown): host is string {
17
+ if (typeof host !== 'string' || host.length === 0 || host.length > 253)
18
+ return false
19
+ // A hosts entry is whitespace separated, so any whitespace or control
20
+ // character would split the line or start a new one.
21
+ if (/[\s#\0]/.test(host))
22
+ return false
23
+ return HOSTNAME_PATTERN.test(host)
24
+ }
25
+
26
+ export function isSafeIp(ip: unknown): ip is string {
27
+ if (typeof ip !== 'string')
28
+ return false
29
+ if (isIP(ip) !== 0)
30
+ return true
31
+ // `::1` and friends are caught by isIP; this keeps the check explicit for
32
+ // hosts files that spell IPv6 without brackets.
33
+ return IPV6_IN_HOSTS.test(ip)
34
+ }
35
+
36
+ export function isAllowedFreePort(port: unknown): port is number {
37
+ return typeof port === 'number' && Number.isInteger(port) && ALLOWED_FREE_PORTS.has(port)
38
+ }
@@ -0,0 +1,80 @@
1
+ import type { ChildProcess } from 'node:child_process'
2
+ import { spawn } from 'node:child_process'
3
+ import process from 'node:process'
4
+
5
+ /**
6
+ * Windows has no `sudo`: elevation goes through UAC, and the elevated process
7
+ * is a new process that cannot inherit the parent's stdio. The helper talks
8
+ * over a named pipe, so it only needs to be started detached.
9
+ *
10
+ * `Start-Process -Verb RunAs` is the documented way to trigger the UAC prompt
11
+ * without a helper binary or a dependency on `sudo-prompt`.
12
+ */
13
+ export interface WindowsElevationOptions {
14
+ /** Executable to run elevated; normally the node runtime. */
15
+ file: string
16
+ /** Arguments for that executable. */
17
+ args: string[]
18
+ /** Directory to start in; defaults to the parent's. */
19
+ cwd?: string
20
+ }
21
+
22
+ /** Quotes an argument for PowerShell's single-quoted string syntax. */
23
+ export function quotePowerShell(value: string): string {
24
+ return `'${value.replace(/'/g, '\'\'')}'`
25
+ }
26
+
27
+ /**
28
+ * Builds the PowerShell command that starts `file` elevated. Exported so the
29
+ * escaping can be unit-tested without touching a real UAC prompt.
30
+ */
31
+ export function buildElevationCommand(options: WindowsElevationOptions): string {
32
+ const argumentList = options.args.map(quotePowerShell).join(',')
33
+ const parts = [
34
+ 'Start-Process',
35
+ '-FilePath',
36
+ quotePowerShell(options.file),
37
+ ]
38
+
39
+ if (argumentList)
40
+ parts.push('-ArgumentList', argumentList)
41
+
42
+ if (options.cwd)
43
+ parts.push('-WorkingDirectory', quotePowerShell(options.cwd))
44
+
45
+ parts.push('-Verb', 'RunAs')
46
+ parts.push('-WindowStyle', 'Hidden')
47
+
48
+ return parts.join(' ')
49
+ }
50
+
51
+ /**
52
+ * Starts the helper elevated on Windows. Resolves once PowerShell has accepted
53
+ * the request; the helper's own readiness is observed through the named pipe.
54
+ */
55
+ export function spawnElevatedOnWindows(options: WindowsElevationOptions): Promise<ChildProcess> {
56
+ return new Promise((resolve, reject) => {
57
+ const script = buildElevationCommand(options)
58
+ const child = spawn(
59
+ 'powershell.exe',
60
+ ['-NoProfile', '-NonInteractive', '-Command', script],
61
+ { stdio: 'ignore', windowsHide: true },
62
+ )
63
+
64
+ // PowerShell exits once UAC is answered; a non-zero code means the user
65
+ // dismissed the prompt or elevation was denied.
66
+ child.once('error', reject)
67
+ child.once('exit', (code) => {
68
+ if (code === 0)
69
+ return resolve(child)
70
+ reject(new Error(
71
+ `administrator permission was not granted (PowerShell exited with ${code}). `
72
+ + 'Accept the UAC prompt to let the plugin edit the hosts file and bind ports 80/443.',
73
+ ))
74
+ })
75
+ })
76
+ }
77
+
78
+ export function isWindowsElevationAvailable(): boolean {
79
+ return process.platform === 'win32'
80
+ }
@@ -0,0 +1,123 @@
1
+ import process from 'node:process'
2
+ import { startHelper } from './privileged/bootstrap'
3
+
4
+ interface ParsedArgs {
5
+ socket: string
6
+ token: string
7
+ hosts?: string
8
+ caddyBin: string
9
+ caddyConfig: string
10
+ ownerUid?: number
11
+ ownerGid?: number
12
+ caddyStorage?: string
13
+ caddyAdmin?: string
14
+ }
15
+
16
+ function parseArgs(argv: string[]): ParsedArgs {
17
+ let socket = ''
18
+ let token = ''
19
+ let hosts: string | undefined
20
+ let caddyBin = ''
21
+ let caddyConfig = ''
22
+ let ownerUid: number | undefined
23
+ let ownerGid: number | undefined
24
+ let caddyStorage: string | undefined
25
+ let caddyAdmin: string | undefined
26
+
27
+ for (let index = 0; index < argv.length; index++) {
28
+ const current = argv[index]
29
+ if (current === '--socket')
30
+ socket = argv[++index] ?? ''
31
+ else if (current === '--token')
32
+ token = argv[++index] ?? ''
33
+ else if (current === '--hosts')
34
+ hosts = argv[++index] ?? undefined
35
+ else if (current === '--caddy')
36
+ caddyBin = argv[++index] ?? ''
37
+ else if (current === '--caddy-config')
38
+ caddyConfig = argv[++index] ?? ''
39
+ else if (current === '--owner-uid')
40
+ ownerUid = Number(argv[++index])
41
+ else if (current === '--owner-gid')
42
+ ownerGid = Number(argv[++index])
43
+ else if (current === '--caddy-storage')
44
+ caddyStorage = argv[++index] ?? undefined
45
+ else if (current === '--caddy-admin')
46
+ caddyAdmin = argv[++index] ?? undefined
47
+ }
48
+
49
+ if (!socket || !token)
50
+ throw new Error('usage: privileged-helper --socket <path> --token <token> --caddy <bin> --caddy-config <path>')
51
+ if (!caddyBin || !caddyConfig)
52
+ throw new Error('privileged-helper requires --caddy and --caddy-config')
53
+
54
+ return {
55
+ socket,
56
+ token,
57
+ hosts,
58
+ caddyBin,
59
+ caddyConfig,
60
+ ownerUid: Number.isInteger(ownerUid) ? ownerUid : undefined,
61
+ ownerGid: Number.isInteger(ownerGid) ? ownerGid : undefined,
62
+ caddyStorage,
63
+ caddyAdmin,
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Entry point of the process that runs with elevated privileges.
69
+ *
70
+ * It owns no product logic. The Caddy binary and Caddyfile are fixed on the
71
+ * command line, so requests on the socket can only say *when* to act, never
72
+ * what* to execute.
73
+ */
74
+ async function main(): Promise<void> {
75
+ const args = parseArgs(process.argv.slice(2))
76
+
77
+ const helper = await startHelper({
78
+ socketPath: args.socket,
79
+ token: args.token,
80
+ hostsPath: args.hosts,
81
+ caddyBin: args.caddyBin,
82
+ caddyConfigPath: args.caddyConfig,
83
+ ownerUid: args.ownerUid,
84
+ ownerGid: args.ownerGid,
85
+ caddyStoragePath: args.caddyStorage,
86
+ caddyAdminSocket: args.caddyAdmin,
87
+ caddyEnv: {
88
+ HOME: process.env.HOME ?? '',
89
+ // Keep Caddy's storage next to the invoking user's home even though the
90
+ // process runs as root.
91
+ XDG_DATA_HOME: process.env.XDG_DATA_HOME ?? '',
92
+ },
93
+ })
94
+
95
+ // Terminal signals reach the whole foreground process group, so they also hit
96
+ // this helper when one of several dev servers stops. Acting on them would tear
97
+ // down the shared Caddy and hosts entries the remaining projects still use.
98
+ // The socket closing is the only signal this helper acts on.
99
+ //
100
+ // Ctrl+C, Ctrl+\, Ctrl+Z and a terminal closing all arrive this way: SIGINT,
101
+ // SIGQUIT, SIGTSTP and SIGHUP. Ignoring SIGTSTP also keeps the helper from
102
+ // being suspended, which would stall every attached project's RPC.
103
+ for (const signal of ['SIGINT', 'SIGQUIT', 'SIGTSTP', 'SIGHUP'] as const)
104
+ process.on(signal, () => {})
105
+
106
+ // SIGTERM is different: IDE and process-tree runners send it to shut the tool
107
+ // down for real. It still must not drop a live session, so it only tears down
108
+ // when no dev server is attached.
109
+ process.on('SIGTERM', () => {
110
+ if (helper.runtime.clientCount() > 0)
111
+ return
112
+ void helper.runtime.dispose().finally(() => process.exit(0))
113
+ })
114
+
115
+ // A closed stdout (the parent died) must not interrupt the cleanup path.
116
+ process.stdout.on('error', () => {})
117
+ process.stderr.on('error', () => {})
118
+ }
119
+
120
+ main().catch((error: Error) => {
121
+ process.stderr.write(`[uhrp-helper] fatal: ${error.message}\n`)
122
+ process.exit(1)
123
+ })
package/src/utils.ts CHANGED
@@ -1,8 +1,4 @@
1
- import { execSync } from 'node:child_process'
2
- import { chmod, readdir, stat } from 'node:fs/promises'
3
1
  import { networkInterfaces } from 'node:os'
4
- import path from 'node:path'
5
- import process from 'node:process'
6
2
  import { createConsola } from 'consola'
7
3
  import c from 'picocolors'
8
4
  import qrcode from 'qrcode-terminal'
@@ -11,15 +7,6 @@ export const consola = createConsola({
11
7
  level: 3,
12
8
  }).withTag('reverse-proxy')
13
9
 
14
- export async function chmodRecursive(_path: string, mode: number): Promise<void> {
15
- await chmod(_path, mode)
16
- if ((await stat(_path)).isDirectory()) {
17
- const children = await readdir(_path)
18
- for (const child of children)
19
- await chmodRecursive(path.join(_path, child), mode)
20
- }
21
- }
22
-
23
10
  export function once<T extends (...args: any[]) => any>(fn: T): (...args: Parameters<T>) => ReturnType<T> {
24
11
  let called = false
25
12
  return (...args: any[]) => {
@@ -30,21 +17,6 @@ export function once<T extends (...args: any[]) => any>(fn: T): (...args: Parame
30
17
  }
31
18
  }
32
19
 
33
- export function isAdmin(): boolean {
34
- if (process.platform === 'win32') {
35
- try {
36
- execSync('net session', { stdio: 'ignore' })
37
- return true
38
- }
39
- catch {
40
- return false
41
- }
42
- }
43
- else {
44
- return !!(process.getuid && process.getuid() === 0)
45
- }
46
- }
47
-
48
20
  export function getIpv4List(): string[] {
49
21
  const interfaces = networkInterfaces()
50
22
 
package/src/host/index.ts DELETED
@@ -1,52 +0,0 @@
1
- import { exec } from 'node:child_process'
2
- import { chmodSync } from 'node:fs'
3
- import process from 'node:process'
4
- import { promisify } from 'node:util'
5
-
6
- import hostile from 'hostile'
7
- import { consola } from '../utils'
8
-
9
- const _setHost = promisify(hostile.set)
10
- const _removeHost = promisify(hostile.remove)
11
-
12
- function getPathOfSystemHostsPath(): string {
13
- return process.platform === 'win32'
14
- ? `${process.env.windir || 'C:\\WINDOWS'}\\system32\\drivers\\etc\\hosts`
15
- : '/etc/hosts'
16
- }
17
-
18
- async function syncHost(): Promise<boolean> {
19
- return new Promise<boolean>((resolve, _reject) => {
20
- exec(
21
- process.platform === 'win32'
22
- ? 'ipconfig /flushdns'
23
- : 'sudo -E killall -HUP mDNSResponder',
24
- (error, _stdout, _stderr) => {
25
- if (error) {
26
- consola.error(`exec error: ${error}`)
27
- return resolve(false)
28
- }
29
- return resolve(true)
30
- },
31
- )
32
- })
33
- }
34
-
35
- export async function addHost(ip: string, host: string): Promise<boolean> {
36
- if (process.platform !== 'win32')
37
- chmodSync(getPathOfSystemHostsPath(), 0o777)
38
- await _setHost('127.0.0.1', 'localhost')
39
- await _setHost(ip, host)
40
- if (ip === 'localhost')
41
- await _setHost('127.0.0.1', host)
42
- return await syncHost()
43
- }
44
-
45
- export async function removeHost(ip: string, host: string): Promise<boolean> {
46
- if (process.platform !== 'win32')
47
- chmodSync(getPathOfSystemHostsPath(), 0o777)
48
- await _removeHost(ip, host)
49
- if (ip === 'localhost')
50
- await _removeHost('127.0.0.1', host)
51
- return await syncHost()
52
- }