Tag Codeberg

  • ssh-Based git Upstreams with Multiple Identities

    A screenshot showing a grid of many grey and a few black boxes, with a legend “70 contributions in the last 12 months“.

    This is what I'd like to contain: Activity graphs on version control platforms. It should not be too easy to get a global graph of my activities, and in particular not across my various activities in work and life, even where these are, taken by themselves, public.

    I have largely given up on self-hosting version control systems for the part of my stuff that the general public might want to look at. In this game, it's platforms[1] exclusively nowadays, as there is no way anyone would find the material anywhere else.

    That, of course, is a severe privacy problem, even when the platform itself is relatively benevolent (i.e., codeberg). For people like me – spending a significant part of their lives at the keyboard and version-controlling a significant part of the keystrokes, the commit history says a lot about their lives, quite likely a lot more than they care to publicly disclose.

    As a partial mitigation, I am at least using different accounts for different functions: work, hacking, politics, the lot. The partial commit histories are decidedly less telling than a global commit history would be.

    However, this turns out to be trickier than you might expect, which is why I am writing this post.

    First off, the common:

    git config user.name "Anselm Flügel"
    git config user.email zuengeln@tfiu.de
    

    does not have anything to do with the platform accounts. It only changes the authorships in the log entries, and you are completely free to put anything at all there. The account used for pushing – and hence the source of the platforms' user history and activity images (see above) is absolutely unrelated to the commits' user.name-s. Instead, the account name is, in effect, encoded in the URI of the remote; and that is where things become subtle.

    Because, you see, there is no useful user name in:

    $ git remote get-url origin
    git@codeberg.org:AnselmF/crapicity.git
    

    The AnselmF in there is part of the repo path; you can push into other peoples' repos if they let you, so that cannot be the source of the user name. And the “git@” at the start, while it looks like a username and actually is one, is the same for everyone.

    So, how do github, codeberg and their ilk figure out which account to do a push under? Well: They use the ssh key that you uploaded into your profile. Since each ssh key can only be assigned to one account, the platforms can deduce the account from the fingerprint of the public key that ssh presents on connecting.

    Historical note: the big Debian SSL disaster 16 years ago, where Debian boxes would only generate a very small number of distinct secret keys (thus making them non-secret), was uncovered in this way, as just when the early github phased in this scheme, impossibly many keys from different persons turned out to have the same fingerprint. Matt Palmer recently related how in his work at github he worked out Debian's broken random number generator back then.

    In practice, this means that when you want to have multiple accounts on a single platform, after you have created a new account, you need to create a new ssh key associated with it (i.e., the new account), preferably with a name that roughly matches its intended use:

    cd ~/.ssh
    ssh-keygen -t ed25519 -f id_anselm
    

    This will leave the public key in ~/.ssh/id_anselm.pub; the contents of that file will go into (say) codeberg's SSH key text box (look for the “Keys” section in your “Settings”).

    This still is not enough: ssh will by default try all the keys you have in ~/.ssh in a deterministic order. This means that you will still always be the same user as long as you use a remote URL like git@codeberg.org:AnselmF/crapicity.git – the user the public key of which is tried first. To change this, you must configure ssh to use your account-specific key for some bespoke remote URIs. The (I think) simplest way to do that is to invent a hostname in your ~/.ssh/config, like this:

    Host codeberg-anselm
            HostName codeberg.org
            User git
            IdentitiesOnly yes
            IdentityFile ~/.ssh/id_anselm
    

    This lets you choose your upstream identity using the authority part of the remote URI; use (in this case) codeberg-anselm rather than codeberg.org to work with your new account. Of course the URIs you paste from codeberg (or github or whatever) will not know about this. Hence, you will normally have to manually configure the remote URI, with a (somewhat hypothetical) command sequence like this:

    git clone git@codeberg.org:AnselmF/crapicity.git # pasted URI
    git remote set-url origin git@codeberg-anselm:AnselmF/crapicity.git
    

    After that, you will push and pull using the new account.

    [1]Well, at this point it is, if I have to be absolutely honest, one platform largely, but I outright refuse to acknowledge that.
  • Feedback and Addenda in Pelican Posts

    Screenshot: a (relatively) rude comment and a reply, vaguely reminiscent of classic slashdot style.

    Blog comments may be dead out there; here, I'd like to at least pretend they're still alive, and thus I've written a pelican plugin to properly mark them up.

    When I added a feedback form to this site about a year ago, I also created a small ReStructuredText (RST) extension for putting feedback items into the files I feed to my blog engine Pelican. The extension has been sitting in my pelican plugins repo on codeberg since then, but because there has not been a lot of feedback on either it or the posts here (sigh!), that was about it.

    But occasionally a few interesting (or at least provocative) pieces of feedback did come in, and I thought it's a pity that basically nobody will notice them[1] or, (cough) much worse, my witty replies.

    At the same time, I had quite a few addenda to older articles, and I felt some proper markup for them (plus better chances for people to notice they're there) would be nice. After a bit of consideration, I figured the use cases are similar enough, and I started extending the feedback plugin to cover addenda, too. So, you can pull the updated plugin from codeberg now. People running it on their sites would certainly be encouragement to add it to the upstream's plugin collection (after some polishing, that is).

    Usage is simple – after copying the file to your plugins folder and adding "rstfeedback" to PLUGINS in pelicanconf.py, you write:

    .. feedback::
        :author: someone or other
        :date: 2022-03-07
    
        Example, yadda.
    

    for some feedback you got (you can nest these for replies) or:

    .. addition::
      :date: 2022-03-07
    
      Example, yadda.
    

    for some addition you want to make to an article; always put in a date in ISO format.

    In both cases a structured div element is generated in the HTML, which you can style in some way; the module comment shows how to get what's shown in the opening figure.

    The extension also adds a template variable LAST_FEEDBACK_ITEMS containing a list of the last ten changes to old posts. Each item is an instance of some ad-hoc class with attributes url, kind (feedback or addendum), the article title, and the date. On this blog, I'm currently formatting it like this in my base template:

    <h2>Letzte Ergänzungen</h2>
    <ul class="feedback">
    {% for feedback_item in LAST_FEEDBACK_ITEMS %}
            <li><a href="{{ SITEURL }}/{{ feedback_item.url }}">{{ feedback_item.kind }} zu „{{ feedback_item.title }}“</a> ({{ feedback_item.date }})</li>
    {% endfor %}
    </ul>
    

    As of this post, this block is at the very bottom of the page, but I plan to give it a more prominent place at least on wide displays real soon now. Let's see when I feel like a bit of CSS hackery.

    Caveats

    First of all, I have not localised the plugin, and for now it generates German strings for “Kommentar” (comment), “Nachtrag” (addendum) and “am” (on). This is relatively easy to fix, in particular because I can tell an article's language from within the extension from the article metadata. True, that doesn't help for infrastructure pages, but then these won't have additions anyway. If this found a single additional user, I'd happily put in support for your preferred language(s) – I should really be doing English for this one.

    This will only work with pages written in ReStructuredText; no markdown here, sorry. Since in my book RST is so much nicer and better defined than markdown and at the same time so easy to learn, I can't really see much of a reason to put in the extra effort. Also, legacy markdown content can be converted to RST using pandoc reasonably well.

    If you don't give a slug in your article's metadata, the plugin uses the post's title to generate a slug like pelican itself does by default. If you changed that default, the links in the LAST_FEEDBACK_ITEMS will be wrong. This is probably easy to fix, but I'd have to read a bit more of pelican's code to do it.

    I suppose the number of recent items – now hardcoded to be 10 – should become a configuration variable, which again ought to be easy to do. A more interesting (but also more involved) additional feature could be to have per-year (say) collections of such additions. Let's say I'm thinking about it.

    Oh, and error handling sucks. That would actually be the first thing I'd tackle if other people took the rstfeedback plugin up. So… If you'd like to have these or similar things in your Pelican – don't hesitate to use the feedback form (or even better your mail client) to make me add some finish to the code.

    [1]I made nginx write logs (without IP addresses, of course) for a while recently, and the result was that there's about a dozen human visitors a day here, mostly looking at rather recent articles, and so chances are really low anyone will ever see comments on old articles without some extra exposure.
  • A QR Code Scanner for the Desktop

    Screenshot: Two windows.  One contains a photo of a QR code, the other two buttons, one for opening the URI parsed from the QR code, the other for canceling and scanning on.

    qropen.py in action: Here, it has scanned a QR code on a chocolate wrapper and asks for confirmation that you actually want to open the URI contained (of course, it doesn't come with the glitzy background).

    When I was investigating the SARS-2 vaccination certificates last year (sorry: that post is in German), I played a bit with QR codes. A cheap by-product of this was a little Python program scanning QR codes (and other symbologies). I cannot say I am a huge fan of these things – I'd take short-ish URIs without cat-on-the-keyboard strings like “?id=508“ instead any day –, but sometimes I get curious, and then this thing comes in handy given that my telephone cannot deal with QR codes.

    Yesterday, I have put that little one-file script, qropen.py, on codeberg, complemented by a readme that points to the various ways you could hack the code. I'll gratefully accept merge requests, perhaps regarding a useful CLI for selecting cameras – you see, with an external camera, something like this thing starts being actually useful, as when I used it to cobble together a vaccination certificate checker in such a setup. Or perhaps doing something smart with EAN codes parsed (so far, they just end up on stdout)?

    On that last point, I will admit that with the camera on my Thinkpad X240, most product EAN codes do not scan well. The underlying reason has been the biggest challenge with this program even for QR codes: Laptop cameras generally have a wide field of view with a fixed focus.

    The wide field of view means that you have to bring the barcodes pretty close to the camera in order to have the features be something like three pixels wide (which is what zbar is most fond of). At that small distance, the fixed focus means that the object is severely out of focus and hence the edges are so blurry that zbar again does not like them.

    Qropen.py tries to mitigate that by unsharp masking and potentially steep gammas. But when the lines are very thin to begin with – as with EAN stripes –, that does not really help. Which means that the QR codes, perhaps somewhat surprisingly given their higher information content, in general work a lot better for qropen.py than to the simple and ancient EAN codes.

    There clearly is a moral to this part of the story. I'm just not sure which (beyond the triviality that EANs were invented for lasers rather than cameras).

  • Wird Thomas Watson Recht behalten?

    In der diesjährigen Buchmesse-Sendung von Forschung aktuell am Deutschlandfunk ging es vor allem um Bücher zur Zukunft an und für sich. In der Anmoderation dazu sagte Ralf Krauter:

    Die Geschichte der Menschheit ist denn auch voll von krass falschen Prognosen. Die vielleicht bekannteste – oder eine meiner Favoriten – kennen Sie womöglich. Tom Watson, der fühere IBM-Chef, sagte im Jahr 1943 mal: Ich denke, es gibt einen Weltmarkt für vielleicht fünf Computer. Kam dann anders, wie wir alle wissen, aber es zeigt schon: Selbst die Prognosen von absoluten Fachleuten sind mit Vorsicht zu genießen.

    Als ich das gerade gehört habe, wollte ich spontan einwenden, dass das Urteil über Thomas Watsons Prognose noch aussteht. Natürlich wird es auf absehbare Zeit hunderte Milliarden von Mikroprozessoren geben, aber die meisten von denen tun Dinge, für die es früher vielleicht diskrete Steuerungen oder Analogelektronik gegeben hätte – sie dienen genau nicht als die universellen programmierbaren Geräte, die Watson bei seiner Schätzung im Kopf gehabt haben wird.

    Viele andere sind verbaut in den späten Erben der Fernschreiber und Terminals der Großrechnerära: Den Mobiltelefonen, die heute vielfach kaum mehr sind als Ein-/Ausgabegeräte für eine Handvoll großer Computer. Dabei muss mensch nochmal die Augen etwas zusammenkneifen; wenn wir Watson geben, dass er von riesigen Mainframes gesprochen hat, mit vielen, vielen CPUs, dann sind die heutigen „Clouds“ von Google, Facebook, Microsoft und Alibaba im Wesentlichen jeweils ein Computer im Sinn von Watson. In dieser Zählung – in der Router und Endgeräte nicht, die Rechenzentren der „Hyperscaler“ jeweils als ein Computer zählen – teilen sich in der Tat fünf oder zehn Computer den Großteil der Computernutzung eines Großteils der Menschheit.

    Je dominanter das Modell wird, in dem dumme Clients unter Kontrolle von Apple bzw. Google („Smartphones“) Dienste ausspielen, die auf einer kleinen Zahl von Infrastrukturen laufen („Cloud“), desto näher kommen wir wieder Watsons Einschätzung. Noch gibt es natürlich ordentliche Computer in allen möglichen Händen. Wie sehr sich die Menschen jedoch schon an das Konzept gewöhnt haben, dass sie nur Terminals, aber keine eigenen Computer mehr haben, mag eine Anekdote von meiner letzten Bahnreise illustrieren.

    Ich sitze im Zug von Würzburg nach Bamberg; er steht noch im Bahnhof. Ich kann mich nicht beherrschen und linse kurz auf den Bildschirm neben mir, und ich bin sehr erfreut, als dort jemand halbwegs ernsthafte Mathematik tippt, natürlich mit dem großartigen TeX. Meine Freude trübt sich etwas auf den zweiten Blick, denn der Mensch benutzt Overleaf, ein System, bei dem mensch in einem Webbrowser editiert und den TeX-Lauf auf einem Server macht, der dann die formatierten Seiten als Bilder wieder zurückschickt.

    Ich habe Overleaf, muss ich sagen, nie auch nur im Ansatz verstanden. Ich habe TeX schon auf meinem Atari ST laufen lassen, der ein Tausendstel des RAM der kleinsten heute verkauften Maschinen hatte, dessen Platte in einem ähnlichen Verhältnis zur Größe der kleinsten SD-Karte steht, die mensch heute im Drogeriemarkt kaufen kann. Gewiss, mit all den riesigen LaTeX-Paketen von heute wäre mein Atari ST überfordert, aber zwischen dem TeX von damals und dem TeX von heute ist kein Faktor 1000. LaTeX ist wirklich überall verfügbar, und es gibt gut gewartete und funktionierende Distributionen. Wer mit anderen gemeinsam schreiben will, kann auf eine gut geölte git-Infrastruktur zurückgreifen. Am wichtigsten: die Menschen vom Stamme vi können damit editieren, jene der emacs-Fraktion mit ihrem Lieblingseditor, niemand wird auf das hakelige Zeug von Overleaf gezwungen.

    Aber zurück zur Anekdote:

    Obwohl er also ganz einfach TeX auch auf seinem Rechner laufen lassen könnte, klickt mein Sitznachbar nur ein wenig hilflos herum, als wir den Bahnhof verlassen und mit dem WLAN auch das Hirn von Overleaf verschwindet. Er versucht dann, mit seinem Telefon die Nabelschnur zum Overleaf-Computer wiederherzustellen, aber (zum Unglück für Herrn Watson) ist die Mobilfunkversorgung der BRD marktförmig organisiert. Die Nabelschnur bleibt am flachen Land, wo zu wenig KundInnen Overleaf machen wollen, gerissen. Schließlich gibt er auf und packt seinen Nicht-Computer weg. Zu seinem Glück geht auf seinem Telefon immerhin noch mindestens ein Spiel ohne Internetverbindung…

    Dass die gegenwärtige Welt offenbar gegen die Vorhersagen von Thomas Watson konvergiert, dürfte kein Zufall sein. Watson war ein begnadeter Verkäufer, und er hat vom Markt geredet. Spätestens seit das WWW breite Schichten der Bevölkerung erreicht, wird auch das Internet mehr von begnadeten VerkäuferInnen und „dem Markt“ gestaltet als von BastlerInnen oder Nerds.

    Wenn ihr die Welt mit fünf Computern für eine Dystopie haltet und das Internet nicht „dem Markt“ überlassen wollt: Arg schwer ist das nicht, denn zumindest Unix und das Netz haben noch viel vom Erbe der WissenschaftlerInnen und BastlerInnen, die die beiden geschaffen haben. Siehe zum Beispiel die Tags Fediverse und DIY auf diesem Blog.

  • 34 Monate Corona im Film

    Im Sommer 2021 habe ich in zwei Posts Filme zur Visualisierung der Corona-Inzidenzen und einer Art Alters-Scores aus den RKI-Daten der vorherigen anderthalb Jahren vorgestellt und ein wenig das Python-Programm diskutiert, das die generiert.

    Ich wollte das über den Sommer immer mal aktualisieren. Aber die Pandemie hat keine erkennbare Pause eingelegt, die eine gute Gelegenheit für eine Art Rückblick gewesen wäre. Jetzt aber ist wohl so in etwa der letzte Moment, in dem so ein Film noch nicht vollständige Clownerei ist, denn schon jetzt testet zumindest anekdotisch kaum mehr jemand. Wenn aber fast niemand PCR-Tests machen lässt, werden die Zahlen, die ich da visualisiere, weitgehend bedeutunglos (weil nicht small data).

    Bevor das Ganze zu einer Art aufwändigen Lavalampen-Simulation wird, habe ich die Programme genommen, dem Inzidenzfilm eine dynamische Colorbar gegönnt – angesichts von Inzidenzen von örtlich über 4000/100'000 im letzten Winter war das bisher feste Maximum von 250 nicht mehr sinnvoll – und habe die Filmchen nochmal mit aktuellen Daten gerendert.

    Hier also die Inzidenzen per Kreis (Hinweise dazu von vor einem Jahr):

    und hier die Alters-Scores (auch dazu Hinweise vom letzten Mal):

    Beide Filme sind mit -i 7 gebaut, jeder Tag ist also sieben Frames lang. Inzwischen wären die Filme zwar auch ohne Interpolation lang genug, aber sie hilft wenigstens dem Lavalampen-Effekt – und natürlich verletze ich gerne das Durchhörbarkeits-Zeitlimit von drei Minuten. Auch wenn die Filme gar keinen Ton machen.

    Ich habe die Gelegenheit genutzt, um den Code, der das macht – statt ihn nur hier zu verlinken, wo ihn niemand finden wird –, brav auf dem Codeberg abzuladen. Vielleicht hilft er dort ja Leuten, die irgendwann irgendwelche Daten in Kreispolygonen von matplotlib aus plotten wollen. Wenn es dafür bessere Standardverfahren geben sollte, habe ich die zumindest vor einem Jahr nicht gefunden.

  • Blog Extensions on Codeberg

    Screenshot of a browser window showing http://localhost:6070/foo and a fortune cookie in glorious ASCII.

    This post takes an odd bend to become an apology for CGI (as in common gateway interface) scripts. This is the netsurf browser communicating with the CGI shell script at the foot of this post.

    I have written a few plugins and extensions for this blog, and I have discussed them in a few posts (e.g., feedback form, tag explanations, cited-by links, or the search engine). The code implementing these things has been strewn across the various posts. I have to admit that having that code attached to just a few blog posts has always felt somewhat too early-90iesy to me.

    Now that I have created my Codeberg account, I have finally copied together all the various bits and pieces to create a repository on Codeberg that you are welcome to clone if you're running pelican or perhaps some other static blog engine. And of course I appreciate merge requests with improvements.

    There is one major file in there I have not previously discussed here: cgiserver.py. You see, I'm a big fan of CGI scripts. They're reasonably simple to write, trivial to deploy, and I have CGIs that have been working with minimal maintenance for more than 20 years. Sure, pulling up an interpreter for every request is not terribly efficient, but for your average CGI that is perhaps called a dozen times per day (depending on how many web crawlers find it interesting) this really doesn't matter. And that's why both the feedback script and the search engine are written as CGIs.

    However, in contrast to apache, nginx (which serves this blog) does not support CGI scripts. I even by and large agree with their rationale for that design decision. Still, I would like to run CGIs, and that's why I've written the cgiserver. It is based on Python's built-in HTTP server and certainly will not scale – but for your average blog (or similar site) it should be just fine. And I don't think it has any glaring security problems (that you don't introduce with your CGIs, that is).

    Installation is almost trivial: put the file somewhere (the in-source sysvinit script assumes /var/www/blog-media/cgiserver.py, but there's absolutely no magic about this), and then run it with a port number (it will only bind to localhost; the default in the sysvinit script is 6070) and a directory into which you put your CGI scripts (the sysvinit script assumes /var/www/blog-media/cgi).

    When you have a cgi script foo, you can dump it in this directory, make it executable and then run it by retrieving http://localhost:6070/foo. In case you have nothing else, you can try a shell script like:

    #!/bin/sh
    echo "content-type: text/plain"
    echo
    /usr/games/fortune
    

    (which of course only works in this form if you have something like fortunes-en installed on a Debian box). That should be enough to give you something like the screenshot opening this post. Even more than 25 years after I have written my first CGI, I am still amazed how simple this is.

    Disclaimer: Writing CGI scripts that take input such that they are not trivially exploitable is higher art. So… don't do it, except as a game. Oh, and to debug your scripts, simply let cgiserver run in a terminal – that way, you will see what your scripts emit on stderr. Note, however, that the way the sysvinit script starts cgiserver, it will run as nobody; if things work when you start cgiserver yourself but not when it's running as a daemon, that's the most likely reason.

  • Bahnauskuft auf antiken Geräten – und auf Codeberg

    Foto: Altes Mobiltelefon mit Terminal, das eine etwas kryptische Bahnauskunft zeigt

    Bahnauskunft von 2022 auf einem Nokia N900 von 2009: Es braucht inzwischen etwas Mühe, um das gebastelt zu kriegen.

    Als die Bahn-Webseite nicht mehr ordentlich auf kompakten Browsern wie dillo funktionierte und auch nicht per WAP– also Mitte der 2010er Jahre –, habe ich mir ein ein kleines Skript geschrieben, das die wesentlichen Infos zur Zugauskunft aus dem HTML herausklaubte und dann in einem einfachen Kommandozeilen-Interface darstellte. Das war, worum es im letzten Sommer bei meinem Rant gegen Zwangs-Redirects umittelbar ging. Der weitere Hintergrund: Ich will Zugauskünfte von meinem alten Nokia N900 aus bekommen (und im Übrigen seit der Abschaltung von UMTS auch über eine 2G-Funkverbindung, also etwas wie 10 kB/s)[1].

    Nachdem das – jedenfalls nach Maßstäben von Programmen, die HTML auf Webseiten zerpflücken – überraschend lang gut ging, ist das im Rahmen der derzeitigen Verschlimmbesserung der Bahn-Seite neulich kaputt gegangen. Obendrauf ist die Javascript-Soße auf bahn.de damit so undurchsichtig geworden, dass mich die Lust, das Skript zu pflegen, sehr nachhaltig verlassen hat. In dieser Lage kam ein Vortrag über die Bahn-APIs, den jemand bei der Gulasch-Programmiernacht 2019 gehalten hat, gerade recht. Also: Das Video davon.

    In diesem Video habe ich gelernt, dass mein „unpromising“ im Rant vor einem Jahr,

    I know bahn.de has a proper API, too, and I'm sure it would be a lot faster if I used it, but alas, my experiments with it were unpromising [...],

    einen tiefen Hintergrund hat. Die Bahn hat nämlich keine API für die Fahrplanauskunft.

    Was es aber stattdessen gibt: die HaFAS-API, auf die die Reiseplanung der Bahn-App selbst aufsetzt. Und es stellt sich heraus, dass Leute schon mit viel Fleiß ausbaldowert haben, wie die so funktioniert, etwa in pyhafas.

    Mit pyhafas kann ich all das schreckliche HTML-parsing aus dem alten bahnconn.py durch ein paar Aufrufe in pyhafas rein ersetzen. Aber leider: pyhafas ist echt modernes Python, und weil es viel mehr kann als es für bahnconn.py bräuchte, wäre das Rückportieren davon nach Python 2.5 ein ernsthaftes Projekt; mehr habe ich aber auf meinem N900 nicht. Außerdem bin ich bekennender Fan von ein-Modul-und-stdlib-Programmen: die brauchen keine Installation und laufen zudem mit allem, das irgendwie Python verdauen kann, also etwa auch jython oder sowas, was spätestens dann in Frage steht, wenn Abhängigkeiten C-Code enthalten.

    Deshalb habe ich aus pyhafas die Dinge, die bahnconn dringend braucht, abgeschaut und eine minimale, Python-2.5-verträgliche Implementation gebastelt. Das Ergebnis: ein neues bahnconn. Holt es euch, wenn ihr Bahnauskunft auf älteren Geräten haben wollt. Ich habe es jetzt nicht auf Atari TTs probiert, aber ich kann mir gut vorstellen, dass es selbst da noch benutzbar ist.

    Codeberg

    Gerade, als ich den Code einfach wieder hier auf dem Blog abwerfen wollte, habe ich beschlossen, das könne ein guter Anlass sein, endlich mal einen zweiten Blick auf Codeberg zu werfen.

    Bisher habe ich nämlich für allen etwas langlebigeren oder größeren Code (also: nicht einfach nur am Blog abgeworfenen Kram), ganz DIY, ein eigenes Subversion-Repository betrieben. Was in den letzten Jahren neu dazukam, habe ich in git+ssh+cgit gesteckt.

    Natürlich hat das niemand mehr gesehen; nicht mal Suchmaschinen gucken mehr auf sowas, seit aller Code der Welt bei github landet. Deshalb, und auch, weil ich Monstren wie gitea und gitlab wirklich nicht auf meiner Maschine haben will (allerdings: cgit ist ok und würde für Publikation auf subversion-Niveau reichen), habe ich mich mit dem Gedanken, dass mein Kram auf einer öffentlichen Plattform besser aufgehoben sein mag, mehr oder minder abgefunden.

    Auf Github bin ich beruflich schon so viel zu viel unterwegs, und der Laden ist deutlich zu nah am Surveillance Capitalism. Zwar kenne ich hinreichend Projekte und Firmen, die ihnen Geld geben, so dass sie gewiss ein konventionell-kapitalistisches Geschäftsmodell fahren könnten; aber schon da fehlt mir der Glaube. Obendrauf hat mir Microsoft in meinem Leben schon so viel Kummer bereitet, dass ich ihnen (bzw. ihrem Tochterunternehmen) nicht noch mehr KundInnen zutreiben will.

    Codeberg, auf der anderen Seite, wird von einem Verein betrieben und macht generell vieles richtig, bis hin zu Einblendungen von Javascript-Exceptions (warum machen das eigentlich nicht alle?), so dass die Seite nicht einfach heimlich kaputt ist, wenn ich Local Storage verbiete (gitea, die Software, auf der Codeberg soweit ich sehe aufsetzt, kann leider immer noch nicht ohne).

    Von dem gitea-Krampf abgesehen hat gestern alles schön funktioniert, nichts an der Anmeldeprozedur war fies oder unzumutbar. Codeberg hat hiermit erstmal das Anselm Flügel Seal of Approval. Ich denke, da werde ich noch mehr Code hinschaffen. Und mal ernsthaft über Spenden nachdenken.

    [1]Janaja, und natürlich nervte mich die fette Bahn-Webseite mit all dem Unsinn darauf auch auf dem Desktop und auch schon vor der gegenwärtigen Verschlimmbesserung.
  • Fernseh-Livestreams mit Python und mpv

    Nachtrag (2022-10-11)

    Ich habe das Programm jetzt auf codeberg untergebracht

    Die Unsitte, alles in den Browser zu verlegen, ist ja schon aus einer Freiheitsperspektive zu verurteilen – „die Plattform“ gibt die Benutzerschnittstelle vor, kann die Software jederzeit abschalten und sieht (potenziell) noch den kleinsten Klick der NutzerIn (vgl. WWWorst App Store). Bei Mediatheken und Livestreams kommt noch dazu, dass videoabspielende Browser jedenfalls in der Vergangenheit gerne mal einen Faktor zwei oder drei mehr Strom verbraucht haben als ordentliche Videosoftware, von vermeidbaren Hakeleien aufgrund von schlechter Hardwarenutzung und daraus resultierendem Elektroschrott ganz zu schweigen.

    Es gibt also viele Gründe, speziell im Videobereich den Web-Gefängnissen entkommen zu wollen. Für übliche Videoplattformen gibt es dafür Meisterwerke der EntwicklerInnengeduld wie youtube-dl oder streamlink.

    Für die Live-Ströme der öffentlich-rechtlichen Anstalten hingegen habe ich zumindest nichts Paketiertes gefunden. Vor vielen Jahren hatte ich von einem Freund ein paar screenscrapende Zeilen Python erledigt dazu. In diesen Zeiten müssen allerdings Webseiten alle paar Monate komplett umgeschrieben („jquery ist doch total alt, angular.js hat auch schon bessere Tage gesehen“) und regelauncht werden, und das Skript ging mit einem Relaunch ca. 2015 kaputt. Als ich am Freitag die Tagesschau ansehen wollte, ohne DVB-Hardware zu haben, habe ich mich deshalb nach einer Neufassung des Skripts umgesehen.

    Das Ergebnis war eine uralte Seite mit mpv-Kommandozeilen und ein Verweis auf ein von den MediathekView-Leuten gepflegtes Verzeichnis von Live-Strömen. Da stehen zwar oben alt aussehende Timestamps drin, das Log aber zeigt, dass der Kram durchaus gepflegt wird.

    Aus letzterem habe ich livetv.py (ja, das ist ein Download-Link) gestrickt, ein weiteres meiner Ein-Datei-Programme. Installiert mpv (und Python, klar), macht chmod +x livetv.py und sagt dann ./livetv.py ARD Livestream – fertig. Bequemer ist es natürlich, das Skript einfach irgendwo in den Pfad zu legen.

    Das Argument, das das Programm haben will, kann irgendeine Zeichenfolge sein, die eindeutig einen Sender identifiziert, also nur in einem Sendernamen vorkommt. Welche Sender es gibt, gibt das Programm aus, wenn es ohne Argumente aufgerufen wird:

    $ livetv.py
    3Sat Livestream
    ...
    PHOENIX Livestream
    

    Mit der aktuellen Liste könnt ihr z.B. livetv.py Hamburg sagen, weil „Hamburg“ (auch nach Normalisierung auf Kleinbuchstaben) nur in einer Stationsbezeichnung vorkommt, während „SWR“ auf eine Rückfrage führt:

    $ livetv.py SWR
    SWR BW Livestream? SWR RP Livestream?
    

    „SWR BW“ (mit oder ohne Quotes auf der Kommandozeile) ist dann eindeutig, woraufhin livetv an den mpv übergibt.

    Ich gehe davon aus, dass die Anstalten die URLs ihrer Streams auch weiterhin munter verändern werden. Deshalb kann sich das Programm neue URLs von den MediathekView-Leuten holen, und zwar durch den Aufruf:

    $ livety.py update
    

    Das schreibt, wenn alles gut geht, die Programmdatei neu und funktioniert mithin nur, wenn ihr Schreibrechte auf das Verzeichnis habt, in dem livetv.py liegt – was besser nicht der Fall sein sollte, wenn ihr es z.B. nach /usr/local/bin geschoben habt.

    A propos Sicherheitsüberlegungen: Der update-Teil vertraut gegenwärtig ein wenig den MediathekView-Repo – ich entschärfe zwar die offensichtlichsten Probleme, die durch Kopieren heruntergeladenen Materials in ausführbaren Code entstehen, aber ich verspreche nicht, raffinierteren Angriffen zu widerstehen. Abgesehen vom update-Teil halte ich das Programm für sicherheits-unkritisch. Es redet selbst auch nicht mit dem Netz, sondern überlässt das dem mpv.

    Livetv.py sagt per Voreinstellung dem mpv, es solle einen „vernünftigen“ Stream aussuchen, was sich im Augenblick zu „2 Mbit/s oder weniger“ übersetzt. Wer eine andere Auffassung von „vernünftig“ hat, kann die --max-bitrate-Option verwenden, die einfach an mpvs --hls-bitrate weitergereicht wird. Damit könnt ihr

    $ livetv.py --max-bitrate min arte.de
    

    für etwas sagen, das für die Sender, die ich geprüft habe, auch auf sehr alten Geräten noch geht,

    $ livetv.py --max-bitrate max arte.fr
    

    für HD-Wahnsinn oder

    $ livetv.py --max-bitrate 4000000 dw live
    

    für einen Stream, der nicht mehr als 4 MB/s verbraucht.

    Technics

    Die größte Fummelei war, die Kanalliste geparst zu bekommen, denn aus Gründen, für die meine Fantasie nicht ausreicht (MediathekView-Leute: Ich wäre echt neugierig, warum ihr das so gemacht habt), kommen die Sender in einem JSON-Objekt (statt einer Liste), und jeder Sender hat den gleichen Schlüssel:

    "X" : [ "3Sat", "Livestream", ...
    "X" : [ "ARD", "Livestream", ...
    

    – ein einfaches json.loads liefert also ein Dictionary, in dem nur ein Kanal enthalten ist.

    Auch wenn ich sowas noch nie gesehen habe, ist es offenbar nicht ganz unüblich, denn der json-Parser aus der Python-Standardbibliothek ist darauf vorbereitet. Wer ein JSONDecoder-Objekt konstruiert, kann in object_pairs_hook eine Funktion übergeben, die entscheiden kann, was mit solchen mehrfach besetzen Schlüsseln pasieren soll. Sie bekommt vom Parser eine Sequenz von Schlüssel-Wert-Paaren übergeben.

    Für meine spezielle Anwendung will ich lediglich ein Mapping von Stationstiteln (in Element 3 der Kanaldefinition) zu Stream-URLs (in Element 8) rausziehen und den Rest der Information wegwerfen. Deshalb reicht mir Code wie dieser:

    def load_stations():
      channels = {}
      def collect(args):
        for name, val in args:
          if name=="X":
            channels[val[2]] = val[8]
    
      dec = json.JSONDecoder(object_pairs_hook=collect)
      dec.decode(LIST_CACHE)
    
      return channels
    

    – das channels-Dictionary, das collect nach und nach füllt, ist wegen Pythons Scoping-Regeln das, das load_stations definiert. Die collect-Funktion ist also eine Closure, eine Funktion, die Teile ihres Definitionsumfelds einpackt und mitnehmt. So etwas macht das Leben von AutorInnen von Code sehr oft leichter – aber vielleicht nicht das Leben der späteren LeserInnen. Dass die collect-Funktion als ein Seiteneffekt von dec.decode(...) aufgerufen wird und dadurch channels gefüllt wird, braucht jedenfalls erstmal etwas Überlegung.

    Der andere interessante Aspekt am Code ist, dass ich die Liste der Live-Streams nicht separat irgendwo ablegen wollte. Das Ganze soll ja ein Ein-Datei-Programm sein, das einfach und ohne Installation überall läuft, wo es Python und mpv gibt. Ein Blick ins Commit-Log der Kanalliste verrät, dass sich diese allein im letzten Jahr über ein dutzend Mal geändert hat (herzlichen Dank an dieser Stelle an die Maintainer!). Es braucht also eine Möglichkeit, sie aktuell zu halten, wenn ich die Liste nicht bei jedem Aufruf erneut aus dem Netz holen will. Das aber will ich auf keinen Fall, weniger, um github zu schonen, mehr, weil sonst github sehen kann, wer so alles wann livetv.py verwendet.

    Ich könnte die Liste beim ersten Programmstart holen und irgendwo im Home (oder gar unter /var/tmp) speichern. Aber dann setzt zumindest der erste Aufruf einen Datenpunkt bei github, und zwar für neue NutzerInnen eher überraschend. Das kann ich verhindern, wenn ich die Liste einfach im Programm selbst speichere, also selbstverändernden Code schreibe.

    Das ist in interpretierten Sprachen eigentlich nicht schwierig, da bei ihnen Quellcode und ausgeführtes Programm identisch sind. Zu den großartigen Ideen in Unix gehört weiter, dass (das Äquivalent von) sys.argv[0] den Pfad zur gerade ausgeführten Datei enthält. Und so dachte ich mir, ich ziehe mir einfach den eigenen Programmcode und ersetze die Zuweisung des LIST_CACHE (das json-Literal von github) per Holzhammer, also regulärem Ausdruck. In Code:

    self_path = sys.argv[0]
    with open(self_path, "rb") as f:
      src = f.read()
    
    src = re.sub(b'(?s)LIST_CACHE = """.*?"""',
      b'LIST_CACHE = """%s"""'%(in_bytes.replace(b'"', b'\\"')),
      src)
    
    with open(self_path, "wb") as f:
      f.write(src)
    

    Dass das Schreiben ein eigener, fast atomarer Schritt ist, ist Vorsicht: Wenn beim Ersetzen etwas schief geht und das Programm eine Exception wirft, ist das open(... "wb") noch nicht gelaufen. Es leert ja die Programmdatei, und solange es das nicht getan hat, hat mensch eine zweite Chance. Ähnlich übrigens meine Überlegung, das alles in Binärstrings zu bearbeiten: beim Enkodieren kann es immer mal Probleme geben, die am Ende zu teilgeschriebenen Dateien führen können. Vermeide ich Umkodierungen, kann zumindest die Sorte von Fehler nicht auftreten.

    Wie dem auch sei: Dieser Code funktioniert nicht. Und zwar in recht typischer Weise innerhalb der Familie von Quines und anderen Selbstanwendungsproblemen: das re.sub erwischt auch seine beiden ersten Argumente, denn beide passen auf das Muster LIST_CACHE = """.*?""". Deshalb würden von livetv.py update auch diese beiden durch das json-Literal mit den Senderdefinitionen ersetzt. Das so geänderte Programm hat zwei Syntaxfehler, weil das json natürlich nicht in die String-Literale passt, und selbst wenn es das täte, gingen keine weiteren Updates mehr, da die Such- und Ersatzpatterns wegersetzt wären.

    Eine Lösung in diesem Fall ist geradezu billig: in Python kann mensch ein Leerzeichen auch als '\x20' schreiben (das ASCII-Zeichen Nummer 0x20 oder 32), und schon matcht der reguläre Ausdruck nicht mehr sich selbst:

    re.sub(b'(?s)LIST_CACHE\x20= """.*?"""',
      b'LIST_CACHE\x20= """%s"""'...
    

    Sicherheitsfragen

    Ein Programm, das Daten aus dem Netz in sich selbst einbaut, muss eigentlich eine Ecke vorsichtiger vorgehen als dieses hier. Stellt euch vor, irgendwer bekommt etwas wie:

    { "Filmliste": [....,
      "X": ["...
      "Igore": ['"""; os.system("rm -r ~"); """']
    }
    

    in das MediathekView-Repo committet; das würde für die MediathekView immer noch prima funktionieren, das Objekt mit dem Schlüssel Ignore würde fast sicher tatsächlich einfach ignoriert.

    Wer dann allerdings livetv.py update laufen lässt, bekommt den ganzen Kram in Python-Quelltext gepackt, und der Inhalt des Ignore-Schlüssels wird vom Python-Parser gelesen. Der sieht, wie der lange String mit den drei Anführungszeichen geschlossen wird. Danach kommt eine normale Python-Anweisung. Die hier das Home-Verzeichnis der NutzerIn löscht. Python wird die treu ausführen. Bumm.

    So funktioniert das in Wirklichkeit zum Glück nicht, denn ich escape im realen Code Anführungszeichen (das .replace(b'"', b'\\"')). Damit …

Seite 1 / 1

Letzte Ergänzungen