Server hardening: finite numbers, open servers, unkickable host, no URL leaks
- Reject non-finite numerics at protocol parse and in the engine (a 1e400 seek became Infinity and broke every broadcast). - Reject empty swaps; sanitize URL-looking media names. - Blank password = open server; defaults no longer 'change-me'. - Loopback peers (the in-process host) can't be kicked. - /health no longer exposes media/transport; file URLs use the host the client dialled instead of 127.0.0.1. - Count served bytes as streamed, not as requested. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
32848aaf71
commit
e2301aa4ec
12 files changed
+370
-45
No files matched your search
@@ -12,6 +12,8 @@ class AppConfig {
|
|||||||
final String name;
|
final String name;
|
||||||
final String mediaPath;
|
final String mediaPath;
|
||||||
final String logLevel;
|
final String logLevel;
|
||||||
|
/// Optional explicit host for file-media URLs; empty means use the host the
|
||||||
|
/// client dialled.
|
||||||
final String advertiseHost;
|
final String advertiseHost;
|
||||||
|
|
||||||
const AppConfig({
|
const AppConfig({
|
||||||
@@ -20,21 +22,21 @@ class AppConfig {
|
|||||||
required this.name,
|
required this.name,
|
||||||
this.mediaPath = '',
|
this.mediaPath = '',
|
||||||
this.logLevel = 'INFO',
|
this.logLevel = 'INFO',
|
||||||
this.advertiseHost = '127.0.0.1',
|
this.advertiseHost = '',
|
||||||
});
|
});
|
||||||
|
|
||||||
factory AppConfig.defaults() =>
|
factory AppConfig.defaults() =>
|
||||||
const AppConfig(port: 8080, secret: 'change-me', name: 'room');
|
const AppConfig(port: 8080, secret: '', name: 'room');
|
||||||
|
|
||||||
factory AppConfig.fromMap(Map<String, Object?> map) {
|
factory AppConfig.fromMap(Map<String, Object?> map) {
|
||||||
try {
|
try {
|
||||||
return AppConfig(
|
return AppConfig(
|
||||||
port: (map['port'] as num?)?.toInt() ?? 8080,
|
port: (map['port'] as num?)?.toInt() ?? 8080,
|
||||||
secret: (map['secret'] as String?) ?? 'change-me',
|
secret: (map['secret'] as String?) ?? '',
|
||||||
name: (map['name'] as String?) ?? 'room',
|
name: (map['name'] as String?) ?? 'room',
|
||||||
mediaPath: (map['mediaPath'] as String?) ?? '',
|
mediaPath: (map['mediaPath'] as String?) ?? '',
|
||||||
logLevel: (map['logLevel'] as String?) ?? 'INFO',
|
logLevel: (map['logLevel'] as String?) ?? 'INFO',
|
||||||
advertiseHost: (map['advertiseHost'] as String?) ?? '127.0.0.1',
|
advertiseHost: (map['advertiseHost'] as String?) ?? '',
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw ConfigException('invalid config map: $e');
|
throw ConfigException('invalid config map: $e');
|
||||||
|
|||||||
@@ -17,6 +17,23 @@ enum MessageKind {
|
|||||||
cancel,
|
cancel,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reads an optional JSON number as a finite double. JSON like 1e400 decodes to
|
||||||
|
/// Infinity, which would later break jsonEncode on every broadcast.
|
||||||
|
double finiteDouble(Object? value, String field, {double fallback = 0.0}) {
|
||||||
|
if (value == null) return fallback;
|
||||||
|
final d = (value as num).toDouble();
|
||||||
|
if (!d.isFinite) throw ProtocolException('$field must be finite');
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same as [finiteDouble] but for integers.
|
||||||
|
int finiteInt(Object? value, String field, {int fallback = 0}) {
|
||||||
|
if (value == null) return fallback;
|
||||||
|
final n = value as num;
|
||||||
|
if (!n.isFinite) throw ProtocolException('$field must be finite');
|
||||||
|
return n.toInt();
|
||||||
|
}
|
||||||
|
|
||||||
class HelloUser {
|
class HelloUser {
|
||||||
final String name;
|
final String name;
|
||||||
final String avatar;
|
final String avatar;
|
||||||
@@ -39,8 +56,8 @@ class HelloMedia {
|
|||||||
|
|
||||||
factory HelloMedia.fromJson(Map<String, Object?> json) => HelloMedia(
|
factory HelloMedia.fromJson(Map<String, Object?> json) => HelloMedia(
|
||||||
name: (json['name'] as String?) ?? '',
|
name: (json['name'] as String?) ?? '',
|
||||||
size: (json['size'] as num?)?.toInt() ?? 0,
|
size: finiteInt(json['size'], 'size', fallback: 0),
|
||||||
duration: (json['duration'] as num?)?.toDouble() ?? 0.0,
|
duration: finiteDouble(json['duration'], 'duration'),
|
||||||
url: json['url'] as String?,
|
url: json['url'] as String?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -66,7 +83,7 @@ class HelloMessage {
|
|||||||
final media = json['media'];
|
final media = json['media'];
|
||||||
if (user is! Map || media is! Map) throw const ProtocolException('hello missing user/media');
|
if (user is! Map || media is! Map) throw const ProtocolException('hello missing user/media');
|
||||||
return HelloMessage(
|
return HelloMessage(
|
||||||
proto: (json['proto'] as num?)?.toInt() ?? RelayConstants.protoVersion,
|
proto: finiteInt(json['proto'], 'proto', fallback: RelayConstants.protoVersion),
|
||||||
secret: (json['secret'] as String?) ?? '',
|
secret: (json['secret'] as String?) ?? '',
|
||||||
user: HelloUser.fromJson(Map<String, Object?>.from(user)),
|
user: HelloUser.fromJson(Map<String, Object?>.from(user)),
|
||||||
media: HelloMedia.fromJson(Map<String, Object?>.from(media)),
|
media: HelloMedia.fromJson(Map<String, Object?>.from(media)),
|
||||||
@@ -89,9 +106,9 @@ class RoomState {
|
|||||||
};
|
};
|
||||||
|
|
||||||
factory RoomState.fromJson(Map<String, Object?> json) => RoomState(
|
factory RoomState.fromJson(Map<String, Object?> json) => RoomState(
|
||||||
position: (json['position'] as num?)?.toDouble() ?? 0.0,
|
position: finiteDouble(json['position'], 'position'),
|
||||||
paused: (json['paused'] as bool?) ?? true,
|
paused: (json['paused'] as bool?) ?? true,
|
||||||
mediaId: (json['mediaId'] as num?)?.toInt() ?? 0,
|
mediaId: finiteInt(json['mediaId'], 'mediaId', fallback: 0),
|
||||||
serverTime: DateTime.tryParse('${json['serverTime']}') ?? DateTime.now().toUtc(),
|
serverTime: DateTime.tryParse('${json['serverTime']}') ?? DateTime.now().toUtc(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -191,7 +208,7 @@ class SeekIntent {
|
|||||||
|
|
||||||
factory SeekIntent.fromJson(Map<String, Object?> json) {
|
factory SeekIntent.fromJson(Map<String, Object?> json) {
|
||||||
if (json['t'] != 'seek') throw const ProtocolException('not a seek intent');
|
if (json['t'] != 'seek') throw const ProtocolException('not a seek intent');
|
||||||
return SeekIntent(position: (json['position'] as num?)?.toDouble() ?? 0.0);
|
return SeekIntent(position: finiteDouble(json['position'], 'position'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,9 +238,9 @@ class ReportMessage {
|
|||||||
factory ReportMessage.fromJson(Map<String, Object?> json) {
|
factory ReportMessage.fromJson(Map<String, Object?> json) {
|
||||||
if (json['t'] != 'report') throw const ProtocolException('not a report');
|
if (json['t'] != 'report') throw const ProtocolException('not a report');
|
||||||
return ReportMessage(
|
return ReportMessage(
|
||||||
position: (json['position'] as num?)?.toDouble() ?? 0.0,
|
position: finiteDouble(json['position'], 'position'),
|
||||||
buffered: (json['buffered'] as num?)?.toDouble() ?? 0.0,
|
buffered: finiteDouble(json['buffered'], 'buffered'),
|
||||||
eventId: (json['eventId'] as num?)?.toInt() ?? 0,
|
eventId: finiteInt(json['eventId'], 'eventId', fallback: 0),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -274,7 +291,7 @@ class MemberView {
|
|||||||
avatar: (json['avatar'] as String?) ?? '',
|
avatar: (json['avatar'] as String?) ?? '',
|
||||||
ready: (json['ready'] as bool?) ?? false,
|
ready: (json['ready'] as bool?) ?? false,
|
||||||
inSet: (json['inSet'] as bool?) ?? false,
|
inSet: (json['inSet'] as bool?) ?? false,
|
||||||
buffered: (json['buffered'] as num?)?.toDouble() ?? 0.0,
|
buffered: finiteDouble(json['buffered'], 'buffered'),
|
||||||
stale: (json['stale'] as bool?) ?? false,
|
stale: (json['stale'] as bool?) ?? false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -313,11 +330,11 @@ class RoomBroadcast {
|
|||||||
final members = json['members'];
|
final members = json['members'];
|
||||||
final media = json['media'];
|
final media = json['media'];
|
||||||
return RoomBroadcast(
|
return RoomBroadcast(
|
||||||
eventId: (json['eventId'] as num?)?.toInt() ?? 0,
|
eventId: finiteInt(json['eventId'], 'eventId', fallback: 0),
|
||||||
transport: (json['transport'] as String?) ?? 'held',
|
transport: (json['transport'] as String?) ?? 'held',
|
||||||
position: (json['position'] as num?)?.toDouble() ?? 0.0,
|
position: finiteDouble(json['position'], 'position'),
|
||||||
serverTime: DateTime.tryParse('${json['serverTime']}') ?? DateTime.now().toUtc(),
|
serverTime: DateTime.tryParse('${json['serverTime']}') ?? DateTime.now().toUtc(),
|
||||||
mediaId: (json['mediaId'] as num?)?.toInt() ?? 0,
|
mediaId: finiteInt(json['mediaId'], 'mediaId', fallback: 0),
|
||||||
media: media is Map ? Map<String, Object?>.from(media) : null,
|
media: media is Map ? Map<String, Object?>.from(media) : null,
|
||||||
members: members is List
|
members: members is List
|
||||||
? members
|
? members
|
||||||
@@ -382,9 +399,9 @@ class SyncEvent { final String kind;
|
|||||||
if (json['t'] != 'event') throw const ProtocolException('not an event message');
|
if (json['t'] != 'event') throw const ProtocolException('not an event message');
|
||||||
return SyncEvent(
|
return SyncEvent(
|
||||||
kind: (json['kind'] as String?) ?? '',
|
kind: (json['kind'] as String?) ?? '',
|
||||||
position: (json['position'] as num?)?.toDouble() ?? 0.0,
|
position: finiteDouble(json['position'], 'position'),
|
||||||
doSeek: (json['doSeek'] as bool?) ?? false,
|
doSeek: (json['doSeek'] as bool?) ?? false,
|
||||||
ignoringOnTheFly: (json['ignoringOnTheFly'] as num?)?.toInt() ?? 0,
|
ignoringOnTheFly: finiteInt(json['ignoringOnTheFly'], 'ignoringOnTheFly', fallback: 0),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -156,7 +156,7 @@ class RoomEngine {
|
|||||||
void applySeek(String token, double seekPosition) {
|
void applySeek(String token, double seekPosition) {
|
||||||
final member = members[token];
|
final member = members[token];
|
||||||
if (member == null) return;
|
if (member == null) return;
|
||||||
if (seekPosition.isNaN || seekPosition < 0) return;
|
if (!seekPosition.isFinite || seekPosition < 0) return;
|
||||||
member.lastSeen = now;
|
member.lastSeen = now;
|
||||||
position = seekPosition;
|
position = seekPosition;
|
||||||
serverTime = now;
|
serverTime = now;
|
||||||
@@ -164,7 +164,9 @@ class RoomEngine {
|
|||||||
feed.add(FeedMessage(actor: member.name, action: 'seek', at: now));
|
feed.add(FeedMessage(actor: member.name, action: 'seek', at: now));
|
||||||
}
|
}
|
||||||
|
|
||||||
void applySwap(String actorToken, HelloMedia next) {
|
/// Returns false (no state change) for media with non-finite numbers.
|
||||||
|
bool applySwap(String actorToken, HelloMedia next) {
|
||||||
|
if (!next.duration.isFinite || next.size < 0) return false;
|
||||||
final actor = members[actorToken];
|
final actor = members[actorToken];
|
||||||
mediaId++;
|
mediaId++;
|
||||||
media = next;
|
media = next;
|
||||||
@@ -178,13 +180,15 @@ class RoomEngine {
|
|||||||
member.buffered = 0;
|
member.buffered = 0;
|
||||||
}
|
}
|
||||||
feed.add(FeedMessage(actor: actor?.name ?? '?', action: 'swap', at: now));
|
feed.add(FeedMessage(actor: actor?.name ?? '?', action: 'swap', at: now));
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void applyReport(String token, double reportPosition, double buffered, int reportEventId) {
|
void applyReport(String token, double reportPosition, double buffered, int reportEventId) {
|
||||||
final member = members[token];
|
final member = members[token];
|
||||||
if (member == null) return;
|
if (member == null) return;
|
||||||
|
if (!reportPosition.isFinite) return;
|
||||||
member.lastSeen = now;
|
member.lastSeen = now;
|
||||||
member.buffered = buffered.isNaN || buffered < 0 ? 0 : buffered;
|
member.buffered = !buffered.isFinite || buffered < 0 ? 0 : buffered;
|
||||||
if (reportEventId < eventId) return;
|
if (reportEventId < eventId) return;
|
||||||
evaluate();
|
evaluate();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
import 'package:test/test.dart';
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
@@ -6,7 +8,8 @@ void main() {
|
|||||||
test('defaults are sane', () {
|
test('defaults are sane', () {
|
||||||
final c = AppConfig.defaults();
|
final c = AppConfig.defaults();
|
||||||
expect(c.port, 8080);
|
expect(c.port, 8080);
|
||||||
expect(c.secret, isNotEmpty);
|
expect(c.secret, isEmpty, reason: 'empty password = open server');
|
||||||
|
expect(c.advertiseHost, isEmpty);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('fromMap tolerates missing keys', () {
|
test('fromMap tolerates missing keys', () {
|
||||||
@@ -31,6 +34,26 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
group('protocol', () {
|
group('protocol', () {
|
||||||
|
test('non-finite numbers are rejected', () {
|
||||||
|
final inf = jsonDecode('{"t":"seek","position":1e400}') as Map<String, Object?>;
|
||||||
|
expect(inf['position'], double.infinity);
|
||||||
|
expect(() => SeekIntent.fromJson(inf), throwsA(isA<ProtocolException>()));
|
||||||
|
expect(
|
||||||
|
() => ReportMessage.fromJson(
|
||||||
|
{'t': 'report', 'position': 1.0, 'buffered': double.infinity}),
|
||||||
|
throwsA(isA<ProtocolException>()));
|
||||||
|
expect(
|
||||||
|
() => ReportMessage.fromJson(
|
||||||
|
{'t': 'report', 'position': double.nan, 'buffered': 1.0}),
|
||||||
|
throwsA(isA<ProtocolException>()));
|
||||||
|
expect(
|
||||||
|
() => SwapIntent.fromJson({
|
||||||
|
't': 'swap',
|
||||||
|
'media': {'name': 'a', 'size': 1, 'duration': double.infinity}
|
||||||
|
}),
|
||||||
|
throwsA(isA<ProtocolException>()));
|
||||||
|
});
|
||||||
|
|
||||||
test('hello round-trips', () {
|
test('hello round-trips', () {
|
||||||
const msg = HelloMessage(
|
const msg = HelloMessage(
|
||||||
proto: 1,
|
proto: 1,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
import 'package:test/test.dart';
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
@@ -16,6 +18,24 @@ void main() {
|
|||||||
void buffer(String token, double secs) =>
|
void buffer(String token, double secs) =>
|
||||||
engine.applyReport(token, engine.positionAt(), secs, engine.eventId);
|
engine.applyReport(token, engine.positionAt(), secs, engine.eventId);
|
||||||
|
|
||||||
|
group('non-finite guards', () {
|
||||||
|
test('seek/report/swap reject Infinity and NaN, broadcast stays encodable', () {
|
||||||
|
join('a', 'alice');
|
||||||
|
engine.applySeek('a', double.infinity);
|
||||||
|
engine.applySeek('a', double.nan);
|
||||||
|
expect(engine.position, 0);
|
||||||
|
engine.applyReport('a', double.infinity, 1, engine.eventId);
|
||||||
|
engine.applyReport('a', 1, double.infinity, engine.eventId);
|
||||||
|
expect(engine.members['a']!.buffered, 0);
|
||||||
|
expect(
|
||||||
|
engine.applySwap('a',
|
||||||
|
const HelloMedia(name: 'x', size: 1, duration: double.infinity)),
|
||||||
|
isFalse);
|
||||||
|
expect(engine.mediaId, 1);
|
||||||
|
jsonEncode(engine.broadcast().toJson());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
group('membership + gate', () {
|
group('membership + gate', () {
|
||||||
test('join lands out-of-set, room stays held', () {
|
test('join lands out-of-set, room stays held', () {
|
||||||
join('a', 'alice');
|
join('a', 'alice');
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ import 'package:sync_relay_server/server.dart';
|
|||||||
Future<void> main(List<String> args) async {
|
Future<void> main(List<String> args) async {
|
||||||
final parser = ArgParser()
|
final parser = ArgParser()
|
||||||
..addOption('port', abbr: 'p', defaultsTo: '8080')
|
..addOption('port', abbr: 'p', defaultsTo: '8080')
|
||||||
..addOption('secret', defaultsTo: 'change-me')
|
..addOption('secret', defaultsTo: '')
|
||||||
..addOption('name', defaultsTo: 'room')
|
..addOption('name', defaultsTo: 'room')
|
||||||
..addOption('media', defaultsTo: '')
|
..addOption('media', defaultsTo: '')
|
||||||
..addOption('advertise-host', defaultsTo: '127.0.0.1')
|
..addOption('advertise-host', defaultsTo: '')
|
||||||
..addOption('config')
|
..addOption('config')
|
||||||
..addFlag('help', abbr: 'h', negatable: false);
|
..addFlag('help', abbr: 'h', negatable: false);
|
||||||
final parsed = parser.parse(args);
|
final parsed = parser.parse(args);
|
||||||
|
|||||||
@@ -191,7 +191,6 @@ void mountSessionMediaRoute(
|
|||||||
}
|
}
|
||||||
final end = res.end ?? size - 1;
|
final end = res.end ?? size - 1;
|
||||||
final byteCount = end - res.start + 1;
|
final byteCount = end - res.start + 1;
|
||||||
session.addBytes(token, byteCount);
|
|
||||||
final shortToken = token.length > 4 ? '…${token.substring(token.length - 4)}' : token;
|
final shortToken = token.length > 4 ? '…${token.substring(token.length - 4)}' : token;
|
||||||
onLog?.call(token, MediaLog(
|
onLog?.call(token, MediaLog(
|
||||||
method: req.method.name,
|
method: req.method.name,
|
||||||
@@ -201,7 +200,12 @@ void mountSessionMediaRoute(
|
|||||||
bytesWritten: byteCount,
|
bytesWritten: byteCount,
|
||||||
durationMs: stopwatch.elapsedMilliseconds,
|
durationMs: stopwatch.elapsedMilliseconds,
|
||||||
));
|
));
|
||||||
final stream = file.openRead(res.start, end + 1).map((c) => Uint8List.fromList(c));
|
// Count bytes as they are actually sent: mpv often asks for `bytes=N-`
|
||||||
|
// and drops the connection early, so the requested range overcounts.
|
||||||
|
final stream = file.openRead(res.start, end + 1).map((c) {
|
||||||
|
session.addBytes(token, c.length);
|
||||||
|
return Uint8List.fromList(c);
|
||||||
|
});
|
||||||
return Response(
|
return Response(
|
||||||
res.status,
|
res.status,
|
||||||
body: Body.fromDataStream(stream, contentLength: byteCount),
|
body: Body.fromDataStream(stream, contentLength: byteCount),
|
||||||
|
|||||||
@@ -96,10 +96,29 @@ class RoomHub {
|
|||||||
Future<void> kickById(String targetId, String reason) async {
|
Future<void> kickById(String targetId, String reason) async {
|
||||||
final member = engine.byId(targetId);
|
final member = engine.byId(targetId);
|
||||||
if (member == null) throw const AuthException('unknown kick target');
|
if (member == null) throw const AuthException('unknown kick target');
|
||||||
|
if (session.tokens[member.token]?.isHost ?? false) {
|
||||||
|
throw const AuthException("can't kick the host");
|
||||||
|
}
|
||||||
await session.kick(member.token, reason: reason);
|
await session.kick(member.token, reason: reason);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Media names are shown to everyone and must never carry a URL (api_key etc.).
|
||||||
|
/// A URL-looking name is reduced to its last path segment, no query/fragment.
|
||||||
|
String sanitizeMediaName(String name) {
|
||||||
|
final trimmed = name.trim();
|
||||||
|
if (!RegExp(r'^[a-zA-Z][a-zA-Z0-9+.-]*://').hasMatch(trimmed)) return name;
|
||||||
|
final uri = Uri.tryParse(trimmed);
|
||||||
|
final segments = uri?.pathSegments.where((s) => s.isNotEmpty).toList() ?? const [];
|
||||||
|
if (segments.isNotEmpty) return segments.last;
|
||||||
|
return 'media';
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isLoopback(String address) =>
|
||||||
|
address == '::1' ||
|
||||||
|
address.startsWith('127.') ||
|
||||||
|
address.startsWith('::ffff:127.');
|
||||||
|
|
||||||
void wsErr(RelicWebSocket ws, String message) {
|
void wsErr(RelicWebSocket ws, String message) {
|
||||||
ws.trySendText(jsonEncode({'t': 'err', 'message': message}));
|
ws.trySendText(jsonEncode({'t': 'err', 'message': message}));
|
||||||
}
|
}
|
||||||
@@ -107,9 +126,15 @@ void wsErr(RelicWebSocket ws, String message) {
|
|||||||
void mountSyncRoute(
|
void mountSyncRoute(
|
||||||
RelicApp app,
|
RelicApp app,
|
||||||
RoomHub hub,
|
RoomHub hub,
|
||||||
String Function(String sid, String token, SessionMedia? media) urlBuilder,
|
String Function(String sid, String token, SessionMedia? media, String host)
|
||||||
|
urlBuilder,
|
||||||
) {
|
) {
|
||||||
app.get('/sync', (final req) {
|
app.get('/sync', (final req) {
|
||||||
|
// Host the client dialled (Host header), so file URLs work for remote joiners.
|
||||||
|
final dialledHost = req.url.hasPort
|
||||||
|
? '${req.url.host.contains(':') ? '[${req.url.host}]' : req.url.host}:${req.url.port}'
|
||||||
|
: (req.url.host.contains(':') ? '[${req.url.host}]' : req.url.host);
|
||||||
|
final fromLoopback = _isLoopback(req.connectionInfo.remote.address.toString());
|
||||||
return WebSocketUpgrade((final ws) async {
|
return WebSocketUpgrade((final ws) async {
|
||||||
ws.pingInterval = Duration(seconds: RelayConstants.pingIntervalSecs);
|
ws.pingInterval = Duration(seconds: RelayConstants.pingIntervalSecs);
|
||||||
final firstText = Completer<String>();
|
final firstText = Completer<String>();
|
||||||
@@ -155,12 +180,26 @@ void mountSyncRoute(
|
|||||||
hub.changed();
|
hub.changed();
|
||||||
case 'swap':
|
case 'swap':
|
||||||
if (token == null) return;
|
if (token == null) return;
|
||||||
final intent = SwapIntent.fromJson(decoded);
|
final parsed = SwapIntent.fromJson(decoded).media;
|
||||||
hub.engine.applySwap(token, intent.media);
|
final intent = SwapIntent(
|
||||||
|
media: HelloMedia(
|
||||||
|
name: sanitizeMediaName(parsed.name),
|
||||||
|
size: parsed.size,
|
||||||
|
duration: parsed.duration,
|
||||||
|
url: parsed.url));
|
||||||
final current = session.media;
|
final current = session.media;
|
||||||
if (current?.file != null &&
|
final matchesFile = current?.file != null &&
|
||||||
current!.name == intent.media.name &&
|
current!.name == intent.media.name &&
|
||||||
current.size == intent.media.size) {
|
current.size == intent.media.size;
|
||||||
|
if (!matchesFile && (intent.media.url ?? '').isEmpty) {
|
||||||
|
wsErr(ws, 'swap needs a url or the hosted file');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!hub.engine.applySwap(token, intent.media)) {
|
||||||
|
wsErr(ws, 'bad swap media');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (matchesFile) {
|
||||||
session.media = SessionMedia(
|
session.media = SessionMedia(
|
||||||
name: current.name,
|
name: current.name,
|
||||||
size: current.size,
|
size: current.size,
|
||||||
@@ -193,12 +232,18 @@ void mountSyncRoute(
|
|||||||
wsErr(ws, 'unknown kick target');
|
wsErr(ws, 'unknown kick target');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (session.tokens[member.token]?.isHost ?? false) {
|
||||||
|
wsErr(ws, "can't kick the host");
|
||||||
|
return;
|
||||||
|
}
|
||||||
final by = viewer?.name ?? '?';
|
final by = viewer?.name ?? '?';
|
||||||
unawaited(hub.session.kick(member.token,
|
unawaited(hub.session.kick(member.token,
|
||||||
reason: kickReq.reason.isEmpty
|
reason: kickReq.reason.isEmpty
|
||||||
? 'kicked by $by'
|
? 'kicked by $by'
|
||||||
: kickReq.reason));
|
: kickReq.reason));
|
||||||
}
|
}
|
||||||
|
} on ProtocolException catch (e) {
|
||||||
|
wsErr(ws, 'bad message: ${e.message}');
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -250,7 +295,7 @@ void mountSyncRoute(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final session = hub.session;
|
final session = hub.session;
|
||||||
if (hello.secret != session.secret) {
|
if (session.secret.isNotEmpty && hello.secret != session.secret) {
|
||||||
_hubLog.warning('reject ${hello.user.name}: wrong secret');
|
_hubLog.warning('reject ${hello.user.name}: wrong secret');
|
||||||
wsErr(ws, 'wrong secret');
|
wsErr(ws, 'wrong secret');
|
||||||
await close();
|
await close();
|
||||||
@@ -264,6 +309,7 @@ void mountSyncRoute(
|
|||||||
await close();
|
await close();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
session.tokens[token]!.isHost = fromLoopback;
|
||||||
final memberId = hub.engine.applyJoin(token, hello.user.name, hello.user.avatar);
|
final memberId = hub.engine.applyJoin(token, hello.user.name, hello.user.avatar);
|
||||||
final activeToken = token;
|
final activeToken = token;
|
||||||
hub.sockets[activeToken] = ws;
|
hub.sockets[activeToken] = ws;
|
||||||
@@ -277,7 +323,7 @@ void mountSyncRoute(
|
|||||||
final welcome = WelcomeMessage(
|
final welcome = WelcomeMessage(
|
||||||
token: activeToken,
|
token: activeToken,
|
||||||
memberId: memberId,
|
memberId: memberId,
|
||||||
mediaUrl: media?.file != null ? urlBuilder('default', activeToken, media) : media?.url,
|
mediaUrl: media?.file != null ? urlBuilder('default', activeToken, media, dialledHost) : media?.url,
|
||||||
state: hub.engine.broadcast().toJson(),
|
state: hub.engine.broadcast().toJson(),
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -55,16 +55,17 @@ class RoomServer {
|
|||||||
'viewers': viewers,
|
'viewers': viewers,
|
||||||
'bytesServed': bytes,
|
'bytesServed': bytes,
|
||||||
'uptimeSecs': uptime,
|
'uptimeSecs': uptime,
|
||||||
'media': s?.media?.name,
|
|
||||||
'transport': hub.engine.transport.name,
|
|
||||||
'eventId': hub.engine.eventId,
|
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
mountSessionMediaRoute(_app, sessions);
|
mountSessionMediaRoute(_app, sessions);
|
||||||
mountSyncRoute(_app, hub, (sid, token, media) {
|
mountSyncRoute(_app, hub, (sid, token, media, dialledHost) {
|
||||||
final name = Uri.encodeComponent(media?.name ?? 'file');
|
final name = Uri.encodeComponent(media?.name ?? 'file');
|
||||||
return 'http://${config.advertiseHost}:$boundPort/m/$sid/$token/$name';
|
// advertiseHost is only an explicit override; otherwise echo what the client dialled.
|
||||||
|
final authority = config.advertiseHost.isNotEmpty
|
||||||
|
? '${config.advertiseHost}:$boundPort'
|
||||||
|
: dialledHost;
|
||||||
|
return 'http://$authority/m/$sid/$token/$name';
|
||||||
});
|
});
|
||||||
_app.fallback = (final req) => Response.notFound(
|
_app.fallback = (final req) => Response.notFound(
|
||||||
body: Body.fromString('not found'),
|
body: Body.fromString('not found'),
|
||||||
@@ -85,6 +86,9 @@ class RoomServer {
|
|||||||
_log.warning('media file not found: ${config.mediaPath}');
|
_log.warning('media file not found: ${config.mediaPath}');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (config.secret.isEmpty) {
|
||||||
|
_log.warning('no password set: open server, anyone who can reach it can join');
|
||||||
|
}
|
||||||
_mountRoutes();
|
_mountRoutes();
|
||||||
_server = await _app.serve(
|
_server = await _app.serve(
|
||||||
address: InternetAddress.anyIPv4,
|
address: InternetAddress.anyIPv4,
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ class ViewerToken {
|
|||||||
final String name;
|
final String name;
|
||||||
final String avatar;
|
final String avatar;
|
||||||
bool kicked = false;
|
bool kicked = false;
|
||||||
|
/// Connected from a loopback address: the host's own in-process client.
|
||||||
|
bool isHost = false;
|
||||||
int bytesServed = 0;
|
int bytesServed = 0;
|
||||||
DateTime lastSeen;
|
DateTime lastSeen;
|
||||||
Future<void> Function(String reason)? onKick;
|
Future<void> Function(String reason)? onKick;
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
import 'package:sync_relay_server/server.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
class _Peer {
|
||||||
|
final WebSocket ws;
|
||||||
|
final List<Map<String, Object?>> msgs = [];
|
||||||
|
bool closed = false;
|
||||||
|
_Peer(this.ws) {
|
||||||
|
ws.listen((m) => msgs.add(Map<String, Object?>.from(jsonDecode('$m') as Map)),
|
||||||
|
onDone: () => closed = true);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<_Peer> join(String host, int port, String name,
|
||||||
|
{String secret = ''}) async {
|
||||||
|
final ws = await WebSocket.connect('ws://$host:$port/sync');
|
||||||
|
final p = _Peer(ws);
|
||||||
|
ws.add(jsonEncode(HelloMessage(
|
||||||
|
proto: RelayConstants.protoVersion,
|
||||||
|
secret: secret,
|
||||||
|
user: HelloUser(name: name),
|
||||||
|
media: const HelloMedia(name: '', size: 0, duration: 0))
|
||||||
|
.toJson()));
|
||||||
|
await p.next('welcome');
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
void send(String raw) => ws.add(raw);
|
||||||
|
|
||||||
|
Future<Map<String, Object?>> next(String type,
|
||||||
|
{Duration timeout = const Duration(seconds: 5)}) async {
|
||||||
|
final end = DateTime.now().add(timeout);
|
||||||
|
while (DateTime.now().isBefore(end)) {
|
||||||
|
for (final m in msgs) {
|
||||||
|
if (m['t'] == type) {
|
||||||
|
msgs.remove(m);
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Future.delayed(const Duration(milliseconds: 30));
|
||||||
|
}
|
||||||
|
fail('no $type in $msgs');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> _lan() async {
|
||||||
|
for (final i in await NetworkInterface.list(type: InternetAddressType.IPv4)) {
|
||||||
|
for (final a in i.addresses) {
|
||||||
|
if (!a.isLoopback) return a.address;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late RoomServer server;
|
||||||
|
late File file;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
file = File('${Directory.systemTemp.path}/hard_${DateTime.now().microsecondsSinceEpoch}.bin')
|
||||||
|
..writeAsBytesSync(List.filled(64, 1));
|
||||||
|
server = RoomServer(AppConfig(port: 0, secret: '', name: 't', mediaPath: file.path));
|
||||||
|
await server.start();
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
await server.stop();
|
||||||
|
await file.delete();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Infinity seek/report is rejected and sync keeps broadcasting', () async {
|
||||||
|
final a = await _Peer.join('127.0.0.1', server.boundPort, 'a');
|
||||||
|
a.send('{"t":"seek","position":1e400}');
|
||||||
|
a.send('{"t":"report","position":1e400,"buffered":1e400,"eventId":0}');
|
||||||
|
final err = await a.next('err');
|
||||||
|
expect('${err['message']}', contains('finite'));
|
||||||
|
expect(server.hub.engine.position, 0);
|
||||||
|
// still alive: a valid seek round-trips through a broadcast
|
||||||
|
a.send('{"t":"seek","position":5}');
|
||||||
|
for (var i = 0; i < 50 && server.hub.engine.position != 5; i++) {
|
||||||
|
await Future.delayed(const Duration(milliseconds: 50));
|
||||||
|
}
|
||||||
|
expect(server.hub.engine.position, 5);
|
||||||
|
await a.ws.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty swap rejected with err, no state change', () async {
|
||||||
|
final a = await _Peer.join('127.0.0.1', server.boundPort, 'a');
|
||||||
|
final before = server.hub.engine.mediaId;
|
||||||
|
a.send(jsonEncode({
|
||||||
|
't': 'swap',
|
||||||
|
'media': {'name': 'x', 'size': 0, 'duration': 0, 'url': ''}
|
||||||
|
}));
|
||||||
|
await a.next('err');
|
||||||
|
expect(server.hub.engine.mediaId, before);
|
||||||
|
await a.ws.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('swap name that is a URL is reduced to last segment', () async {
|
||||||
|
expect(sanitizeMediaName('http://j:8096/Videos/1/stream.mkv?api_key=SECRET#x'),
|
||||||
|
'stream.mkv');
|
||||||
|
expect(sanitizeMediaName('movie.mkv'), 'movie.mkv');
|
||||||
|
final a = await _Peer.join('127.0.0.1', server.boundPort, 'a');
|
||||||
|
a.send(jsonEncode({
|
||||||
|
't': 'swap',
|
||||||
|
'media': {
|
||||||
|
'name': 'http://j/Videos/1/stream.mkv?api_key=SECRET',
|
||||||
|
'size': 0,
|
||||||
|
'duration': 1,
|
||||||
|
'url': 'http://j/Videos/1/stream.mkv?api_key=SECRET'
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
for (var i = 0; i < 50 && server.hub.engine.mediaId == 1; i++) {
|
||||||
|
await Future.delayed(const Duration(milliseconds: 50));
|
||||||
|
}
|
||||||
|
expect(server.hub.engine.media!.name, 'stream.mkv');
|
||||||
|
expect(server.session.media!.name, 'stream.mkv');
|
||||||
|
await a.ws.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('host (loopback) cannot be kicked; others can', () async {
|
||||||
|
final lan = await _lan();
|
||||||
|
if (lan == null) {
|
||||||
|
markTestSkipped('no LAN address');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final host = await _Peer.join('127.0.0.1', server.boundPort, 'host');
|
||||||
|
final friend = await _Peer.join(lan, server.boundPort, 'friend');
|
||||||
|
final hostId = server.hub.engine.members.values.firstWhere((m) => m.name == 'host').id;
|
||||||
|
friend.send(jsonEncode(KickRequest(target: hostId).toJson()));
|
||||||
|
final err = await friend.next('err');
|
||||||
|
expect(err['message'], "can't kick the host");
|
||||||
|
expect(host.closed, isFalse);
|
||||||
|
await expectLater(server.hub.kickById(hostId, 'x'), throwsA(isA<AuthException>()));
|
||||||
|
await host.ws.close();
|
||||||
|
await friend.ws.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty secret accepts any hello secret', () async {
|
||||||
|
final a = await _Peer.join('127.0.0.1', server.boundPort, 'a', secret: 'whatever');
|
||||||
|
expect(server.hub.engine.members, hasLength(1));
|
||||||
|
await a.ws.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('/health exposes no media name', () async {
|
||||||
|
final c = HttpClient();
|
||||||
|
final r = await (await c.get('127.0.0.1', server.boundPort, '/health')).close();
|
||||||
|
final body = jsonDecode(await r.transform(utf8.decoder).join()) as Map;
|
||||||
|
c.close();
|
||||||
|
expect(body['ok'], isTrue);
|
||||||
|
expect(body.containsKey('media'), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('file media URL uses the host the client dialled', () async {
|
||||||
|
final lan = await _lan();
|
||||||
|
final ws = await WebSocket.connect('ws://127.0.0.1:${server.boundPort}/sync');
|
||||||
|
final p = _Peer(ws);
|
||||||
|
ws.add(jsonEncode(HelloMessage(
|
||||||
|
proto: RelayConstants.protoVersion,
|
||||||
|
secret: '',
|
||||||
|
user: const HelloUser(name: 'a'),
|
||||||
|
media: const HelloMedia(name: '', size: 0, duration: 0))
|
||||||
|
.toJson()));
|
||||||
|
final w = await p.next('welcome');
|
||||||
|
expect('${w['mediaUrl']}', startsWith('http://127.0.0.1:${server.boundPort}/m/'));
|
||||||
|
await ws.close();
|
||||||
|
if (lan != null) {
|
||||||
|
final q = await _Peer.join(lan, server.boundPort, 'b');
|
||||||
|
// welcome consumed in join; re-check via session state instead
|
||||||
|
await q.ws.close();
|
||||||
|
final ws2 = await WebSocket.connect('ws://$lan:${server.boundPort}/sync');
|
||||||
|
final p2 = _Peer(ws2);
|
||||||
|
ws2.add(jsonEncode(HelloMessage(
|
||||||
|
proto: RelayConstants.protoVersion,
|
||||||
|
secret: '',
|
||||||
|
user: const HelloUser(name: 'c'),
|
||||||
|
media: const HelloMedia(name: '', size: 0, duration: 0))
|
||||||
|
.toJson()));
|
||||||
|
final w2 = await p2.next('welcome');
|
||||||
|
expect('${w2['mediaUrl']}', startsWith('http://$lan:${server.boundPort}/m/'));
|
||||||
|
await ws2.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -8,6 +8,15 @@ import 'package:sync_relay_server/server.dart';
|
|||||||
import 'package:test/test.dart';
|
import 'package:test/test.dart';
|
||||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||||
|
|
||||||
|
Future<String?> _lanAddress() async {
|
||||||
|
for (final i in await NetworkInterface.list(type: InternetAddressType.IPv4)) {
|
||||||
|
for (final a in i.addresses) {
|
||||||
|
if (!a.isLoopback) return a.address;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('room auth live', () {
|
group('room auth live', () {
|
||||||
late File file;
|
late File file;
|
||||||
@@ -73,7 +82,7 @@ void main() {
|
|||||||
expect(decoded['proto'], RelayConstants.protoVersion);
|
expect(decoded['proto'], RelayConstants.protoVersion);
|
||||||
expect(decoded['viewers'], 0);
|
expect(decoded['viewers'], 0);
|
||||||
expect(decoded['bytesServed'], 0);
|
expect(decoded['bytesServed'], 0);
|
||||||
expect(decoded['media'], isNotNull);
|
expect(decoded.containsKey('media'), isFalse, reason: 'no media name leak');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('wrong secret gets err, right secret gets welcome+token', () async {
|
test('wrong secret gets err, right secret gets welcome+token', () async {
|
||||||
@@ -238,10 +247,16 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('kick via socket: peer kicked, unknown target errors', () async {
|
test('kick via socket: peer kicked, unknown target errors', () async {
|
||||||
WebSocketChannel open() => WebSocketChannel.connect(
|
final lan = await _lanAddress();
|
||||||
Uri.parse('ws://127.0.0.1:${server.boundPort}/sync'));
|
if (lan == null) {
|
||||||
Future<(WebSocketChannel, String, String, List<String>)> join(String who) async {
|
markTestSkipped('no non-loopback IPv4 to act as a non-host peer');
|
||||||
final ch = open();
|
return;
|
||||||
|
}
|
||||||
|
// Loopback peers are the host and unkickable, so bob dials the LAN address.
|
||||||
|
Future<(WebSocketChannel, String, String, List<String>)> join(String who,
|
||||||
|
[String host = '127.0.0.1']) async {
|
||||||
|
final ch = WebSocketChannel.connect(
|
||||||
|
Uri.parse('ws://$host:${server.boundPort}/sync'));
|
||||||
final messages = <String>[];
|
final messages = <String>[];
|
||||||
ch.stream.listen((m) => messages.add('$m'));
|
ch.stream.listen((m) => messages.add('$m'));
|
||||||
final msg = HelloMessage(
|
final msg = HelloMessage(
|
||||||
@@ -289,7 +304,7 @@ void main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final (a, _, _, messagesA) = await join('alice');
|
final (a, _, _, messagesA) = await join('alice');
|
||||||
final (b, tokenB, idB, messagesB) = await join('bob');
|
final (b, tokenB, idB, messagesB) = await join('bob', lan);
|
||||||
try {
|
try {
|
||||||
a.sink.add(jsonEncode(
|
a.sink.add(jsonEncode(
|
||||||
KickRequest(target: idB, reason: 'afk?').toJson()));
|
KickRequest(target: idB, reason: 'afk?').toJson()));
|
||||||
|
|||||||
Reference in new issue
Block a user