@linxin666/dsh-usage 0.4.7 → 0.4.8
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.i18n.yaml +2 -2
- package/README.md +2 -2
- package/README.zh.md +2 -2
- package/lib/client.js +73 -31
- package/lib/client.js.map +1 -1
- package/lib/index.js +15 -1
- package/lib/types/client/PeakBadge.d.ts +22 -0
- package/lib/types/client/locales.d.ts +4 -0
- package/lib/types/host/loopback.d.ts +20 -1
- package/package.json +1 -1
- package/src/client/PeakBadge.tsx +48 -0
- package/src/client/UsageFootCard.tsx +27 -14
- package/src/client/UsageSectionCard.tsx +2 -11
- package/src/client/locales.ts +8 -0
- package/src/client/usage.module.css +59 -1
- package/src/host/loopback.ts +25 -1
package/lib/index.js
CHANGED
|
@@ -2025,12 +2025,26 @@ function isLoopbackHostname(hostname) {
|
|
|
2025
2025
|
return isIPv4Loopback(hostname);
|
|
2026
2026
|
}
|
|
2027
2027
|
/**
|
|
2028
|
+
* Whether a reverse proxy stood between the client and this host: the request
|
|
2029
|
+
* carries CF-Connecting-IP or X-Forwarded-For. Either header is stamped by a
|
|
2030
|
+
* hop that forwards traffic, so the request did not originate on this machine.
|
|
2031
|
+
* @param request - node HTTP request.
|
|
2032
|
+
* @returns true when a forwarding header is present.
|
|
2033
|
+
*/
|
|
2034
|
+
function isProxiedRequest(request) {
|
|
2035
|
+
return request.headers["cf-connecting-ip"] !== void 0 || request.headers["x-forwarded-for"] !== void 0;
|
|
2036
|
+
}
|
|
2037
|
+
/**
|
|
2028
2038
|
* Request-level trust fence: a loopback socket address AND a loopback Host
|
|
2029
2039
|
* header, plus browser same-origin markers. The socket address is
|
|
2030
|
-
* authoritative;
|
|
2040
|
+
* authoritative for a direct connection; a request that carries a forwarding
|
|
2041
|
+
* header is refused outright, because a proxy in front of the host makes both
|
|
2042
|
+
* the socket address and the Host header describe the proxy rather than the
|
|
2043
|
+
* client. X-Forwarded-For is therefore never evidence *for* loopback.
|
|
2031
2044
|
*/
|
|
2032
2045
|
function isLoopbackRequest(request) {
|
|
2033
2046
|
if (!isLoopbackAddress(request.socket.remoteAddress)) return false;
|
|
2047
|
+
if (isProxiedRequest(request)) return false;
|
|
2034
2048
|
const host = request.headers.host;
|
|
2035
2049
|
if (typeof host !== "string") return false;
|
|
2036
2050
|
let hostUrl;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DeepSeek pricing-clock marker, shared by the usage panel's peak line and the
|
|
3
|
+
* sidebar foot card: a filled dot plus the period word, red while the peak
|
|
4
|
+
* window bills double and green while off-peak bills half. The word always
|
|
5
|
+
* rides with the colour (colour alone is not a signal), and the priced
|
|
6
|
+
* sentence moves to the title so a tight surface stays one short token.
|
|
7
|
+
* @module @linxin666/dsh-usage/client/PeakBadge
|
|
8
|
+
*/
|
|
9
|
+
import type { ReactNode } from 'react';
|
|
10
|
+
/** Props: where the clock stands and how much copy the surface can hold. */
|
|
11
|
+
export interface PeakBadgeProps {
|
|
12
|
+
/** Whether the published DeepSeek peak window is in force now. */
|
|
13
|
+
peak: boolean;
|
|
14
|
+
/** End of the current period (the peak close), or the next peak open when off-peak. */
|
|
15
|
+
boundaryMs: number;
|
|
16
|
+
/** compact: dot plus the period word; full: the whole priced sentence. */
|
|
17
|
+
variant: 'compact' | 'full';
|
|
18
|
+
}
|
|
19
|
+
/** HH:MM of one instant; an unparsable instant drops the time. */
|
|
20
|
+
export declare function formatClock(ms: number): string;
|
|
21
|
+
/** The peak/off-peak marker itself; nothing renders outside the DeepSeek family. */
|
|
22
|
+
export declare function PeakBadge(props: PeakBadgeProps): ReactNode;
|
|
@@ -29,6 +29,10 @@ export declare const zh: {
|
|
|
29
29
|
'usage.day.error': string;
|
|
30
30
|
'usage.peak.on': string;
|
|
31
31
|
'usage.peak.off': string;
|
|
32
|
+
'usage.peak.tag.on': string;
|
|
33
|
+
'usage.peak.tag.off': string;
|
|
34
|
+
'usage.peak.ends': string;
|
|
35
|
+
'usage.peak.starts': string;
|
|
32
36
|
'usage.calls': string;
|
|
33
37
|
'usage.tokens.total': string;
|
|
34
38
|
'usage.tokens.input': string;
|
|
@@ -7,6 +7,14 @@
|
|
|
7
7
|
* Semantics: RFC 5735 IPv4 127/8, ::1, IPv4-mapped ::ffff:127/8 (matching the
|
|
8
8
|
* remote-web-ui gate), localhost hostnames, plus the browser same-origin
|
|
9
9
|
* markers (sec-fetch-site and Origin) for the request-level fence.
|
|
10
|
+
*
|
|
11
|
+
* A reverse proxy in front of the host rewrites the Host header to its own
|
|
12
|
+
* upstream literal (a Cloudflare tunnel, frp and an nginx reverse proxy all
|
|
13
|
+
* dial 127.0.0.1:<port>), and the socket the origin sees is the proxy's, which
|
|
14
|
+
* is loopback by construction. Both halves of the fence then hold for a public
|
|
15
|
+
* request. Forwarding headers are the signal that a hop stands in between: a
|
|
16
|
+
* request that carries CF-Connecting-IP or X-Forwarded-For is answered through
|
|
17
|
+
* a proxy and is never treated as a local one (issue #1906).
|
|
10
18
|
* @module dsh-web-shared/host/loopback
|
|
11
19
|
*/
|
|
12
20
|
import type { IncomingMessage } from 'node:http';
|
|
@@ -16,9 +24,20 @@ export declare function isIPv4Loopback(v4: string): boolean;
|
|
|
16
24
|
export declare function isLoopbackAddress(address: string | undefined): boolean;
|
|
17
25
|
/** Whether a normalized URL hostname names the loopback authority (localhost, [::1], 127/8). */
|
|
18
26
|
export declare function isLoopbackHostname(hostname: string): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Whether a reverse proxy stood between the client and this host: the request
|
|
29
|
+
* carries CF-Connecting-IP or X-Forwarded-For. Either header is stamped by a
|
|
30
|
+
* hop that forwards traffic, so the request did not originate on this machine.
|
|
31
|
+
* @param request - node HTTP request.
|
|
32
|
+
* @returns true when a forwarding header is present.
|
|
33
|
+
*/
|
|
34
|
+
export declare function isProxiedRequest(request: IncomingMessage): boolean;
|
|
19
35
|
/**
|
|
20
36
|
* Request-level trust fence: a loopback socket address AND a loopback Host
|
|
21
37
|
* header, plus browser same-origin markers. The socket address is
|
|
22
|
-
* authoritative;
|
|
38
|
+
* authoritative for a direct connection; a request that carries a forwarding
|
|
39
|
+
* header is refused outright, because a proxy in front of the host makes both
|
|
40
|
+
* the socket address and the Host header describe the proxy rather than the
|
|
41
|
+
* client. X-Forwarded-For is therefore never evidence *for* loopback.
|
|
23
42
|
*/
|
|
24
43
|
export declare function isLoopbackRequest(request: IncomingMessage): boolean;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linxin666/dsh-usage",
|
|
3
3
|
"description": "Usage statistics plugin for the dsh web GUI: per-provider balance and coding-plan quota detection plus a live token usage ledger, with a compact price glance card above the sidebar's Settings row",
|
|
4
|
-
"version": "0.4.
|
|
4
|
+
"version": "0.4.8",
|
|
5
5
|
"icon": "icon.svg",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"engines": {
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DeepSeek pricing-clock marker, shared by the usage panel's peak line and the
|
|
3
|
+
* sidebar foot card: a filled dot plus the period word, red while the peak
|
|
4
|
+
* window bills double and green while off-peak bills half. The word always
|
|
5
|
+
* rides with the colour (colour alone is not a signal), and the priced
|
|
6
|
+
* sentence moves to the title so a tight surface stays one short token.
|
|
7
|
+
* @module @linxin666/dsh-usage/client/PeakBadge
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ReactNode } from 'react'
|
|
11
|
+
import { t } from './locales.ts'
|
|
12
|
+
import styles from './usage.module.css'
|
|
13
|
+
|
|
14
|
+
/** Props: where the clock stands and how much copy the surface can hold. */
|
|
15
|
+
export interface PeakBadgeProps {
|
|
16
|
+
/** Whether the published DeepSeek peak window is in force now. */
|
|
17
|
+
peak: boolean
|
|
18
|
+
/** End of the current period (the peak close), or the next peak open when off-peak. */
|
|
19
|
+
boundaryMs: number
|
|
20
|
+
/** compact: dot plus the period word; full: the whole priced sentence. */
|
|
21
|
+
variant: 'compact' | 'full'
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** HH:MM of one instant; an unparsable instant drops the time. */
|
|
25
|
+
export function formatClock(ms: number): string {
|
|
26
|
+
try {
|
|
27
|
+
return new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
28
|
+
} catch {
|
|
29
|
+
return ''
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The peak/off-peak marker itself; nothing renders outside the DeepSeek family. */
|
|
34
|
+
export function PeakBadge(props: PeakBadgeProps): ReactNode {
|
|
35
|
+
const { peak, boundaryMs, variant } = props
|
|
36
|
+
const sentence = t(peak ? 'usage.peak.on' : 'usage.peak.off', { time: formatClock(boundaryMs) })
|
|
37
|
+
return (
|
|
38
|
+
<span
|
|
39
|
+
className={variant === 'full' ? styles.peakLine : styles.peakBadge}
|
|
40
|
+
data-peak={peak ? 'on' : 'off'}
|
|
41
|
+
data-dsh-part="peak-status"
|
|
42
|
+
title={sentence}
|
|
43
|
+
>
|
|
44
|
+
<span className={styles.peakDot} aria-hidden="true" />
|
|
45
|
+
{variant === 'full' ? sentence : t(peak ? 'usage.peak.tag.on' : 'usage.peak.tag.off')}
|
|
46
|
+
</span>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
@@ -27,7 +27,10 @@ import { useEffect, useState, useSyncExternalStore, type ReactNode } from 'react
|
|
|
27
27
|
import type { ConfigForm } from '@deepseek-ai/dsh-client-ui-settings/client'
|
|
28
28
|
import { t } from './locales.ts'
|
|
29
29
|
import styles from './usage.module.css'
|
|
30
|
+
import { formatClock, PeakBadge } from './PeakBadge.tsx'
|
|
30
31
|
import { formatTokens, type UsageSettings } from './UsageSectionCard.tsx'
|
|
32
|
+
import { deepseekPeriodAt } from '../core/pricing.ts'
|
|
33
|
+
import { isDeepSeekProviderRoute } from '../core/adapters.ts'
|
|
31
34
|
import { totalTokens } from '../core/ledger.ts'
|
|
32
35
|
import type { BalanceView, ProviderSnapshotView, UsageOverviewView } from '../core/types.ts'
|
|
33
36
|
import type { UsageStoreInstance } from './usage-store.ts'
|
|
@@ -114,27 +117,23 @@ function formatBalance(balance: BalanceView): string {
|
|
|
114
117
|
return currency + ' ' + balance.totalBalance
|
|
115
118
|
}
|
|
116
119
|
|
|
117
|
-
function formatClock(ms: number): string {
|
|
118
|
-
try {
|
|
119
|
-
return new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
120
|
-
} catch {
|
|
121
|
-
return ''
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
120
|
/**
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
* route). Undefined when there is no route to name.
|
|
121
|
+
* Route key owning today's priced spend: the day's highest cost row, else the
|
|
122
|
+
* current session route (an older host without the day rows falls back to the
|
|
123
|
+
* current route). Undefined when neither names one.
|
|
130
124
|
*/
|
|
131
|
-
function
|
|
125
|
+
function spendRoute(snapshot: UsageOverviewView): string | undefined {
|
|
132
126
|
let top: UsageOverviewView['usage']['today']['providers'][number] | undefined
|
|
133
127
|
for (const row of snapshot.usage.today.providers) {
|
|
134
128
|
if (row.totals.cost <= 0) continue
|
|
135
129
|
if (top === undefined || row.totals.cost > top.totals.cost) top = row
|
|
136
130
|
}
|
|
137
|
-
|
|
131
|
+
return top?.provider ?? snapshot.current.provider
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Display name of the route spendRoute() names, resolved through the snapshot. */
|
|
135
|
+
function spendProvider(snapshot: UsageOverviewView): string | undefined {
|
|
136
|
+
const route = spendRoute(snapshot)
|
|
138
137
|
if (route === undefined) return undefined
|
|
139
138
|
return snapshot.providers.find((entry) => entry.provider === route)?.displayName
|
|
140
139
|
?? (route === snapshot.current.provider ? snapshot.current.displayName : undefined)
|
|
@@ -266,6 +265,11 @@ export function UsageFootCard(props: UsageFootCardProps): ReactNode {
|
|
|
266
265
|
|
|
267
266
|
const head = headline(snapshot)
|
|
268
267
|
const balances = balanceLine(snapshot)
|
|
268
|
+
// The published peak/off-peak clock prices the official DeepSeek family only,
|
|
269
|
+
// so the marker follows the route that owns today's spend (else the session
|
|
270
|
+
// route) and stays away from every other provider's card.
|
|
271
|
+
const route = spendRoute(snapshot)
|
|
272
|
+
const period = route !== undefined && isDeepSeekProviderRoute(route) ? deepseekPeriodAt(Date.now()) : undefined
|
|
269
273
|
|
|
270
274
|
if (collapsed) {
|
|
271
275
|
return (
|
|
@@ -274,6 +278,7 @@ export function UsageFootCard(props: UsageFootCardProps): ReactNode {
|
|
|
274
278
|
<span className={styles.footStrip} data-dsh-part="foot-card-strip">
|
|
275
279
|
<GaugeIcon />
|
|
276
280
|
{head.provider !== undefined && <span className={styles.footStripProvider}>{head.provider}</span>}
|
|
281
|
+
{period !== undefined && <PeakBadge peak={period.peak} boundaryMs={period.boundaryMs} variant="compact" />}
|
|
277
282
|
<span className={styles.footStripLabel}>{head.label}</span>
|
|
278
283
|
<span className={styles.footStripValue}>{head.value}</span>
|
|
279
284
|
</span>
|
|
@@ -292,6 +297,14 @@ export function UsageFootCard(props: UsageFootCardProps): ReactNode {
|
|
|
292
297
|
</span>
|
|
293
298
|
<span className={styles.footLine} data-dsh-part="foot-card-usage">{usageLine(snapshot, head.tokenFallback)}</span>
|
|
294
299
|
{balances !== undefined && <span className={styles.footLine} data-dsh-part="foot-card-balances">{balances}</span>}
|
|
300
|
+
{period !== undefined && (
|
|
301
|
+
<span className={styles.footPeak}>
|
|
302
|
+
<PeakBadge peak={period.peak} boundaryMs={period.boundaryMs} variant="compact" />
|
|
303
|
+
<span className={styles.footPeakClock}>
|
|
304
|
+
{t(period.peak ? 'usage.peak.ends' : 'usage.peak.starts', { time: formatClock(period.boundaryMs) })}
|
|
305
|
+
</span>
|
|
306
|
+
</span>
|
|
307
|
+
)}
|
|
295
308
|
<span className={styles.footMeta}>{t('usage.updated', { time: formatClock(snapshot.updatedAt) })}</span>
|
|
296
309
|
</button>
|
|
297
310
|
{toggle}
|
|
@@ -13,6 +13,7 @@ import type { ConfigForm, ConfigFormSnapshot } from '@deepseek-ai/dsh-client-ui-
|
|
|
13
13
|
import type { UsageStoreInstance } from './usage-store.ts'
|
|
14
14
|
import { t } from './locales.ts'
|
|
15
15
|
import styles from './usage.module.css'
|
|
16
|
+
import { PeakBadge } from './PeakBadge.tsx'
|
|
16
17
|
import { isDeepSeekProviderRoute } from '../core/adapters.ts'
|
|
17
18
|
import { deepseekPeriodAt } from '../core/pricing.ts'
|
|
18
19
|
import { voucherData, drawVoucher, faceValue, formatDay, formatDenomination, loadVoucherArt } from './voucher.ts'
|
|
@@ -77,14 +78,6 @@ function formatTime(ms: number): string {
|
|
|
77
78
|
}
|
|
78
79
|
}
|
|
79
80
|
|
|
80
|
-
function formatClock(ms: number): string {
|
|
81
|
-
try {
|
|
82
|
-
return new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
83
|
-
} catch {
|
|
84
|
-
return ''
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
81
|
/** Formatted CNY spend estimate (the only priced currency today). */
|
|
89
82
|
function formatCost(cost: number): string {
|
|
90
83
|
return '¥' + cost.toFixed(2)
|
|
@@ -389,9 +382,7 @@ export function UsageSectionCard(props: UsageSectionProps): ReactNode {
|
|
|
389
382
|
onSelect={(date) => { setUsageDay(date === todayDate ? undefined : date) }}
|
|
390
383
|
/>
|
|
391
384
|
{deepseekVisible && (
|
|
392
|
-
<
|
|
393
|
-
{t(deepseekPeriod.peak ? 'usage.peak.on' : 'usage.peak.off', { time: formatClock(deepseekPeriod.boundaryMs) })}
|
|
394
|
-
</span>
|
|
385
|
+
<PeakBadge peak={deepseekPeriod.peak} boundaryMs={deepseekPeriod.boundaryMs} variant="full" />
|
|
395
386
|
)}
|
|
396
387
|
{usageDayLoad?.status === 'error'
|
|
397
388
|
? <span className={styles.errorLine}>{t('usage.day.error', { error: usageDayLoad.error ?? '' })}</span>
|
package/src/client/locales.ts
CHANGED
|
@@ -31,6 +31,10 @@ export const zh = {
|
|
|
31
31
|
'usage.day.error': '该日用量加载失败:{error}',
|
|
32
32
|
'usage.peak.on': 'DeepSeek 高峰时段:计价 ×2,{time} 结束',
|
|
33
33
|
'usage.peak.off': 'DeepSeek 空闲时段:计价为高峰一半,{time} 进入高峰',
|
|
34
|
+
'usage.peak.tag.on': '高峰',
|
|
35
|
+
'usage.peak.tag.off': '空闲',
|
|
36
|
+
'usage.peak.ends': '{time} 结束',
|
|
37
|
+
'usage.peak.starts': '{time} 进入高峰',
|
|
34
38
|
'usage.calls': '{n} 次调用',
|
|
35
39
|
'usage.tokens.total': '总 tokens',
|
|
36
40
|
'usage.tokens.input': '输入',
|
|
@@ -98,6 +102,10 @@ export const en: Record<UsageKey, string> = {
|
|
|
98
102
|
'usage.day.error': 'Failed to load that day: {error}',
|
|
99
103
|
'usage.peak.on': 'DeepSeek peak hours: 2x pricing, ends {time}',
|
|
100
104
|
'usage.peak.off': 'DeepSeek off-peak: half of peak pricing, peak returns {time}',
|
|
105
|
+
'usage.peak.tag.on': 'Peak',
|
|
106
|
+
'usage.peak.tag.off': 'Off-peak',
|
|
107
|
+
'usage.peak.ends': 'ends {time}',
|
|
108
|
+
'usage.peak.starts': 'peak at {time}',
|
|
101
109
|
'usage.calls': '{n} calls',
|
|
102
110
|
'usage.tokens.total': 'Total tokens',
|
|
103
111
|
'usage.tokens.input': 'Input',
|
|
@@ -594,7 +594,7 @@
|
|
|
594
594
|
|
|
595
595
|
.footStripProvider {
|
|
596
596
|
flex: none;
|
|
597
|
-
max-width:
|
|
597
|
+
max-width: 38%;
|
|
598
598
|
overflow: hidden;
|
|
599
599
|
text-overflow: ellipsis;
|
|
600
600
|
font-weight: 500;
|
|
@@ -616,3 +616,61 @@
|
|
|
616
616
|
:global([data-sidebar-collapsed]) .footCard {
|
|
617
617
|
display: none;
|
|
618
618
|
}
|
|
619
|
+
|
|
620
|
+
/* ---------------------------------------------------------------------------
|
|
621
|
+
DeepSeek pricing clock: red while the peak window bills double, green while
|
|
622
|
+
off-peak bills half. The period word rides with the dot, so colour is never
|
|
623
|
+
the only signal, and the priced sentence lives in the title attribute.
|
|
624
|
+
--------------------------------------------------------------------------- */
|
|
625
|
+
|
|
626
|
+
.peakBadge,
|
|
627
|
+
.peakLine {
|
|
628
|
+
display: inline-flex;
|
|
629
|
+
align-items: center;
|
|
630
|
+
gap: 4px;
|
|
631
|
+
font-size: 11px;
|
|
632
|
+
font-variant-numeric: tabular-nums;
|
|
633
|
+
white-space: nowrap;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
.peakBadge {
|
|
637
|
+
flex: none;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
.peakLine {
|
|
641
|
+
gap: 6px;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
.peakBadge[data-peak='on'],
|
|
645
|
+
.peakLine[data-peak='on'] {
|
|
646
|
+
color: var(--dsw-alias-state-error-primary, #b42318);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
.peakBadge[data-peak='off'],
|
|
650
|
+
.peakLine[data-peak='off'] {
|
|
651
|
+
color: var(--dsw-alias-state-success-primary, #067647);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
.peakDot {
|
|
655
|
+
flex: none;
|
|
656
|
+
width: 6px;
|
|
657
|
+
height: 6px;
|
|
658
|
+
border-radius: 50%;
|
|
659
|
+
background: currentColor;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/* Expanded foot card: its own line at full opacity (the meta line below is
|
|
663
|
+
dimmed) so the red/green reads on every skin. */
|
|
664
|
+
.footPeak {
|
|
665
|
+
display: flex;
|
|
666
|
+
align-items: center;
|
|
667
|
+
gap: 5px;
|
|
668
|
+
overflow: hidden;
|
|
669
|
+
font-size: 10px;
|
|
670
|
+
white-space: nowrap;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
.footPeakClock {
|
|
674
|
+
opacity: 0.7;
|
|
675
|
+
font-variant-numeric: tabular-nums;
|
|
676
|
+
}
|
package/src/host/loopback.ts
CHANGED
|
@@ -8,6 +8,14 @@
|
|
|
8
8
|
* Semantics: RFC 5735 IPv4 127/8, ::1, IPv4-mapped ::ffff:127/8 (matching the
|
|
9
9
|
* remote-web-ui gate), localhost hostnames, plus the browser same-origin
|
|
10
10
|
* markers (sec-fetch-site and Origin) for the request-level fence.
|
|
11
|
+
*
|
|
12
|
+
* A reverse proxy in front of the host rewrites the Host header to its own
|
|
13
|
+
* upstream literal (a Cloudflare tunnel, frp and an nginx reverse proxy all
|
|
14
|
+
* dial 127.0.0.1:<port>), and the socket the origin sees is the proxy's, which
|
|
15
|
+
* is loopback by construction. Both halves of the fence then hold for a public
|
|
16
|
+
* request. Forwarding headers are the signal that a hop stands in between: a
|
|
17
|
+
* request that carries CF-Connecting-IP or X-Forwarded-For is answered through
|
|
18
|
+
* a proxy and is never treated as a local one (issue #1906).
|
|
11
19
|
* @module dsh-web-shared/host/loopback
|
|
12
20
|
*/
|
|
13
21
|
|
|
@@ -36,13 +44,29 @@ export function isLoopbackHostname(hostname: string): boolean {
|
|
|
36
44
|
return isIPv4Loopback(hostname)
|
|
37
45
|
}
|
|
38
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Whether a reverse proxy stood between the client and this host: the request
|
|
49
|
+
* carries CF-Connecting-IP or X-Forwarded-For. Either header is stamped by a
|
|
50
|
+
* hop that forwards traffic, so the request did not originate on this machine.
|
|
51
|
+
* @param request - node HTTP request.
|
|
52
|
+
* @returns true when a forwarding header is present.
|
|
53
|
+
*/
|
|
54
|
+
export function isProxiedRequest(request: IncomingMessage): boolean {
|
|
55
|
+
return request.headers['cf-connecting-ip'] !== undefined
|
|
56
|
+
|| request.headers['x-forwarded-for'] !== undefined
|
|
57
|
+
}
|
|
58
|
+
|
|
39
59
|
/**
|
|
40
60
|
* Request-level trust fence: a loopback socket address AND a loopback Host
|
|
41
61
|
* header, plus browser same-origin markers. The socket address is
|
|
42
|
-
* authoritative;
|
|
62
|
+
* authoritative for a direct connection; a request that carries a forwarding
|
|
63
|
+
* header is refused outright, because a proxy in front of the host makes both
|
|
64
|
+
* the socket address and the Host header describe the proxy rather than the
|
|
65
|
+
* client. X-Forwarded-For is therefore never evidence *for* loopback.
|
|
43
66
|
*/
|
|
44
67
|
export function isLoopbackRequest(request: IncomingMessage): boolean {
|
|
45
68
|
if (!isLoopbackAddress(request.socket.remoteAddress)) return false
|
|
69
|
+
if (isProxiedRequest(request)) return false
|
|
46
70
|
const host = request.headers.host
|
|
47
71
|
if (typeof host !== 'string') return false
|
|
48
72
|
let hostUrl: URL
|