fix(train): Jellyfin over https alone, but on the phone itself (ADR 0030, D54)

An http address to another machine is refused as typed, saying why: over it the password, the token and what the person listens to went through the network in clear, and the music job, held by Android to https, retried for ever without a track. A Jellyfin on the phone (localhost, 127.0.0.1) stays reachable over http, the network security config allowing those two alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-30 12:55:59 +01:00
commit ced82b1e75
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
12 changed files with 74 additions and 0 deletions

View file

@ -61,6 +61,7 @@
android:allowBackup="false" android:allowBackup="false"
android:fullBackupContent="false" android:fullBackupContent="false"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:networkSecurityConfig="@xml/network_security_config"
tools:targetApi="s"> tools:targetApi="s">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Nothing in clear (ADR 0030, D47): https alone, as Android has it by
default, but for a Jellyfin on the phone itself, which the music
downloads reach over http. Only the system's certificates are trusted. -->
<network-security-config>
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system"/>
</trust-anchors>
</base-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">localhost</domain>
<domain includeSubdomains="false">127.0.0.1</domain>
</domain-config>
</network-security-config>

View file

@ -481,6 +481,7 @@
"musicServer": "Jellyfin-Server", "musicServer": "Jellyfin-Server",
"musicConnect": "Verbinden", "musicConnect": "Verbinden",
"musicBadAddress": "Gib die Adresse des Servers ein, etwa jellyfin.example.org.", "musicBadAddress": "Gib die Adresse des Servers ein, etwa jellyfin.example.org.",
"musicInClear": "Train verbindet sich mit Jellyfin nur über https: Über http ginge das Passwort unverschlüsselt durchs Netz. Gib die https-Adresse des Servers ein.",
"musicUnreachable": "Der Server ist nicht erreichbar. Prüfe die Adresse und die Verbindung.", "musicUnreachable": "Der Server ist nicht erreichbar. Prüfe die Adresse und die Verbindung.",
"musicNotJellyfin": "Unter dieser Adresse ist kein Jellyfin-Server.", "musicNotJellyfin": "Unter dieser Adresse ist kein Jellyfin-Server.",
"musicRefused": "Jellyfin hat abgelehnt. Prüfe Benutzername und Passwort.", "musicRefused": "Jellyfin hat abgelehnt. Prüfe Benutzername und Passwort.",

View file

@ -607,6 +607,7 @@
"@musicServer": {"description": "The label of the server's address."}, "@musicServer": {"description": "The label of the server's address."},
"musicConnect": "Connect", "musicConnect": "Connect",
"musicBadAddress": "Enter the server's address, such as jellyfin.example.org.", "musicBadAddress": "Enter the server's address, such as jellyfin.example.org.",
"musicInClear": "Train connects to Jellyfin over https only: http would send the password unencrypted. Use the server's https address.",
"musicUnreachable": "The server can't be reached. Check the address and the connection.", "musicUnreachable": "The server can't be reached. Check the address and the connection.",
"musicNotJellyfin": "There is no Jellyfin server at this address.", "musicNotJellyfin": "There is no Jellyfin server at this address.",
"musicRefused": "Jellyfin refused. Check the user name and password.", "musicRefused": "Jellyfin refused. Check the user name and password.",

View file

