Conversation
The stale-token answer was matched by equality against "authorization
failed.", and the stock Ministra server appends a debug counter to it. So
"Authorization failed. 75" -- what it actually sends -- was not recognised,
and neither were the other two refusals in the same family: "Access denied."
for a blocked account, "Unauthorized request." when the mac cookie never
arrived. All three arrived typed as PortalEndpointError, which has two wrong
consequences. The resolver re-authenticates on PortalAuthError alone, so the
one answer the cached-token path exists to survive failed the tune instead;
and the handshake retries the portal's other path on an endpoint failure, so
a portal that was refusing us was asked a second time -- the request the
comment there already calls the one that gets a MAC noticed.
The bodies are matched against the whole body rather than searched for inside
it. They are bare phrases, so a proxy page reading "<html><body>Access
denied</body></html>" -- 38 characters, under any length a cap would catch --
would otherwise be read as the portal speaking, and send the resolver
re-authenticating against a host that never answered at all.
Panels that are not Ministra refuse inside the envelope rather than in place
of it, and those were not seen anywhere: get_genres answered {'js': {'error':
'Invalid token'}} counted as a portal with no genres, so Test portals reported
it as authenticated with 0 groups, and get_all_channels on the same reply told
the user to check a MAC address that was never the problem. handshake() had
the same hole from the other side -- a reply with no token left the cached one
in place and returned it as if it had succeeded.
Two vocabularies, because the inputs are not comparable. A raw body is an
arbitrary document and gets the three anchored phrases; 'error' is a field a
panel filled in deliberately -- a reply that worked has none at all -- so
'Invalid token' and a bare 'unauthorized' are worth reading there. 'msg' gets
the exact bodies only, and only when the reply carries no 'status': a reply
that has one has a verdict login() reads for itself, and a status-2 portal
saying "Authorization required" is asking for a password, not refusing us.
Raising here would skip the do_auth it was asking for.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three clients now agree on more of the STB handshake than this one was sending, and the parts missing here are all read by the same thing: the optional access_filter.php a reseller installs in front of Ministra. The stock middleware ignores every one of them, which is why their absence has never shown up as a failure anyone could trace -- a portal that runs a filter simply refuses, and says nothing about which field made it. - 'prehash' on the handshake and the profile. iptvnator computes the SHA1 of the upper-case MAC, QiTV ships one constant for every install it has ever run; only the first says anything true about this box, and if a portal ever checks the value against its own record, a constant shared by every user of one client is the version that fails. - 'metrics', the JSON blob a portal stores and shows its operator, so a device list has a MAG in it rather than a blank row. It echoes the nonce the handshake handed back, which is the part a portal can actually check: it issued that value one request ago, and only something that read the answer can quote it. - 'client_type' and 'video_out', which both other clients send and this one never did. get_ordered_list gains 'category', 'force_ch_link_check' and 'hd' for the same reason _common_params sends the identity three ways: a Ministra portal reads 'genre' and ignores the rest, the clones do not all read the same one, and the cost of saying it several ways is a longer query string. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
create_link encoded the command with quote(cmd, safe=""), which escapes the percent sign along with everything else. The portal's own client does not: it concatenates the command into the query string and lets the URL layer escape only what a URL cannot carry, so PHP form-decodes it exactly once and a command that already contains '%3A' arrives with that escape intact. Ours arrived as '%253A' -- still encoded, a different string from the one a real box sends, and the stock create_link handler matches on it with preg_match. The commands this could happen to are the ones a portal answered its listing with as a resolved URL, which are exactly the ones canonical_cmd declines to rewrite when they carry no channel id: worst where there is least left to recover it. The safe set is the one a URL serializer keeps raw in a query, minus nothing that matters: '&', '#' and ';' stay encoded, so a portal cannot restructure our own request by putting them in a command. 'disable_ad' and 'download' go out beside it. Neither changes what a live channel answers; both are what a portal expecting its own client sees on every request except this plugin's. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The endpoint cannot be worked out from what a user pastes. Ministra serves <base>/stalker_portal/server/load.php; reseller panels serve <base>/c/portal.php or <base>/portal.php, and some serve neither, from a path of their own. pvr.stalker knows the first pair and stalkerhek the second, this plugin knew the second, and a portal on any of the others could not be reached at all. So alternate_endpoint, which was a mapping, becomes endpoint_candidates, which is a list to probe. Its second entry is exactly what the mapping used to return, both ways round, so a portal found on the second try before is found on the second try still; the rest are added after it. The other half is that normalize_portal_url no longer overrides a .php the user pasted. Swapping any other .php for portal.php in the same directory is stalkerhek's rule and the one departure from it here: a panel under a path of its own is not a typo, it is the address the provider handed out, and replacing it with a guess is how the one URL known to work stops being tried. The standard siblings are probed after it, so nothing is lost by trying it first. Walking the list stays bounded by the rule that was already there: only an answer that is not a Stalker API moves to the next candidate. Every path lives on the same host, so a portal that is down, unwell or refusing the MAC fails on the first one -- at tune time a wasted round-trip is time Dispatcharr is not yet spending on the next source. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
**The expiry.** It was read from get_main_info's 'phone', which is where a
reseller types it because Ministra shows that field in the MAG interface. But
Ministra's own answer carries it too, as a Unix timestamp in the account_info
block of the profile login() has already read -- free, and the authoritative
one where it exists. get_main_info is now only asked when that came up empty,
and its 'end_date' and 'expire_billing_date' are read alongside 'phone'.
Timestamps needed the parser to grow two rules. A portal writes "never" as 0
or -1, and reading either as a date reports every unlimited account as having
run out in 1970 -- the one wrong answer worse than no answer at all; and the
same value arrives in milliseconds from portals that send it that way.
**The refusal.** Portals write these with markup in them ("Subscription
expired.<br/> Call us."), and it was going into a Dispatcharr notification
with the tags still in it.
A device conflict now says what to do about it. It is the only refusal here a
user can act on, and the portal's own wording names the box rather than the
binding, so the message arrived describing a broken STB instead of two
settings on the portal line. Matched on the binding alone: "device limit
reached" has no remedy, and offering one would be worse than saying nothing.
**A repeated channel.** get_all_channels was deduplicated only on the paged
path. Portals do repeat a row there, and Dispatcharr hashes a stream partly on
its URL, so a duplicate is a second stream for one channel rather than an
extra line nobody sees.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A MAG sends the mac cookie and the session token to everything it fetches from the portal, and panels that gate the stream on that cookie answer a request without one with a 403. This plugin sent neither, so those channels authenticated, resolved a link, and then would not play -- with nothing in the ffmpeg output to say which of the three steps had actually failed. They go out now, but only where a box would send them. A create_link URL frequently points somewhere that is nobody's business but its own -- a CDN, an operator's edge, another provider entirely -- and it carries its own token in the query, so it needs nothing from us. Sending the MAC and the session token there would hand a third party everything required to use the subscription. Same host, and never a downgrade from https to http. A different port is still the portal: panels routinely serve the stream from :8080 next to the portal on :80, and those are exactly the ones gated on the cookie. resolve() hands the token back with the link because only the session that minted a link is the one the stream will accept -- reading the cache again would race the re-authentication the optimistic path may just have done. --probe reports which of the two shapes it sent, since "resolved but 403" is the failure this exists to explain. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A Stalker row carries use_http_tmp_link and use_load_balancing, and the portal's own player.js calls create_link only when one of them is set -- everything else plays the cmd the listing already gave. pvr.stalker does the same and cites that line of player.js for it; iptvnator does it again with guards for portals that are not Ministra. This plugin asked for a link every time. On nearly every portal that changes nothing, and the reason is worth writing down: a stock row is 'ffmpeg http://localhost/ch/123_', which only the portal can resolve whatever its flags say -- and canonical_cmd rewrites the rows that arrive as resolved links into exactly that shape. pvr.stalker has no loopback guard at all, which is itself evidence that Ministra sets the flag on those rows: an ecosystem depends on it. The family this is for is the one undoubled_link was written for. Those providers answer the listing with a resolved link, and answer create_link by gluing their own base in front of it -- producing a path that returns 401, so the reply and its token are thrown away and the command is played anyway. That is one request per tune, spent to arrive back where it started, against providers that count connections. Four guards, each of which falls back on asking the portal, so none of them can break an install that works today. Absent flags are no evidence rather than a no -- every portal synced before this carries neither, and reading silence as "static" would move a whole installation at once. The scheme has to be one ffmpeg opens, which is not the question extract_link answers: a portal's own ffrt4:// pseudo-URL parses like an address and plays as nothing. The host must not be loopback, in rather more spellings than the obvious one. The fourth is ours rather than anybody's reference behaviour, and it is what makes this safe in a resolver rather than in a player: no query string. A link that expires carries its token there, and being wrong does not cost a retry -- by then the resolver has become ffmpeg and Dispatcharr has spent this channel's failover on a source that resolved perfectly well. The set of qualifying commands is published like the portal itself, Redis with a disk mirror, so a restart does not quietly turn the optimisation off. It is rewritten on every sync including when empty: a portal that has stopped marking its channels static must not inherit the list that said otherwise. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Portals box was the one place a MAC, a password and the rest of the account identity were rendered back to the user, and Dispatcharr serves a plugin's settings row to every account on the install -- so they were also in an API response anyone could read. The row now holds a redacted rendering and the real list lives only in portals.txt, which the panel cannot reach. Redaction is a property of the write path alone: _reconcile_registry undoes it on the way in, so every migration, action and sync goes on reading plain lines. Names, URLs and the tuning keys stay readable. A box of nothing but bullets is one nobody can recognise their own portals in, and the rule is what a value proves rather than what it configures. Two guards decide the whole thing. Nothing is redacted until portals.txt is known to hold the same list -- hiding the only copy of a credential is how a configuration gets lost -- and redacted text is never mirrored back over that file, because _failed() can be reached with settings that never passed through the reconciliation. This hides the credentials; it does not encrypt them. The resolver reads the MAC on every tune in a process with no Django, so portals.txt and the state mirrors go on holding it in the clear at 0600, and the README says so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Taking a field out of plugin.json only stops the panel rendering it. The value stays in PluginConfig.settings, which Dispatcharr serves to every account on the install -- so the Add-portal form removed in 0.4.0 left a MAC, a password and a portal URL in the API response for a month, belonging to a portal the user had long since deleted. sync_interval_hours went the same way with the schedule. Redacting the portal list and leaving those behind would have been half a job, so anything the manifest does not declare is now dropped from the row. The manifest is the single definition of the panel, and test_manifest.py already pins that every setting the code reads is declared, so the set is safe by construction. Ordering matters: it runs after the migrations, whose whole input is keys the manifest stopped declaring three versions ago and which would otherwise be taken out from under them. And it runs on every action rather than once, because an open panel replays the stale keys until the page is reloaded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two lines on the release page, because that is what somebody deciding whether to upgrade needs: the portals this now talks to, and the credentials it stops showing. The reasoning behind each sits below the details marker, for whoever comes looking for it in the file. Test and Re-fetch all are named at the top for the same reason as 0.9.3: only the code changed, so Sync compares your settings against what was published, finds nothing moved, and fetches nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.