mirror of
https://github.com/block/buzz.git
synced 2026-11-14 05:12:27 +00:00
Relay WebSocket clients in this repo now send a `User-Agent` of the form `<name>/<version>` on the upgrade request. The version comes from the build. | Client | User-Agent | |---|---| | `buzz-acp` | `buzz-acp/<crate version>` | | `buzz-cli` | `buzz-cli/<crate version>` | | `buzz-ws-client` (default) | `buzz-ws-client/<crate version>` | | `buzz-pairing-cli` | `buzz-pairing-cli/<crate version>` | | `buzz-audio-client` (default) | `buzz-audio-client/<crate version>` | | mobile (relay and huddle sockets) | `Buzz-Mobile/<app version>` | - **`buzz-ws-client`**: every connect path sends the default. A `User-Agent` in the caller's `connect_with_headers` headers replaces it, so other consumers can identify themselves without any new API. `buzz-cli` uses this to send its own name. - **`buzz-audio-client`**: keeps a `User-Agent` already on the host-supplied request, and adds its default otherwise. - **Mobile**: `openRelayWebSocket` sets the HTTP client's `userAgent`, which also covers the huddle transport. Startup waits up to 500 ms for the app version from `package_info_plus` before the app runs, so the first socket carries it; if the lookup fails or times out, the value is `Buzz-Mobile`. Each client has a test asserting the header on the upgrade request. --------- Signed-off-by: Will Pfleger <wpfleger@block.xyz> Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
366 lines
12 KiB
Dart
366 lines
12 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:buzz/shared/relay/relay_socket.dart';
|
|
import 'package:buzz/shared/relay/relay_websocket.dart';
|
|
import 'package:crypto/crypto.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
/// A server that completes the WS handshake then never speaks again: no pongs,
|
|
/// no close frame. Only a client-side ping timeout can notice.
|
|
Future<ServerSocket> _silentAfterHandshakeServer() async {
|
|
final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
|
|
server.listen((client) {
|
|
client.listen(
|
|
(data) {
|
|
final match = RegExp(
|
|
r'Sec-WebSocket-Key: (.*)\r\n',
|
|
caseSensitive: false,
|
|
).firstMatch(String.fromCharCodes(data));
|
|
if (match == null) return;
|
|
final accept = base64.encode(
|
|
sha1
|
|
.convert(
|
|
utf8.encode(
|
|
'${match.group(1)!.trim()}258EAFA5-E914-47DA-95CA-C5AB0DC85B11',
|
|
),
|
|
)
|
|
.bytes,
|
|
);
|
|
client.write(
|
|
'HTTP/1.1 101 Switching Protocols\r\n'
|
|
'Upgrade: websocket\r\nConnection: Upgrade\r\n'
|
|
'Sec-WebSocket-Accept: $accept\r\n\r\n',
|
|
);
|
|
},
|
|
onError: (_) {},
|
|
onDone: () {},
|
|
);
|
|
});
|
|
return server;
|
|
}
|
|
|
|
/// A server that accepts TCP connections and then never answers, like a
|
|
/// stalled TLS or upgrade handshake on a congested network.
|
|
/// Accepts TCP and never answers the upgrade. [closed] completes when the
|
|
/// client drops the connection.
|
|
Future<(ServerSocket server, Future<void> closed)>
|
|
_stalledHandshakeServer() async {
|
|
final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
|
|
final closed = Completer<void>();
|
|
server.listen(
|
|
(client) => client.listen(
|
|
(_) {},
|
|
onError: (_) {},
|
|
onDone: () {
|
|
if (!closed.isCompleted) closed.complete();
|
|
},
|
|
),
|
|
);
|
|
return (server, closed.future);
|
|
}
|
|
|
|
void main() {
|
|
const testPingInterval = Duration(milliseconds: 150);
|
|
const testConnectTimeout = Duration(milliseconds: 200);
|
|
|
|
setUp(() {
|
|
RelaySocket.debugPingInterval = testPingInterval;
|
|
RelaySocket.debugConnectTimeout = testConnectTimeout;
|
|
});
|
|
|
|
tearDown(() {
|
|
RelaySocket.debugPingInterval = RelaySocket.pingInterval;
|
|
RelaySocket.debugConnectTimeout = relayWebSocketConnectTimeout;
|
|
});
|
|
|
|
for (final scoped in [false, true]) {
|
|
test('gives up on a handshake that never completes '
|
|
'(${scoped ? 'scoped headers' : 'no headers'})', () async {
|
|
final (server, clientClosed) = await _stalledHandshakeServer();
|
|
|
|
final disconnected = Completer<Object?>();
|
|
final socket = RelaySocket(
|
|
wsUrl: 'ws://127.0.0.1:${server.port}',
|
|
nsec: null,
|
|
onMessage: (_) {},
|
|
onConnected: () {},
|
|
onDisconnected: (error) {
|
|
if (!disconnected.isCompleted) disconnected.complete(error);
|
|
},
|
|
);
|
|
if (scoped) {
|
|
socket.headers = {'Nostr-Federated-Identity': 'Bearer synthetic'};
|
|
}
|
|
unawaited(socket.connect());
|
|
|
|
final error = await disconnected.future.timeout(
|
|
testConnectTimeout * 10,
|
|
onTimeout: () => fail(
|
|
'RelaySocket must fail a stalled handshake so the session can '
|
|
'schedule a reconnect',
|
|
),
|
|
);
|
|
expect(socket.state, SocketState.disconnected);
|
|
expect(error.toString(), contains('Relay connection timed out'));
|
|
// The abandoned handshake is closed, not left for the OS to time out.
|
|
await clientClosed.timeout(
|
|
testConnectTimeout * 10,
|
|
onTimeout: () => fail('the timed-out handshake was left open'),
|
|
);
|
|
|
|
socket.dispose();
|
|
await server.close();
|
|
});
|
|
}
|
|
|
|
// Pairing validation disconnects in a finally block after a failed connect;
|
|
// an unbounded close there stranded pairing until the pairing relay expired.
|
|
for (final refusal in [401, null]) {
|
|
test(
|
|
'disconnect completes after '
|
|
'${refusal == null ? 'a timed-out handshake' : 'a refused upgrade ($refusal)'}',
|
|
() async {
|
|
HttpServer? refusing;
|
|
ServerSocket? stalled;
|
|
if (refusal == null) {
|
|
stalled = (await _stalledHandshakeServer()).$1;
|
|
} else {
|
|
refusing = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
|
refusing.listen((request) async {
|
|
request.response.statusCode = refusal;
|
|
await request.response.close();
|
|
});
|
|
}
|
|
final url = 'ws://127.0.0.1:${(refusing?.port ?? stalled!.port)}';
|
|
Object? reported;
|
|
final socket = RelaySocket(
|
|
wsUrl: url,
|
|
nsec: null,
|
|
onMessage: (_) {},
|
|
onConnected: () {},
|
|
onDisconnected: (error) => reported = error,
|
|
);
|
|
|
|
await socket.connect();
|
|
expect(socket.state, SocketState.disconnected);
|
|
if (refusal != null) expect(relayUpgradeRefused(reported), isTrue);
|
|
await socket.disconnect().timeout(
|
|
testConnectTimeout * 10,
|
|
onTimeout: () =>
|
|
fail('disconnect() hung on a channel that never opened'),
|
|
);
|
|
|
|
await refusing?.close(force: true);
|
|
await stalled?.close();
|
|
},
|
|
);
|
|
}
|
|
|
|
test('disconnect is bounded while the upgrade is still pending', () async {
|
|
// Pairing's 8s validation deadline can fire before the 10s connect timeout.
|
|
RelaySocket.debugConnectTimeout = const Duration(seconds: 30);
|
|
final (server, _) = await _stalledHandshakeServer();
|
|
final socket = RelaySocket(
|
|
wsUrl: 'ws://127.0.0.1:${server.port}',
|
|
nsec: null,
|
|
onMessage: (_) {},
|
|
onConnected: () {},
|
|
onDisconnected: (_) {},
|
|
);
|
|
unawaited(socket.connect());
|
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
|
|
|
await socket.disconnect().timeout(
|
|
RelaySocket.closeTimeout * 2,
|
|
onTimeout: () => fail('disconnect() hung on a pending upgrade'),
|
|
);
|
|
expect(socket.state, SocketState.disconnected);
|
|
|
|
socket.dispose();
|
|
await server.close();
|
|
});
|
|
|
|
test('detects a peer that stops answering pings', () async {
|
|
final server = await _silentAfterHandshakeServer();
|
|
|
|
final disconnected = Completer<Object?>();
|
|
final socket = RelaySocket(
|
|
wsUrl: 'ws://127.0.0.1:${server.port}',
|
|
nsec: null,
|
|
onMessage: (_) {},
|
|
onConnected: () {},
|
|
onDisconnected: (error) {
|
|
if (!disconnected.isCompleted) disconnected.complete(error);
|
|
},
|
|
);
|
|
unawaited(socket.connect());
|
|
|
|
var detected = true;
|
|
try {
|
|
await disconnected.future.timeout(testPingInterval * 4);
|
|
} on TimeoutException {
|
|
detected = false;
|
|
}
|
|
|
|
expect(
|
|
detected,
|
|
isTrue,
|
|
reason:
|
|
'RelaySocket must surface an unanswered ping through onDisconnected',
|
|
);
|
|
|
|
socket.dispose();
|
|
await server.close();
|
|
});
|
|
|
|
test(
|
|
'scoped headers stay at the upgrade origin and healthy peers stay connected',
|
|
() async {
|
|
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
|
final paths = <String>[];
|
|
// A different port prevents a closed upgrade connection from masking a
|
|
// redirect attempt through connection-pool reuse.
|
|
final sink = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
|
sink.listen((request) async {
|
|
paths.add('sink');
|
|
expect(
|
|
request.headers.value('Nostr-Federated-Identity'),
|
|
'Bearer synthetic',
|
|
);
|
|
request.response.statusCode = 403;
|
|
await request.response.close();
|
|
});
|
|
server.listen((request) async {
|
|
paths.add(request.uri.path);
|
|
expect(
|
|
request.headers.value('Nostr-Federated-Identity'),
|
|
'Bearer synthetic',
|
|
);
|
|
if (request.uri.path == '/denied') {
|
|
request.response.statusCode = 403;
|
|
await request.response.close();
|
|
return;
|
|
}
|
|
if (request.uri.path == '/redirect') {
|
|
request.response.statusCode = 302;
|
|
request.response.headers.set(
|
|
'Location',
|
|
'http://127.0.0.1:${sink.port}/sink',
|
|
);
|
|
await request.response.close();
|
|
return;
|
|
}
|
|
final ws = await WebSocketTransformer.upgrade(request);
|
|
ws.listen((_) {}, onError: (_) {}, onDone: () {});
|
|
});
|
|
|
|
var tornDown = false;
|
|
final socket = RelaySocket(
|
|
wsUrl: 'ws://127.0.0.1:${server.port}',
|
|
nsec: null,
|
|
onMessage: (_) {},
|
|
onConnected: () {},
|
|
onDisconnected: (_) => tornDown = true,
|
|
);
|
|
socket.headers = {'Nostr-Federated-Identity': 'Bearer synthetic'};
|
|
unawaited(socket.connect());
|
|
|
|
await Future<void>.delayed(testPingInterval * 4);
|
|
|
|
expect(tornDown, isFalse);
|
|
|
|
await socket.disconnect();
|
|
final refused = Completer<void>();
|
|
final redirect = RelaySocket(
|
|
wsUrl: 'ws://127.0.0.1:${server.port}/redirect',
|
|
nsec: null,
|
|
onMessage: (_) {},
|
|
onConnected: () {},
|
|
onDisconnected: (_) {
|
|
if (!refused.isCompleted) refused.complete();
|
|
},
|
|
);
|
|
redirect.headers = {'Nostr-Federated-Identity': 'Bearer synthetic'};
|
|
await redirect.connect();
|
|
await refused.future;
|
|
expect(paths, ['/', '/redirect']);
|
|
redirect.dispose();
|
|
final denial = Completer<Object?>();
|
|
final denied = RelaySocket(
|
|
wsUrl: 'ws://127.0.0.1:${server.port}/denied',
|
|
nsec: null,
|
|
onMessage: (_) {},
|
|
onConnected: () {},
|
|
onDisconnected: (error) {
|
|
if (!denial.isCompleted) denial.complete(error);
|
|
},
|
|
);
|
|
denied.headers = {'Nostr-Federated-Identity': 'Bearer synthetic'};
|
|
await denied.connect();
|
|
expect(relayUpgradeRefused(await denial.future), isTrue);
|
|
denied.dispose();
|
|
await server.close(force: true);
|
|
await sink.close(force: true);
|
|
},
|
|
);
|
|
|
|
test('upgrades name the app and its version', () async {
|
|
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
|
final agents = <String?>[];
|
|
server.listen((request) async {
|
|
agents.add(request.headers.value(HttpHeaders.userAgentHeader));
|
|
final ws = await WebSocketTransformer.upgrade(request);
|
|
ws.listen((_) {}, onError: (_) {}, onDone: () {});
|
|
});
|
|
setRelayUserAgent('1.2.3');
|
|
for (final headers in [
|
|
<String, String>{},
|
|
{'Nostr-Federated-Identity': 'Bearer synthetic'},
|
|
]) {
|
|
final socket = await openRelayWebSocket(
|
|
Uri.parse('ws://127.0.0.1:${server.port}'),
|
|
headers: headers,
|
|
pingInterval: const Duration(seconds: 30),
|
|
);
|
|
await socket.close();
|
|
}
|
|
expect(agents, ['Buzz-Mobile/1.2.3', 'Buzz-Mobile/1.2.3']);
|
|
await server.close(force: true);
|
|
});
|
|
|
|
test('first upgrade waits for a delayed version lookup', () async {
|
|
final agents = <String?>[];
|
|
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
|
server.listen((request) async {
|
|
agents.add(request.headers.value(HttpHeaders.userAgentHeader));
|
|
final ws = await WebSocketTransformer.upgrade(request);
|
|
ws.listen((_) {}, onError: (_) {}, onDone: () {});
|
|
});
|
|
relayUserAgent = 'Buzz-Mobile';
|
|
await loadRelayUserAgent(
|
|
() => Future.delayed(const Duration(milliseconds: 100), () => '4.5.6'),
|
|
);
|
|
final socket = await openRelayWebSocket(
|
|
Uri.parse('ws://127.0.0.1:${server.port}'),
|
|
headers: const {},
|
|
pingInterval: const Duration(seconds: 30),
|
|
);
|
|
await socket.close();
|
|
expect(agents, ['Buzz-Mobile/4.5.6']);
|
|
await server.close(force: true);
|
|
});
|
|
|
|
test('a hung or failed version lookup keeps the unversioned agent', () async {
|
|
relayUserAgent = 'Buzz-Mobile';
|
|
await loadRelayUserAgent(
|
|
() => Completer<String>().future,
|
|
timeout: const Duration(milliseconds: 10),
|
|
);
|
|
expect(relayUserAgent, 'Buzz-Mobile');
|
|
await loadRelayUserAgent(() async => throw StateError('no platform'));
|
|
expect(relayUserAgent, 'Buzz-Mobile');
|
|
});
|
|
}
|