@ -2908,6 +2908,12 @@ abstract class AppLocalizations {
/// **'Enter the server\'s address, such as jellyfin.example.org.'** /// **'Enter the server\'s address, such as jellyfin.example.org.'**
String get musicBadAddress; String get musicBadAddress;
/// No description provided for @musicInClear.
///
/// In en, this message translates to:
/// **'Train connects to Jellyfin over https only: http would send the password unencrypted. Use the server\'s https address.'**
String get musicInClear;
/// No description provided for @musicUnreachable. /// No description provided for @musicUnreachable.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -1723,6 +1723,10 @@ class AppLocalizationsDe extends AppLocalizations {
String get musicBadAddress => String get musicBadAddress =>
'Gib die Adresse des Servers ein, etwa jellyfin.example.org.'; 'Gib die Adresse des Servers ein, etwa jellyfin.example.org.';
@override
String get musicInClear =>
'Train verbindet sich mit Jellyfin nur über https: Über http ginge das Passwort unverschlüsselt durchs Netz. Gib die https-Adresse des Servers ein.';
@override @override
String get musicUnreachable => String get musicUnreachable =>
'Der Server ist nicht erreichbar. Prüfe die Adresse und die Verbindung.'; 'Der Server ist nicht erreichbar. Prüfe die Adresse und die Verbindung.';

View file

@ -1713,6 +1713,10 @@ class AppLocalizationsEn extends AppLocalizations {
String get musicBadAddress => String get musicBadAddress =>
'Enter the server\'s address, such as jellyfin.example.org.'; 'Enter the server\'s address, such as jellyfin.example.org.';
@override
String get musicInClear =>
'Train connects to Jellyfin over https only: http would send the password unencrypted. Use the server\'s https address.';
@override @override
String get musicUnreachable => String get musicUnreachable =>
'The server can\'t be reached. Check the address and the connection.'; 'The server can\'t be reached. Check the address and the connection.';

View file

@ -35,6 +35,9 @@ enum MusicStep() {
/// Train's own. /// Train's own.
enum MusicFailure() { enum MusicFailure() {
badAddress, badAddress,
/// http to another machine than the phone (D47).
inClear,
unreachable, unreachable,
notJellyfin, notJellyfin,
refused, refused,
@ -126,6 +129,10 @@ class MusicCubit(
emit(state.copyWith(failure: () => MusicFailure.badAddress)); emit(state.copyWith(failure: () => MusicFailure.badAddress));
return; return;
} }
if (jellyfinInClear(address)) {
emit(state.copyWith(failure: () => MusicFailure.inClear));
return;
}
emit(state.copyWith(step: MusicStep.finding, failure: () => null)); emit(state.copyWith(step: MusicStep.finding, failure: () => null));
try { try {
final server = await _library.find(address); final server = await _library.find(address);

View file

@ -86,6 +86,14 @@ Uri? jellyfinAddress(String typed) {
); );
} }
/// Whether [address] would carry the password and the token in clear: http
/// to anywhere but this phone itself (D47). Train asks Jellyfin over https
/// alone, but for a server on the phone (`localhost`, `127.0.0.1`), which
/// the app's network security config lets the downloads reach too.
bool jellyfinInClear(Uri address) =>
address.scheme == 'http' &&
!const {'localhost', '127.0.0.1'}.contains(address.host.toLowerCase());
/// Jellyfin's API as Train asks it (D47), over plain HTTP: as the client /// Jellyfin's API as Train asks it (D47), over plain HTTP: as the client
/// "Halkyon Train" on the device `deviceId`, to which Jellyfin ties the /// "Halkyon Train" on the device `deviceId`, to which Jellyfin ties the
/// tokens it gives. /// tokens it gives.

View file

@ -63,6 +63,7 @@ class const MusicScreen({
String _failureText(AppLocalizations l10n, MusicFailure failure) => String _failureText(AppLocalizations l10n, MusicFailure failure) =>
switch (failure) { switch (failure) {
MusicFailure.badAddress => l10n.musicBadAddress, MusicFailure.badAddress => l10n.musicBadAddress,
MusicFailure.inClear => l10n.musicInClear,
MusicFailure.unreachable => l10n.musicUnreachable, MusicFailure.unreachable => l10n.musicUnreachable,
MusicFailure.notJellyfin => l10n.musicNotJellyfin, MusicFailure.notJellyfin => l10n.musicNotJellyfin,
MusicFailure.refused => l10n.musicRefused, MusicFailure.refused => l10n.musicRefused,

View file

@ -51,6 +51,18 @@ void main() {
expect(jellyfinAddress('ftp://music.example.org'), isNull); expect(jellyfinAddress('ftp://music.example.org'), isNull);
expect(jellyfinAddress('https://'), isNull); expect(jellyfinAddress('https://'), isNull);
}); });
test('in clear is http to anywhere but this phone itself (D47)', () {
bool inClear(String typed) => jellyfinInClear(jellyfinAddress(typed)!);
expect(inClear('http://192.168.1.4:8096'), isTrue);
expect(inClear('http://music.example.org'), isTrue);
expect(inClear('http://127.0.0.2:8096'), isTrue);
expect(inClear('https://192.168.1.4:8920'), isFalse);
expect(inClear('music.example.org'), isFalse);
expect(inClear('http://localhost:8096'), isFalse);
expect(inClear('http://LocalHost:8096'), isFalse);
expect(inClear('http://127.0.0.1:8096'), isFalse);
});
}); });
test('a server says its name and whether Quick Connect is on', () async { test('a server says its name and whether Quick Connect is on', () async {

View file

@ -192,6 +192,20 @@ void main() {
expect(jellyfin.requests, isEmpty); expect(jellyfin.requests, isEmpty);
}); });
testWidgets('an address in clear is refused, and nothing is asked', (
tester,
) async {
final jellyfin = FakeJellyfin();
await show(tester, jellyfin);
await tester.enterText(find.byType(TextField), 'http://192.168.1.4:8096');
await tester.tap(find.text('Connect'));
await tester.pump();
expect(find.textContaining('https'), findsOneWidget);
expect(jellyfin.requests, isEmpty);
});
testWidgets('a code not approved in time expires, and a new one is asked ' testWidgets('a code not approved in time expires, and a new one is asked '
'for', (tester) async { 'for', (tester) async {
final jellyfin = FakeJellyfin(); final jellyfin = FakeJellyfin();