diff --git a/CHANGELOG.md b/CHANGELOG.md index 72fbf75..5632db3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# 1.1.2 + +- Add support for internal redirects. +- Make log more useful for multiple concurrent requests. + # 1.1.1 - Update linter rules. diff --git a/bin/main.dart b/bin/main.dart index f2d2d4a..4325690 100644 --- a/bin/main.dart +++ b/bin/main.dart @@ -6,28 +6,72 @@ import 'package:dotenv/dotenv.dart' as dotenv; import 'package:uri/uri.dart'; -final List trustedCert = [ +/// Invalid string URI. +const _invalidUri = '::Not valid URI::'; +const headersNotToForwardToRemote = [ + HttpHeaders.hostHeader, +]; +const headersToSpoofBeforeForwardToRemote = [ + HttpHeaders.refererHeader, +]; +const headersNotToForwardFromRemote = [ + HttpHeaders.connectionHeader, +]; +const headersToSpoofBeforeForwardFromRemote = [ + HttpHeaders.locationHeader, +]; + + +extension UriHasOrigin on Uri { + bool get hasOrigin => (scheme == 'http' || scheme == 'https') && host != ''; +} + +/// List of additional root CA +final List trustedRoots = [ [72, 80, 78, 151, 76, 13, 172, 91, 92, 212, 118, 200, 32, 34, 116, 178, 76, 140, 113, 114], // DST Root CA X3 ].map(String.fromCharCodes).toList(); -void addCORSHeaders(HttpRequest request) { - final _uri = Uri.tryParse(request.headers['referer']?.singleOrNull ?? '*'); - request.response.headers +bool secureCompare(String a, String b) { + if(a.codeUnits.length != b.codeUnits.length) + return false; + + var r = 0; + for(var i = 0; i < a.codeUnits.length; i++) { + r |= a.codeUnitAt(i) ^ b.codeUnitAt(i); + } + return r == 0; +} + +/// Returns environment variable or `.env` variable +String? getEnv(String variable) => + Platform.environment[variable] ?? dotenv.env[variable]; + +/// Adds CORS headers to [response] +void addCORSHeaders(HttpRequest request, HttpResponse response) { + final refererUri = Uri.tryParse( + request.headers[HttpHeaders.refererHeader]?.singleOrNull ?? _invalidUri, + ); + response.headers ..add( - 'Access-Control-Allow-Origin', - (_uri != null && (const [ 'http', 'https', ]).contains(_uri.scheme) && _uri.host != '') - ? _uri.origin + HttpHeaders.accessControlAllowOriginHeader, + (refererUri != null && refererUri.hasOrigin) + ? refererUri.origin : '*', ) ..add( - 'Access-Control-Allow-Methods', - request.headers['access-control-request-method']?.join(',') ?? '*', + HttpHeaders.accessControlAllowMethodsHeader, + request.headers[HttpHeaders.accessControlRequestMethodHeader]?.join(',') + ?? '*', ) ..add( - 'Access-Control-Allow-Headers', - request.headers['access-control-request-headers']?.join(',') ?? 'authorization,*', + HttpHeaders.accessControlAllowHeadersHeader, + request.headers[HttpHeaders.accessControlRequestHeadersHeader]?.join(',') + ?? 'authorization,*', ) - ..add('Access-Control-Allow-Credentials', 'true'); + ..add( + HttpHeaders.accessControlAllowCredentialsHeader, + 'true', + ); } void main(List arguments) async { @@ -35,44 +79,42 @@ void main(List arguments) async { if (File.fromUri(Uri.file(dotEnvFile)).existsSync()) dotenv.load(dotEnvFile); - // Local server - final localIp = InternetAddress.tryParse(dotenv.env['LOCAL_BIND_IP'] ?? '') ?? InternetAddress.loopbackIPv4; - final localPort = int.tryParse(dotenv.env['LOCAL_PORT'] ?? '') ?? 8080; + // Local server bind settings + final localBindIp = InternetAddress.tryParse(getEnv('LOCAL_BIND_IP') ?? '') ?? InternetAddress.loopbackIPv4; + final localPort = int.tryParse(getEnv('LOCAL_PORT') ?? '') ?? 8080; + // Local auth - final localUsername = dotenv.env['LOCAL_USERNAME']; - final localPassword = dotenv.env['LOCAL_PASSWORD']; - final localBasicAuth = (localUsername != null && localPassword != null) - ? 'Basic ${base64Encode(utf8.encode('$localUsername:$localPassword'))}' - : null; - final localBaseUrl = 'http://${localIp.host}:$localPort'; + final localUsername = getEnv('LOCAL_USERNAME'); + final localPassword = getEnv('LOCAL_PASSWORD'); + final localBasicAuth = (localUsername == null || localPassword == null) ? null + : 'Basic ${base64Encode(utf8.encode('$localUsername:$localPassword'))}'; + final localBaseUrl = 'http://${localBindIp.host}:$localPort'; // Remote server - final serverScheme = dotenv.env['SERVER_SCHEME'] ?? 'https'; - final serverHost = dotenv.env['SERVER_HOST'] ?? 'example.com'; - final serverPort = int.tryParse(dotenv.env['SERVER_PORT'] ?? (serverScheme == 'https' ? '443' : '')) ?? 80; - // Server auth - final serverUsername = dotenv.env['SERVER_USERNAME']; - final serverPassword = dotenv.env['SERVER_PASSWORD']; - final serverBasicAuth = (serverUsername != null && serverPassword != null) - ? 'Basic ${base64Encode(utf8.encode('$serverUsername:$serverPassword'))}' - : null; - final serverBaseUrl = '$serverScheme://$serverHost${![ 'http', 'https', ].contains(serverScheme) ? serverPort : ''}'; - - final httpProxy = Uri.tryParse(dotenv.env['HTTP_PROXY'] ?? '::Not valid URI::'); - final match = httpProxy != null - ? RegExp(r'^(?.+?):(?.+?)$') - .firstMatch(httpProxy.userInfo) - : null; - final proxyUsername = match?.namedGroup('username'); - final proxyPassword = match?.namedGroup('password'); - final httpProxyCredentials = (proxyUsername != null && proxyPassword != null) - ? HttpClientBasicCredentials(proxyUsername, proxyPassword) - : null; + final remoteScheme = getEnv('SERVER_SCHEME') ?? 'https'; + final remoteHost = getEnv('SERVER_HOST') ?? 'example.com'; + final remotePort = int.tryParse(getEnv('SERVER_PORT') ?? (remoteScheme == 'https' ? '443' : '')) ?? 80; + + // Remote server auth + final remoteUsername = getEnv('SERVER_USERNAME'); + final remotePassword = getEnv('SERVER_PASSWORD'); + final remoteBasicAuth = (remoteUsername == null || remotePassword == null) ? null + : 'Basic ${base64Encode(utf8.encode('$remoteUsername:$remotePassword'))}'; + final serverBaseUrl = '$remoteScheme://$remoteHost${![ 'http', 'https', ].contains(remoteScheme) ? remotePort : ''}'; + + // HTTP proxy + final httpProxy = Uri.tryParse(getEnv('HTTP_PROXY') ?? _invalidUri); + final httpProxyCredentialsMatch = httpProxy == null ? null + : RegExp(r'^(?.+?):(?.+?)$').firstMatch(httpProxy.userInfo); + final httpProxyUsername = httpProxyCredentialsMatch?.namedGroup('username'); + final httpProxyPassword = httpProxyCredentialsMatch?.namedGroup('password'); + final httpProxyCredentials = (httpProxyUsername == null || httpProxyPassword == null) ? null + : HttpClientBasicCredentials(httpProxyUsername, httpProxyPassword); stdout.write('Starting mirror server $localBaseUrl -> $serverBaseUrl'); if (localBasicAuth != null) stdout.write(' [Local auth]'); - if (serverBasicAuth != null) + if (remoteBasicAuth != null) stdout.write(' [Remote auth auto-fill]'); if (httpProxy != null) { stdout.write(' [Through HTTP proxy]'); @@ -85,7 +127,7 @@ void main(List arguments) async { late final HttpServer server; try { - server = await HttpServer.bind(localIp, localPort); + server = await HttpServer.bind(localBindIp, localPort); } catch(error) { stdout.writeln(' [Error]'); stderr @@ -94,11 +136,13 @@ void main(List arguments) async { return; } stdout.writeln(' [Done]'); + final client = HttpClient() + ..autoUncompress = false ..badCertificateCallback = (cert, host, port) => - trustedCert.contains(String.fromCharCodes(cert.sha1)); + trustedRoots.contains(String.fromCharCodes(cert.sha1)); - // HTTP proxy + // Apply HTTP proxy if (httpProxy != null) { if (httpProxyCredentials != null) { client.addProxyCredentials( @@ -111,15 +155,21 @@ void main(List arguments) async { client.findProxy = (uri) => 'PROXY ${httpProxy.host}:${httpProxy.port}'; } + var requestId = 0; + server.listen((request) { - addCORSHeaders(request); + requestId++; + final response = request.response; - // preflight + addCORSHeaders(request, response); + + // Handle preflight if ( - request.method == 'OPTIONS' && + request.method.toUpperCase() == 'OPTIONS' && request.headers[HttpHeaders.accessControlRequestMethodHeader] != null ) { + stdout.writeln('[$requestId] Preflight handled.'); response ..contentLength = 0 ..statusCode = HttpStatus.ok @@ -129,7 +179,8 @@ void main(List arguments) async { if (localBasicAuth != null) { final _userAuth = request.headers[HttpHeaders.authorizationHeader]?.singleOrNull; - if (_userAuth == null || _userAuth != localBasicAuth) { + if (_userAuth == null || !secureCompare(_userAuth, localBasicAuth)) { + stdout.writeln('[$requestId] Unauthorized access denied.'); response ..statusCode = HttpStatus.unauthorized ..headers.add(HttpHeaders.wwwAuthenticateHeader, 'Basic realm=Protected') @@ -140,61 +191,92 @@ void main(List arguments) async { } } - final targetUri = (UriBuilder - .fromUri(request.uri) - ..scheme = serverScheme - ..host = serverHost - ..port = serverPort + final remoteUri = (UriBuilder.fromUri(request.uri) + ..scheme = remoteScheme + ..host = remoteHost + ..port = remotePort ).build(); - stdout.write('Proxy: ${request.method} $targetUri'); - - (client - ..userAgent = request.headers['user-agent']?.singleOrNull) - .openUrl(request.method, targetUri) - .then((proxyRequest) async { - if (serverBasicAuth != null) - proxyRequest.headers.add(HttpHeaders.authorizationHeader, serverBasicAuth); - request.headers.forEach((name, values) { - if (![ - // Headers to skip - HttpHeaders.hostHeader, - ].contains(name)) { - if (name == HttpHeaders.refererHeader) - proxyRequest.headers.add( - name, - values.map( + stdout.writeln('[$requestId] Forwarding: ${request.method} $remoteUri'); + + (client..userAgent = request.headers[HttpHeaders.userAgentHeader]?.singleOrNull) + .openUrl(request.method, remoteUri) + .then((requestToRemote) async { + requestToRemote.followRedirects = false; + + // Remote server auth + if (remoteBasicAuth != null) + requestToRemote.headers.add(HttpHeaders.authorizationHeader, remoteBasicAuth); + + request.headers.forEach((headerName, headerValues) { + // Filter out headers + if (!headersNotToForwardToRemote.contains(headerName)) { + // Spoof headers to look like from the original server + if (headersToSpoofBeforeForwardToRemote.contains(headerName)) + requestToRemote.headers.add( + headerName, + headerValues.map( (value) => value.replaceAll(localBaseUrl, serverBaseUrl), ), ); else - proxyRequest.headers.add(name, values); + // Forward headers as-is + requestToRemote.headers.add(headerName, headerValues); } }); + + // If there's content pipe request body if (request.contentLength > 0) - await proxyRequest.addStream(request); - return proxyRequest.close(); + await requestToRemote.addStream(request); + + return requestToRemote.close(); }) .then( - (proxyResponse) async { - stdout.write(' [${proxyResponse.statusCode}]'); - proxyResponse.headers.forEach((name, values) { - if (![ - HttpHeaders.connectionHeader, - HttpHeaders.contentLengthHeader, - HttpHeaders.contentEncodingHeader, - ].contains(name)) - response.headers.add(name, values); + (remoteResponse) async { + stdout.writeln('[$requestId] Remote response: ${remoteResponse.statusCode}'); + remoteResponse.headers.forEach((headerName, headerValues) { + // Filter out headers + if (!headersNotToForwardFromRemote.contains(headerName)) + // Spoof headers, so they'll point to mirror + if (headersToSpoofBeforeForwardFromRemote.contains(headerName)) + response.headers.add( + headerName, + headerValues.map( + (value) => value.replaceAll(serverBaseUrl, localBaseUrl), + ), + ); + // Add headers as-is + else + response.headers.add(headerName, headerValues); }); - response.statusCode = proxyResponse.statusCode; - proxyResponse + response.statusCode = remoteResponse.statusCode; + + // Pipe remote response + remoteResponse .pipe(response) - .then((value) => stdout.writeln(' [Done]')) + .then( + (_) => stdout.writeln('[$requestId] Forwarded.'), + onError: (dynamic error) { + final _error = error.toString().splitMapJoin( + '\n', + onNonMatch: (part) => '[$requestId] $part', + ); + stderr + ..writeln('[$requestId] Response forwarding error:') + ..writeln(_error); + }, + ) .ignore(); }, onError: (dynamic error) { - stdout.writeln(' [Error]'); - stderr.writeln('Proxy error details: $error'); + final _error = error.toString().splitMapJoin( + '\n', + onNonMatch: (part) => '[$requestId] $part', + ); + stderr + ..writeln('[$requestId] Mirror error:') + ..writeln(_error); + response ..statusCode = HttpStatus.internalServerError ..headers.contentType = ContentType.text diff --git a/pubspec.lock b/pubspec.lock index 4583cbf..5e688f7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,85 +5,97 @@ packages: dependency: transitive description: name: args - url: "https://pub.dartlang.org" + sha256: "139d809800a412ebb26a3892da228b2d0ba36f0ef5d9a82166e5e52ec8d61611" + url: "https://pub.dev" source: hosted - version: "2.3.0" + version: "2.3.2" collection: dependency: "direct main" description: name: collection - url: "https://pub.dartlang.org" + sha256: "4a07be6cb69c84d677a6c3096fcf960cc3285a8330b4603e0d463d15d9bd934c" + url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.1" dotenv: dependency: "direct main" description: name: dotenv - url: "https://pub.dartlang.org" + sha256: dc4c91d8d5e9e361803fc81e8ea7ba0f35be483b656837ea6b9a3c41df308c68 + url: "https://pub.dev" source: hosted version: "3.0.0" flutter_lints: dependency: transitive description: name: flutter_lints - url: "https://pub.dartlang.org" + sha256: aeb0b80a8b3709709c9cc496cdc027c5b3216796bc0af0ce1007eaf24464fd4c + url: "https://pub.dev" source: hosted version: "2.0.1" lints: dependency: transitive description: name: lints - url: "https://pub.dartlang.org" + sha256: "5e4a9cd06d447758280a8ac2405101e0e2094d2a1dbdd3756aec3fe7775ba593" + url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.0.1" matcher: dependency: transitive description: name: matcher - url: "https://pub.dartlang.org" + sha256: c94db23593b89766cda57aab9ac311e3616cf87c6fa4e9749df032f66f30dcb8 + url: "https://pub.dev" source: hosted - version: "0.12.11" + version: "0.12.14" meta: dependency: transitive description: name: meta - url: "https://pub.dartlang.org" + sha256: "12307e7f0605ce3da64cf0db90e5fcab0869f3ca03f76be6bb2991ce0a55e82b" + url: "https://pub.dev" source: hosted - version: "1.7.0" + version: "1.9.0" path: dependency: transitive description: name: path - url: "https://pub.dartlang.org" + sha256: "8829d8a55c13fc0e37127c29fedf290c102f4e40ae94ada574091fe0ff96c917" + url: "https://pub.dev" source: hosted - version: "1.8.1" + version: "1.8.3" quiver: dependency: transitive description: name: quiver - url: "https://pub.dartlang.org" + sha256: b1c1ac5ce6688d77f65f3375a9abb9319b3cb32486bdc7a1e0fdf004d7ba4e47 + url: "https://pub.dev" source: hosted - version: "3.0.1+1" + version: "3.2.1" stack_trace: dependency: transitive description: name: stack_trace - url: "https://pub.dartlang.org" + sha256: c3c7d8edb15bee7f0f74debd4b9c5f3c2ea86766fe4178eb2a18eb30a0bdaed5 + url: "https://pub.dev" source: hosted - version: "1.10.0" + version: "1.11.0" uri: dependency: "direct main" description: name: uri - url: "https://pub.dartlang.org" + sha256: "889eea21e953187c6099802b7b4cf5219ba8f3518f604a1033064d45b1b8268a" + url: "https://pub.dev" source: hosted version: "1.0.0" zekfad_lints: dependency: "direct dev" description: name: zekfad_lints - url: "https://pub.dartlang.org" + sha256: "998e12b9e4ca20c2d0eccab60741ec30b7ec398f6f19100a86e3e352666a0b7a" + url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.2.0" sdks: - dart: ">=2.17.6 <3.0.0" + dart: ">=2.18.0 <4.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index d867a83..1413bc7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ publish_to: none name: dev_mirror description: Development proxy for accessing private APIs. -version: 1.1.1 +version: 1.1.2 homepage: https://github.com/Zekfad/dev-mirror environment: