< the skull

EXPLAINING LAZYLOGIN
====================
2026-08-22

It's hard to keel a private tracker alive...

—>Two Clocks
------------

I built a system to keep web accounts alive. The difficult part was not
automating access. It was discovering that the clock that actually mattered was
not the one I was feeding, and making sure the tool itself would never become
the reason an account was lost.

The problem starts out as something trivial. Some services delete accounts due
to inactivity. You disappear for a few weeks, an automated routine marks the
account as abandoned, and something that took years to build disappears without
warning. Multiply that by a few dozen accounts, each with a different tolerance
window, and you get a task humans are particularly bad at: remembering to visit
things at irregular intervals, indefinitely.

The solution seems obvious: a script that logs in every now and then. That is
how it started. What follows is a record of what this naive version got wrong,
and the decisions that remained after each mistake.

Why not use HTTP requests
-------------------------

The first temptation is the cheapest one: an HTTP library, a POST to the login
form, a saved cookie. It works for a couple of weeks and then starts failing in
ways that produce no error message.

The reason is that an HTTP client does not look like a browser at any layer. The
TLS handshake fingerprint is different. There is no JavaScript engine, so any
challenge that requires executing code dies immediately. Headers arrive in the
wrong order, without the client hints that a real browser sends. None of this is
detected by accident. It is exactly what anti-bot systems measure.

So I run a real browser. That solves an entire category of problems and creates
a new one: automated browsers also announce themselves.

Headless is what gives it away
------------------------------

The obvious choice would be to run the browser in headless mode. I measured what
that costs:

  headless (default)          →  ...HeadlessChrome/150.0.0.0...
  headless + --headless=new   →  ...HeadlessChrome/150.0.0.0...
  headful                     →  ...Chrome/150.0.0.0...  (clean)

Headless mode writes `HeadlessChrome` directly into the User-Agent, and no flag
removes it. I tested the newest mode specifically because of this. It is the
cheapest automation signal imaginable: a string comparison.

The previous version of the system worked around this by rewriting the
User-Agent and patching properties of the `navigator` object with injected
JavaScript. I removed all of that, and this became one of the most
counterintuitive decisions in the project.

A patched getter is more detectable than the value it hides. It fails a
`toString()` test, and a manually written User-Agent has to remain consistent
with a dozen properties derived from it.

The alternative is almost stupidly simple: run a real headful browser, with the
window positioned at -32000,-32000. Nothing appears on the screen, and the
fingerprint is authentic because nothing was forged: real User-Agent, real
client hints brands, real plugin list, real compositor. Invisible by
construction, not by disguise.

Inside a container, the same idea becomes even cleaner. I use a virtual display
with Xvfb, and the browser is headful on a monitor that does not exist.

The leak no JavaScript patch can reach
--------------------------------------

Popular automation libraries communicate with the browser through a debugging
protocol, and some calls made through that protocol are observable from inside
the page. One of them, used to obtain execution contexts, leaves a trace that
any serious detection system can look for.

No amount of injected JavaScript can hide this because the leak happens at a
lower layer. I solved it by replacing the library with a compatible fork that
removes this call and uses isolated contexts instead.

Two practical consequences came from this, both discovered painfully. First, the
fork disables the console API, so `page.on("console")` becomes silent and
debugging has to rely on error events and response codes. The second is more
treacherous: the fork is a separate package with its own exception classes. An
`except TimeoutError` imported from the original library will never match a
timeout produced by the fork, turning a recoverable wait into a permanent
failure without making the reason obvious. I stopped importing concrete
exception types and started identifying timeouts by the class name.

The discovery that turned the project upside down
-------------------------------------------------

This is the finding that changed everything, and it only appeared because I read
the source code of the platforms instead of assuming how they worked.

One of the platforms most commonly used by these services decides what to delete
by reading a column called `last_login`. That column is written only during an
authentication event. Browsing the site with an already valid session updates a
different column, one that the deletion routine never reads.

The implication is brutal for a system like this. My architecture prioritized
reusing saved sessions specifically to avoid unnecessary logins. But reusing a
session keeps the site working without postponing deletion. The tool could have
reported success every week, with everything green, until the account simply
disappeared.

My answer was to separate two clocks for each account: one for visits and
another for authentication. Frequent visits keep the session warm and satisfy
platforms that count page access. A real periodic login satisfies the ones that
only count authentication. Each service has its own interval, derived from the
actual tolerance window of that platform.

There is also a useful asymmetry here: visiting frequently reduces the number of
logins, because a session that never goes cold never needs to be recreated.
Cheap traffic replaces an expensive operation.

What the system refuses to do
-----------------------------

This is the core of the project, and the part that shaped the code more than
anything else.

The greatest risk of a tool like this is not failing to visit. It is getting the
account locked while trying. Failed login attempts are the currency that
produces IP blocks. By reading the authentication controllers of these
platforms, I found concrete limits. One blocks an IP for hours after roughly
half a dozen failures within 24 hours, and for an entire day if more than one
different username is attempted from the same address. Another family issues a
six-hour ban after six failures, escalating eventually to a permanent ban.

Since all accounts leave through the same address, one naive behavior could lock
dozens of them at once. The previous version of the code had exactly that
behavior: a loop that tried username variations until one worked.

The rules that survived are:

  * One attempt per service per execution. No internal retry loop.
  * Never vary the username. The configured value is used literally, once.
  * Authentication errors are terminal. Rejected credentials, banned account,
    captcha blocking access, all of these stop that service for the current
    execution.
  * Progressive backoff. After consecutive failures, the interval grows: 6h,
    24h, 72h, one week.
  * Free block probing. One platform simply does not render the login form while
    the IP is banned. Checking for that costs zero attempts.
  * A login ceiling per cycle. After a long shutdown, almost everything appears
    to need renewal at the same time. A limit spreads this across several
    cycles.
  * Rejected credentials stay marked. A password that the service has already
    rejected is not attempted again because that would be a guaranteed failure.
    The marker stores a fingerprint of the password and clears itself
    automatically when the password changes.

Knowing the platform instead of guessing
----------------------------------------

Almost all of these services run on one of roughly half a dozen known platforms,
and each has its own field names, two-factor flow, and session markers. Guessing
generically produced the worst kind of error: “I could not confirm the login,”
without explaining why.

I started using a recipe for each platform. The differences matter more than
they seem:

  * In one family, the second factor appears on a separate page, and the field
    submits automatically after receiving six characters. Filling it and then
    clicking submit as well causes a duplicate POST that repeats an already
    consumed code.
  * In another, the second factor is inside the same login form. The code has to
    be entered before the single submission, and an expired code is
    indistinguishable from a wrong password.
  * One platform uses `uid` and `pwd` where all the others use `username` and
    `password`.
  * Another includes a captcha trap: an invisible field that has to remain
    empty, plus a timestamp from when the page loaded. Filling every field on
    the page, or submitting instantly, gives the automation away.

State detection also stopped being binary. Instead of “logged in or not,” I
classify each page as: valid session, login form, second factor, captcha, edge
challenge, invalid credentials, attempt limit, banned account, maintenance. The
reason determines the next step, and the difference between stopping and
insisting.

Captcha and the connection nobody mentions
------------------------------------------

For interactive challenges, my first option is not a paid solving service. It is
clicking the actual widget, inside my own browser.

That is free, but the real reason is something else. The clearance cookie issued
by these systems is tied to the outgoing IP address and to the exact User-Agent
that obtained it. A token obtained by an external service is born in another
browser, on another machine, and is rejected on the first request. The session
needs to be born in the browser that will actually use it.

The same rule governs human intervention. When something requires a person, I do
not ask them to log in through their own browser and then copy the cookie. I
expose the container’s own browser through a remote window, and the person logs
in inside that browser. The session is born in the correct place, with no
transplant involved.

Scheduling on a computer that sleeps
------------------------------------

The system runs on a desktop, not a server. It hibernates, restarts, and
sometimes stays powered off for days. A scheduler built around “sleep until the
next scheduled time” loses every scheduled event that passes while the machine
is offline.

So there is no scheduled sleeping. Each service stores its next visit date on
disk, and every few minutes the loop asks a single question: what is overdue? A
week with the computer powered off results in a recovery burst, not a week of
permanently missed visits.

I added two refinements on top of this. Initial dates receive a stable offset
derived from the name of each service, so one third of the fleet becomes due
each day instead of everything at once. It becomes a trickle rather than a
flood. Accounts that have already passed their tolerance window skip this
politeness and are visited immediately. Two days of politeness are not worth a
lost account.

Five incidents that were expensive
----------------------------------

It is worth recording the errors that only appeared during execution, because
four of the five would survive an ordinary code review.

An `await` inside a generator expression
----------------------------------------

Symptom

Every service failed with:

  TypeError: 'async_generator' object is not an iterator

with no useful clue about the origin.

Cause

An `await` inside a generator expression turns it into an asynchronous
generator. The `any()` consuming it called `next()` and exploded. A single line
that compiles, imports, and looks perfectly reasonable.

Fix

I moved the `await` into a variable before the comprehension, then wrote a
static AST checker that detects this pattern, along with coroutines created
without `await`, which can fail in complete silence.

Windows line endings in a shell script
--------------------------------------

Symptom

The container kept dying with:

  /entrypoint.sh: No such file or directory

for a file that was demonstrably inside the container.

Cause

A tool running on Windows converted the line endings, turning the shebang into:

  #!/bin/sh\r

The kernel was looking for an interpreter whose name ended with a carriage
return. The missing file was the interpreter, not the script.

Fix

I normalize the file and convert the line endings during the build, so the
origin of the editing environment no longer matters.

The browser remembered where the window was
-------------------------------------------

Symptom

Manual sessions opened, loaded, and remained invisible. The remote screen showed
nothing.

Cause

The browser stores its last window position inside the profile. Since automatic
visits park the window outside the screen, all profiles eventually remembered
that position. The visible window was therefore born hidden, even without the
positioning flag.

Fix

I explicitly force the window position in visible mode, overriding whatever the
profile remembers.

Two instances fighting over the same profile
--------------------------------------------

Symptom

`TargetClosedError` when opening the profile, immediately after a previous
session ended.

Cause

The browser does not release the profile directory lock the instant the context
closes. A check launched immediately afterward found the profile still locked
and fell back to a disposable context, losing exactly the continuity that the
persistent profile exists to provide.

Fix

I wait a few seconds and try the persistent profile again before considering the
fallback.

Filling credentials was causing the failures
--------------------------------------------

Symptom

Several services rejected passwords that worked perfectly when typed elsewhere.

Cause

Two things happened at the same time. The system pre-filled the fields before
handing over the window. When I typed over them without clearing the values
first, the strings were concatenated. And when the stored password was outdated,
automatic submission generated a real failed login attempt, exactly the currency
that produces IP blocks.

Fix

I never automatically fill credentials during a manual session. An explicit
button writes the credentials into the DOM when requested. This also works
around the remote session keyboard mapping, which can occasionally produce a
different character from the one typed.

What this taught me
-------------------

Three things happened often enough to become principles.

An honest fingerprint beats a forged one. I removed almost all of the “stealth”
the system originally had, and it became more robust. A Linux browser saying it
is Linux is safer than a fake and internally inconsistent Windows browser. A
partial disguise is worse than no disguise at all.

Evidence beats assumption. The decisions that mattered most came from reading
the source code of the platforms and measuring real behavior, not from
accumulated reputation in forums. The discovery of the two clocks was sitting in
a public configuration file that nobody had any particular reason to open.

The job of the tool is not to lose the account. Visiting is easy. All the
engineering, the attempt limits, progressive backoff, error classification, and
refusal to insist exists for the moment when something goes wrong. A maintenance
tool that destroys what it was supposed to preserve is worse than having no tool
at all.


< the skull