@@ -15,9 +15,14 @@ const MAX_SCAN_BYTES = 100 * 1024 * 1024;
1515const EXTRA_SCAN_REQUESTS = 64 ;
1616const blockedAddresses = createBlockedAddresses ( ) ;
1717
18+ interface PinnedAddress {
19+ address : string ;
20+ family : 4 | 6 ;
21+ }
22+
1823interface OutboundDependencies {
1924 fetch ?: typeof fetch ;
20- resolve ?: ( hostname : string ) => Promise < Array < { address : string ; family : 4 | 6 } > > ;
25+ resolve ?: ( hostname : string ) => Promise < PinnedAddress [ ] > ;
2126}
2227
2328interface OutboundOptions {
@@ -39,6 +44,11 @@ export class OutboundClient {
3944 private remainingRequests : number ;
4045 private readonly fetchImpl : typeof fetch ;
4146 private readonly resolve : NonNullable < OutboundDependencies [ "resolve" ] > ;
47+ // Un dispatcher per insieme di indirizzi fissati: le centinaia di pagine
48+ // dello stesso monitor riusano connessioni TCP/TLS invece di rifarle a ogni
49+ // richiesta. Il DNS resta risolto una volta sola per host per tutto lo scan.
50+ private readonly agents = new Map < string , Agent > ( ) ;
51+ private readonly pinned = new Map < string , Promise < PinnedAddress [ ] > > ( ) ;
4252
4353 constructor (
4454 private readonly site : SiteConfig ,
@@ -57,47 +67,55 @@ export class OutboundClient {
5767 throw new OutboundBudgetError ( "Budget richieste dello scan esaurito." ) ;
5868 }
5969
60- const addresses = await this . resolvePinnedAddresses ( currentUrl ) ;
61- const dispatcher = new Agent ( {
62- connect : { lookup : pinnedLookup ( addresses ) }
63- } ) ;
64-
65- try {
66- const response = await this . fetchImpl ( currentUrl , {
67- dispatcher,
68- headers : options . headers ,
69- redirect : "manual" ,
70- signal : AbortSignal . timeout ( this . site . crawl . timeoutMs )
71- } as RequestInit ) ;
72-
73- const location = response . headers . get ( "location" ) ;
74- if ( location && isRedirect ( response . status ) ) {
75- await response . body ?. cancel ( ) ;
76- if ( redirects >= MAX_REDIRECTS ) throw new Error ( `Troppi redirect: ${ rawUrl } ` ) ;
77- currentUrl = this . authorize ( new URL ( location , currentUrl ) . toString ( ) ) ;
78- continue ;
79- }
70+ const response = await this . fetchImpl ( currentUrl , {
71+ dispatcher : this . dispatcherFor ( await this . resolvePinnedAddresses ( currentUrl ) ) ,
72+ headers : options . headers ,
73+ redirect : "manual" ,
74+ signal : AbortSignal . timeout ( this . site . crawl . timeoutMs )
75+ } as RequestInit ) ;
8076
81- if ( response . status < 200 || response . status >= 300 ) {
82- await response . body ?. cancel ( ) ;
83- return {
84- body : new Uint8Array ( ) ,
85- headers : response . headers ,
86- status : response . status ,
87- url : currentUrl
88- } ;
89- }
77+ const location = response . headers . get ( "location" ) ;
78+ if ( location && isRedirect ( response . status ) ) {
79+ await response . body ?. cancel ( ) ;
80+ if ( redirects >= MAX_REDIRECTS ) throw new Error ( `Troppi redirect: ${ rawUrl } ` ) ;
81+ currentUrl = this . authorize ( new URL ( location , currentUrl ) . toString ( ) ) ;
82+ continue ;
83+ }
9084
91- const maxBytes =
92- typeof options . maxBytes === "function"
93- ? options . maxBytes ( currentUrl , response . headers )
94- : options . maxBytes ;
95- const body = await this . readBody ( response , maxBytes ) ;
96- return { body , headers : response . headers , status : response . status , url : currentUrl } ;
97- } finally {
98- await dispatcher . close ( ) ;
85+ if ( response . status < 200 || response . status >= 300 ) {
86+ await response . body ?. cancel ( ) ;
87+ return {
88+ body : new Uint8Array ( ) ,
89+ headers : response . headers ,
90+ status : response . status ,
91+ url : currentUrl
92+ } ;
9993 }
94+
95+ const maxBytes =
96+ typeof options . maxBytes === "function"
97+ ? options . maxBytes ( currentUrl , response . headers )
98+ : options . maxBytes ;
99+ const body = await this . readBody ( response , maxBytes ) ;
100+ return { body, headers : response . headers , status : response . status , url : currentUrl } ;
101+ }
102+ }
103+
104+ /** Chiude i pool aperti: senza questo il processo resta appeso a fine scan. */
105+ async close ( ) : Promise < void > {
106+ const agents = [ ...this . agents . values ( ) ] ;
107+ this . agents . clear ( ) ;
108+ await Promise . all ( agents . map ( ( agent ) => agent . close ( ) ) ) ;
109+ }
110+
111+ private dispatcherFor ( addresses : PinnedAddress [ ] ) : Agent {
112+ const key = addresses . map ( ( { address } ) => address ) . join ( "," ) ;
113+ let agent = this . agents . get ( key ) ;
114+ if ( ! agent ) {
115+ agent = new Agent ( { connect : { lookup : pinnedLookup ( addresses ) } } ) ;
116+ this . agents . set ( key , agent ) ;
100117 }
118+ return agent ;
101119 }
102120
103121 private authorize ( rawUrl : string ) : string {
@@ -113,15 +131,22 @@ export class OutboundClient {
113131 return url . toString ( ) ;
114132 }
115133
116- private async resolvePinnedAddresses ( url : string ) : Promise < Array < { address : string ; family : 4 | 6 } > > {
134+ private resolvePinnedAddresses ( url : string ) : Promise < PinnedAddress [ ] > {
117135 const rawHostname = new URL ( url ) . hostname ;
118136 const hostname = rawHostname . startsWith ( "[" ) ? rawHostname . slice ( 1 , - 1 ) : rawHostname ;
119- const addresses = await this . resolve ( hostname ) ;
120- if ( addresses . length === 0 ) throw new Error ( `DNS senza indirizzi per ${ hostname } ` ) ;
121- if ( addresses . some ( ( { address, family } ) => ! isPublicAddress ( address , family ) ) ) {
122- throw new Error ( `Destinazione privata o riservata bloccata: ${ hostname } ` ) ;
123- }
124- return addresses ;
137+ const cached = this . pinned . get ( hostname ) ;
138+ if ( cached ) return cached ;
139+
140+ const promise = ( async ( ) => {
141+ const addresses = await this . resolve ( hostname ) ;
142+ if ( addresses . length === 0 ) throw new Error ( `DNS senza indirizzi per ${ hostname } ` ) ;
143+ if ( addresses . some ( ( { address, family } ) => ! isPublicAddress ( address , family ) ) ) {
144+ throw new Error ( `Destinazione privata o riservata bloccata: ${ hostname } ` ) ;
145+ }
146+ return addresses ;
147+ } ) ( ) ;
148+ this . pinned . set ( hostname , promise ) ;
149+ return promise ;
125150 }
126151
127152 private async readBody ( response : Response , maxBytes : number ) : Promise < Uint8Array > {
@@ -199,20 +224,30 @@ function createBlockedAddresses(): BlockList {
199224 [ "192.0.2.0" , 24 ] ,
200225 [ "192.168.0.0" , 16 ] ,
201226 [ "198.18.0.0" , 15 ] ,
227+ [ "192.88.99.0" , 24 ] ,
202228 [ "198.51.100.0" , 24 ] ,
203229 [ "203.0.113.0" , 24 ] ,
204230 [ "224.0.0.0" , 4 ] ,
205231 [ "240.0.0.0" , 4 ]
206232 ] as const ) {
207233 list . addSubnet ( network , prefix , "ipv4" ) ;
208234 }
235+ // IANA IPv6 Special-Purpose Address Registry: tutto ciò che non è "Global:
236+ // True". I prefissi di transizione (NAT64, Teredo, 6to4) restano bloccati
237+ // perché incapsulano destinazioni IPv4 che possono essere riservate.
209238 for ( const [ network , prefix ] of [
210239 [ "::" , 128 ] ,
211240 [ "::1" , 128 ] ,
241+ [ "64:ff9b::" , 96 ] ,
212242 [ "64:ff9b:1::" , 48 ] ,
213243 [ "100::" , 64 ] ,
244+ [ "2001::" , 32 ] ,
214245 [ "2001:2::" , 48 ] ,
246+ [ "2001:20::" , 28 ] ,
215247 [ "2001:db8::" , 32 ] ,
248+ [ "2002::" , 16 ] ,
249+ [ "3fff::" , 20 ] ,
250+ [ "5f00::" , 16 ] ,
216251 [ "fc00::" , 7 ] ,
217252 [ "fe80::" , 10 ] ,
218253 [ "ff00::" , 8 ]
0 commit comments