← Back to list

Proton Drive kept throttling my backups. So I built the Linux client Proton hasn’t shipped yet.

A read-only gateway on Proton’s official SDK that makes restic believe 45 GB of end-to-end encrypted cloud storage is a local folder. No…

Ricardo Franco Cantero · 2026-07-03 23:10 · 25 claps · 32.5 min read paywalled
#self-hosting #proton #backup #typescript #privacy
Open on Medium ↗
Wiki topics: RAG · RAG & Retrieval 🌐 · Web Development 🔒 · Cybersecurity 🔓 · Open Source

Proton Drive kept throttling my backups. So I built the Linux client Proton hasn’t shipped yet.

A read-only gateway on Proton’s official SDK that makes restic believe 45 GB of end-to-end encrypted cloud storage is a local folder. No staging copy, no reverse-engineered API, no waiting for the roadmap.

At 03:45 every night, a cron job on my €16 VPS (yes, the one from 44 Containers and a €16 Server) asks Proton Drive one simple question: what changed today? For months the answer took a few minutes. Then twenty. Then one night it took six hours, left a kernel thread stuck in uninterruptible sleep, and produced a mount point so dead that ls didn't even have the decency to fail. It just sat there, holding its breath.

The folder that killed it every single time contains 785 photos.

Not 785,000. Seven hundred and eighty-five.

The setup, and why it mattered

Proton Drive is my primary cloud. Everything lives there. The reason there’s a backup chain at all is the same reason I wrote When the cloud locks you out: the cloud is somebody else’s computer, and somebody else’s computer can lock you out, corrupt silently, or simply have a bad Tuesday.

So a small VPS pulls the whole Drive down every night and feeds it to restic, which snapshots it to a NAS at home over WireGuard and to a second cloud provider offsite. Credentials for all of that live only on the VPS. If ransomware ever owns one of my laptops, it finds precisely nothing to encrypt twice.

The pulling part ran on rclone’s protondrive backend, mounted as a FUSE filesystem that restic reads like a local disk. That backend is a heroic piece of community reverse engineering, and I mean that sincerely. It's also, structurally, a storm of API calls. Every nightly run walks the entire tree and fires thousands of metadata requests at Proton, with enthusiasm and without backoff.

Proton’s API doesn’t send you an error when it’s had enough of you. It just stops picking up. And rclone interprets silence as an invitation to ask again, louder.

A Proton engineer confirmed what I suspected when I dug into it: the throttling is pattern-based. It’s not your account that gets flagged. It’s your manners.

The night I proved it

Here’s the part that turned suspicion into a plan. Proton quietly ships an official CLI now, built on their official Drive SDK, the same codebase their own apps are converging on.

So on the worst evening, while rclone had been failing to list that iPhone folder for five straight hours, I put the official CLI on the same VPS, logged into the same account, and listed the same folder.

  • rclone, best case that day: 160 seconds for a non-recursive listing. Every attempt after that: no answer within 300 to 400 seconds. Ever.
  • Official CLI, same folder, same account, same evening: 12.2 seconds cold. 1.7 seconds the second time.

Same account. Same 785 files. The problem was never the data. It was the accent. Traffic that behaves like Proton’s own apps sails through while the account was still, at that very moment, choking rclone-shaped traffic.

That test changed the question from “how do I fix rclone” to “why am I still talking to Proton through a middleman they’ve stopped taking calls from.”

The obvious fix I refused to do

The textbook answer is a staging copy. Sync the whole Drive to a local folder with some SDK-based tool, point restic at the folder, done. It’s clean and boring, and I even wrote the design for it.

Then I looked at the numbers. The Drive holds 45 GB today and will hold about 180 GB once the family photo library moves in. The VPS has 82 GB free. My NAS would need to hold a full mirror plus the restic repository it already stores, which is paying for the same data twice so one program can read it once a night.

I don’t have the space, and honestly, the idea offended me a little. Restic already deduplicates, compresses, and versions everything. Keeping a second full copy of the cloud just to give restic something to point at is like printing your email so you can scan it back in.

The other textbook answer is patience. Proton has confirmed a native Linux client is in development. It’s coming. It’s also not here, and my backups were failing tonight.

So, option three: build the thing that doesn’t exist yet.

The idea: a concierge with a clipboard

What restic actually needs from a filesystem, 99% of the time, is gossip. Who exists, how big they are, when they last changed. It reads actual file contents only for files whose size or mtime changed since the last snapshot. On a quiet day that’s a handful of files. On most days it’s none.

So the gateway is exactly that split:

Proton Drive API  (official SDK: event stream + targeted downloads)
        │
        ▼
metadata cache          ← SQLite file, a few MB. The clipboard.
        │
        ▼
WebDAV server on 127.0.0.1   ← listings answered from the clipboard,
        │                      file contents streamed live on request
        ▼
rclone mount (webdav backend, read-only)
        │
        ▼
restic → NAS + offsite     ← completely unchanged

One long-running process keeps a tiny SQLite database in sync with Proton using the SDK’s event stream, the same mechanism Proton’s own apps use. Ask Proton “anything new since event X?” once a minute, apply the answers, remember the new X. That’s the entire steady state.

When restic scans at night, every stat and every directory listing is answered from that local database. Proton sees zero traffic for the scan. When restic actually opens a file, the gateway fetches that one file through the SDK, which decrypts it and verifies its integrity, and streams it straight through to restic. Nothing is ever written to disk. The bytes just pass through on their way to the backup repository.

The full tree walk that used to happen every night now happens exactly once, at setup, to fill the clipboard.

And yes, rclone is still in the picture, which feels like inviting your ex to the wedding. But the crucial difference is which rclone backend. The protondrive backend is the broken part. The webdav backend is boring, battle-tested, and only ever talks to localhost. rclone never speaks to Proton again. It just doesn’t know it.

Build notes, or: the fun parts nobody documents

The SDK is genuinely good and genuinely young. Getting it running headless on a server involved a few discoveries you won’t find in a README.

The auth package isn’t on npm. The main SDK (@protontech/drive-sdk) installs fine. The account module that does login, session persistence and token refresh lives in the same repo under incubating/, published nowhere. You vendor it: download the repo tarball, copy one folder of TypeScript into your project. It has exactly one runtime dependency (ky). The "incubating" label means interfaces may move, so pin your SDK version and upgrade deliberately.

Proton ships raw TypeScript on npm. @protontech/crypto exports .ts source files directly. Your runtime has to transpile packages inside node_modules, which tsx happily does. Node 22 plus tsx runs the whole thing. No Bun required, even though Proton's own CLI uses Bun.

One patch, straight from Proton’s own kitchen. The crypto package imports openpgp/lightweight, a subpath that Node's ESM resolver refuses because it isn't in openpgp's exports map. Proton patches this themselves in their CLI build. Same two-line fix, applied automatically in a postinstall script.

Node 22 is missing three Uint8Array methods (toBase64, fromBase64, toHex) that the crypto code uses. They're a stage-3 TC39 proposal. The polyfill ships inside the crypto package itself, you just have to import it before anything else touches crypto.

Login without a browser is a solved problem. The SDK’s web-auth flow prints a sign-in URL, you open it on any other device, the server polls until you’ve confirmed. Each URL lives about ten minutes, so my login command just mints a fresh one in a loop until I get around to clicking it. The session lands in a file with 0600 permissions and refreshes its own tokens for months.

Identify yourself honestly. Proton’s SDK rules ask third-party tools to send external-drive-{name}@{version} as their app identifier and to behave event-based instead of scan-based. That's the whole social contract: don't pretend to be the official client, don't hammer the tree. Personal use is explicitly allowed, the whole repo is MIT. This gateway follows both rules to the letter, which is exactly why it doesn't get throttled.

The bootstrap trick worth stealing

Filling the metadata cache means walking every folder once. Walks take minutes, and the cloud doesn’t stop moving while you walk it. If a file changes in a folder you’ve already visited, you’d never know.

The fix is stolen from how Proton’s own CLI handles events: capture the event-stream position before you start walking. Ask the SDK for the current event ID first, save it, then do the full walk. When the steady-state loop starts later, it replays everything from that pre-walk baseline. Changes that happened mid-walk arrive as events and overwrite whatever the walk saw. No gap, no lock, no second pass.

The walk itself is resumable (every completed folder is marked in SQLite, so a crash continues where it stopped) and listens for the SDK’s RequestsThrottled signal to slow itself down politely. For my 3,069 files across 419 folders it took 4 minutes and 10 seconds, at a leisurely two API calls per folder, and never saw a single throttle signal.

The two bugs that ate my evening

Everything worked on the first try, which should have been the warning.

Listings through the mount were instant. Single file downloads were byte-perfect. Then I pointed restic at the mount for a real backup and watched it read exactly ten files and stop. No error. No timeout. The repository just stopped growing, and every subsequent download request, including ones from a plain curl, hung forever.

Bug one: aborted downloads jam the SDK’s download pool. A FUSE mount is a rude client by nature. rclone opens files, reads a bit, closes them early, reopens them. Every closed connection left the SDK’s download machinery holding a slot for a transfer nobody would ever finish. After a handful of those, the pool was full and the process was deaf. The fix is an AbortController per request, wired to the HTTP response's close event, so a client hanging up propagates all the way down to the SDK and frees the slot. If the client bails, the download bails. Basic hygiene, brutal failure mode.

That got me from ten files to… still stalling. Which is when it got interesting.

Bug two: the SDK’s seekable stream deadlocks under parallel load. I isolated it with curl, no rclone, no restic, no FUSE. Fifty full-file GETs, eight in parallel: flawless, every time. The same test with Range: bytes=0- headers: 32 of 70 requests hung forever.

Here’s the thing about that header. rclone almost never asks for a whole file. It asks for “everything from byte zero, please,” which is the same request wearing a trench coat. My server dutifully routed all Range requests through the SDK’s getSeekableStream(), and that code path, unlike the main downloader, falls over when several streams run at once. Proton's own apps apparently don't exercise it this way. I'll be filing the issue.

The fix was to stop using it entirely. Range requests now run through the same verified full-file download as everything else: stream from byte zero, slice out the requested window on the fly, abort the underlying download the moment the range is satisfied. For rclone’s bytes=0- opens, which is nearly all of them, that's literally a full read with zero overhead. Genuine mid-file seeks get logged so I'd notice if my assumption ever breaks. It hasn't.

After that fix: seventy parallel ranged requests, seventy clean responses. And the first time restic and the mount really went at it together, uninterrupted, all night long, nothing hung. Draw your own conclusions about what a stable relationship looks like.

The numbers

All measured on the same account, same VPS, same week. Medium doesn’t do tables, so here it is the honest way, one bout at a time. rclone’s protondrive backend in the left corner, the SDK gateway in the right.

Listing the 785-file iPhone folder. Before: 160 seconds on a good day, and most days never finished at all. After: 0.106 seconds.

Walking the entire tree, all 3,680 entries. Before: this walk, every night, was the reason we’re here. After: 3.8 seconds, with zero calls to Proton. Every answer came from the local cache.

First restic snapshot of that iPhone folder, 3.13 GiB across 785 files. Before: hung after ten files. After: 3 minutes 50 seconds, start to snapshot.

Second restic snapshot, nothing changed. Before: not applicable, we never got this far. After: 0.82 seconds and zero downloads from Proton. Read that one again.

First FULL snapshot, 3,069 files, 42.3 GiB. Before: never completed once in its life. After: 22 minutes 19 seconds.

**restic check on the finished repository: no errors found. Restore of a file plus an md5 comparison against the source:** bit-for-bit identical.

That 0.82 seconds is the entire point of the architecture. The nightly backup of a 45 GB cloud drive now costs less than a second of scanning plus the download of whatever actually changed that day, which most days rounds to nothing. Proton’s servers see one polite event-poll per minute and the occasional file fetch. Everyone’s happy, especially the rate limiter.

The initial full snapshot deserves its own sentence. All 42 GiB flowed Proton → SDK → WebDAV → FUSE → restic → encrypted repository in 22 minutes flat, averaging around 32 MB/s, decryption and integrity verification included. The chain that couldn’t list 785 files without dying now moves the entire Drive in the time it takes to watch a sitcom episode. Downloading everything once is unavoidable in any design, and it’s the one workload Proton’s infrastructure is visibly built for. Their own apps do the same thing on first sync.

What this is not

It’s read-only, on purpose. Nothing in this gateway can modify, delete, or upload a single byte on the Drive side, which is exactly the property you want in the tool your backup system depends on. It’s single-user. It’s not a sync client. It doesn’t do Proton’s photo albums yet (the SDK has the interfaces, I don’t have the need).

And it’s a bridge with a demolition date. Proton’s real Linux client will replace the mount, and their mandatory crypto migration in late 2026 means this code needs one planned maintenance window before then. When the official thing ships, I’ll delete mine with gratitude. The restic layer, the checks, the notification chain, none of that changes either way. That investment survives every scenario.

Run it yourself

Everything below is the complete, working source. You need Node 22+, the tsx runner, rclone 1.60+ with FUSE, and restic. It runs happily on a bare Alpine VPS in about 200 MB of RAM. The SDK's license is MIT and personal, non-commercial use is explicitly welcomed by Proton's own SDK guidelines. Replace the app name if you fork it, keep identifying as external-drive-something, and don't make me regret publishing the throttling section.

Project layout

proton-drive-gateway/
├── package.json
├── tsconfig.json
├── scripts/patch-crypto.mjs
├── types/uint8array-base64.d.ts
├── vendor/proton-drive-sdk-account/   ← vendored from Proton's repo (MIT)
├── src/
│   ├── config.ts
│   ├── logger.ts
│   ├── init.ts
│   ├── walk.ts
│   ├── api/            httpClient.ts, driveAccountAdapter.ts
│   ├── cache/          sqliteCache.ts, driveCryptoCacheAdapter.ts
│   ├── credentials/    interface.ts, parseCredentials.ts, fileStore.ts, credentials.ts
│   ├── events/         provider.ts, applyEvent.ts
│   ├── store/          metadataStore.ts, nodeMapper.ts
│   ├── webdav/         server.ts
│   └── commands/       login.ts, list.ts, bootstrap.ts, serve.ts
└── data/               ← session, SQLite caches (chmod 700)

Setup

mkdir -p proton-drive-gateway/{src,vendor,data,scripts,types}
cd proton-drive-gateway && chmod 700 data
# Vendor Proton's incubating account package (login/session/token refresh).
# It is MIT licensed but not published on npm, so we copy it from the repo.
wget -q https://github.com/ProtonDriveApps/sdk/archive/refs/heads/main.tar.gz
tar xzf main.tar.gz
cp -r sdk-main/incubating/account/js/src vendor/proton-drive-sdk-account
# One file of Proton's CLI is reused as-is (also MIT): the adapter that
# serialises cached crypto material. Copy it into our cache folder.
cp sdk-main/cli/src/cache/driveCryptoCacheAdapter.ts src/cache/
rm -rf sdk-main main.tar.gz
npm install          # postinstall applies the openpgp patch automatically

package.json

{
    "name": "proton-drive-gateway",
    "private": true,
    "version": "0.1.0",
    "description": "Read-only gateway: Proton Drive via the official SDK, exposed as local WebDAV for restic. No staging copy.",
    "type": "module",
    "scripts": {
        "postinstall": "node scripts/patch-crypto.mjs",
        "login": "tsx src/commands/login.ts",
        "list": "tsx src/commands/list.ts",
        "bootstrap": "tsx src/commands/bootstrap.ts",
        "serve": "tsx src/commands/serve.ts",
        "check-types": "tsc --noEmit"
    },
    "dependencies": {
        "@protontech/crypto": "2.0.0",
        "@protontech/drive-sdk": "0.19.1",
        "core-js": "^3.44.0",
        "ky": "^1.14.3"
    },
    "devDependencies": {
        "@types/node": "^22.15.21",
        "tsx": "^4.19.0",
        "typescript": "^5.9.3"
    }
}

Versions are pinned on purpose. The account package is labelled incubating by Proton; treat every upgrade as a small project, not an npm update.

scripts/patch-crypto.mjs

// Same fix Proton applies in their own CLI build (config/js/patches in the
// SDK repo): 'openpgp/lightweight' is not resolvable under Node ESM, the
// full build is. Idempotent; runs via npm postinstall.
import { readFileSync, writeFileSync } from 'node:fs';
//
const files = [
    'node_modules/@protontech/crypto/src/pmcrypto/openpgp.ts',
    'node_modules/@protontech/crypto/src/pmcrypto/pmcrypto.d.ts',
];
//
for (const file of files) {
    const before = readFileSync(file, 'utf8');
    const after = before.replaceAll('openpgp/lightweight', 'openpgp');
    if (after !== before) {
        writeFileSync(file, after);
        console.log(`patched: ${file}`);
    }
}

tsconfig.json

{
    "compilerOptions": {
        "target": "ESNext",
        "module": "ESNext",
        "moduleResolution": "bundler",
        "lib": ["ESNext", "DOM"],
        "strict": true,
        "skipLibCheck": true,
        "noEmit": true,
        "esModuleInterop": true,
        "resolveJsonModule": true,
        "allowImportingTsExtensions": true,
        "forceConsistentCasingInFileNames": true,
        "baseUrl": ".",
        "paths": {
            "proton-drive-sdk-account": ["./vendor/proton-drive-sdk-account/index.ts"],
            "proton-drive-sdk-account/*": ["./vendor/proton-drive-sdk-account/*"]
        }
    },
    "include": ["src/**/*.ts", "vendor/**/*.ts", "types/**/*.d.ts"],
    "exclude": ["**/node_modules/*", "vendor/proton-drive-sdk-account/api-core-types.ts"]
}

types/uint8array-base64.d.ts

// Typings for the TC39 arraybuffer-base64 proposal that @protontech/crypto
// relies on; the runtime implementation comes from core-js via
// '@protontech/crypto/polyfill'.
interface Uint8Array<TArrayBuffer extends ArrayBufferLike> {
    toBase64(options?: { alphabet?: 'base64' | 'base64url'; omitPadding?: boolean }): string;
    toHex(): string;
    setFromBase64(base64: string): { read: number; written: number };
    setFromHex(hex: string): { read: number; written: number };
}
//
interface Uint8ArrayConstructor {
    fromBase64(
        base64: string,
        options?: { alphabet?: 'base64' | 'base64url'; lastChunkHandling?: 'loose' | 'strict' | 'stop-before-partial' },
    ): Uint8Array<ArrayBuffer>;
    fromHex(hex: string): Uint8Array<ArrayBuffer>;
}

src/config.ts

import path from 'node:path';
import { fileURLToPath } from 'node:url';
//
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
//
export interface Config {
    /** Mandatory identifier for third-party tools: external-drive-{name}@{semver}. */
    appVersion: string;
    /** 'external-drive' for third-party tools (never spoof 'cli-drive'). */
    authClientId: string;
    sdkVersion: string;
    baseUrl: string;
    dataDir: string;
    clientUidPrefix: string;
}
//
export const config: Config = {
    appVersion: 'external-drive-gateway@0.1.0',
    authClientId: 'external-drive',
    sdkVersion: 'js@0.19.1',
    baseUrl: process.env.PROTON_DRIVE_BASE_URL || 'drive-api.proton.me',
    dataDir: process.env.GATEWAY_DATA_DIR || path.join(projectRoot, 'data'),
    clientUidPrefix: 'gateway',
};

src/logger.ts

import type { Logger } from '@protontech/drive-sdk';
//
const LEVELS = ['debug', 'info', 'warn', 'error'] as const;
type Level = (typeof LEVELS)[number];
//
const minLevel: Level = (process.env.GATEWAY_LOG_LEVEL as Level) || 'info';
//
function log(level: Level, name: string, message: string, args: unknown[]) {
    if (LEVELS.indexOf(level) < LEVELS.indexOf(minLevel)) {
        return;
    }
    const line = `${new Date().toISOString()} [${level.toUpperCase()}] ${name}: ${message}`;
    if (level === 'error' || level === 'warn') {
        console.error(line, ...args);
    } else {
        console.log(line, ...args);
    }
}
//
export function getLogger(name: string): Logger {
    return {
        debug: (message, ...args) => log('debug', name, message, args),
        info: (message, ...args) => log('info', name, message, args),
        warn: (message, ...args) => log('warn', name, message, args),
        error: (message, ...args) => log('error', name, message, args),
    };
}

src/credentials/interface.ts

import type { SessionInfo } from 'proton-drive-sdk-account';
//
export interface StoredCredentials {
    cachePassword?: string;
    userKeyPassword: string;
    session: SessionInfo;
}
//
export interface CredentialsStore {
    load(): Promise<StoredCredentials | null>;
    save(snapshot: StoredCredentials): Promise<void>;
    remove(): Promise<void>;
}

src/credentials/parseCredentials.ts

import type { StoredCredentials } from './interface';
//
export function parseStoredSnapshot(raw: string | null): StoredCredentials | null {
    if (raw == null || raw === '') {
        return null;
    }
    try {
        const session = JSON.parse(raw) as StoredCredentials;
        if (
            (session.cachePassword && typeof session.cachePassword !== 'string') ||
            !session.userKeyPassword ||
            typeof session.userKeyPassword !== 'string' ||
            !session.session?.uid ||
            typeof session.session.uid !== 'string' ||
            !session.session?.accessToken ||
            typeof session.session.accessToken !== 'string' ||
            (session.session?.refreshToken && typeof session.session.refreshToken !== 'string')
        ) {
            return null;
        }
        return {
            cachePassword: session.cachePassword,
            userKeyPassword: session.userKeyPassword,
            session: {
                uid: session.session.uid,
                accessToken: session.session.accessToken,
                refreshToken: session.session.refreshToken,
            },
        };
    } catch {
        return null;
    }
}

src/credentials/fileStore.ts

import { readFile, unlink, writeFile } from 'node:fs/promises';
import path from 'node:path';
//
import type { Logger } from '@protontech/drive-sdk';
//
import type { CredentialsStore, StoredCredentials } from './interface';
import { parseStoredSnapshot } from './parseCredentials';
//
const SESSION_FILENAME = 'auth-session.json';
//
export class FileSessionStore implements CredentialsStore {
    private readonly filePath: string;
//
    constructor(
        dataDir: string,
        private readonly logger: Logger,
    ) {
        this.filePath = path.join(dataDir, SESSION_FILENAME);
    }
//
    async load(): Promise<StoredCredentials | null> {
        let raw: string;
        try {
            raw = await readFile(this.filePath, 'utf8');
        } catch (err) {
            if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
                this.logger.debug(`Session file does not exist: ${this.filePath}`);
                return null;
            }
            throw err;
        }
        this.logger.debug(`Loading session from file: ${this.filePath}`);
        return parseStoredSnapshot(raw);
    }
//
    async save(snapshot: StoredCredentials): Promise<void> {
        this.logger.debug(`Saving session to file: ${this.filePath}`);
        await writeFile(this.filePath, JSON.stringify(snapshot), { mode: 0o600 });
    }
//
    async remove(): Promise<void> {
        this.logger.debug(`Removing session file: ${this.filePath}`);
        try {
            await unlink(this.filePath);
        } catch (err) {
            if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
                throw err;
            }
        }
    }
}

src/credentials/credentials.ts

import { randomBytes } from 'node:crypto';
//
import type { Logger } from '@protontech/drive-sdk';
//
import type { SessionCredentials, SessionInfo } from 'proton-drive-sdk-account';
//
import type { CredentialsStore } from './interface';
//
export class Credentials implements SessionCredentials {
    private cachePassword?: string;
    private userKeyPassword?: string;
    private sessionInfo?: SessionInfo;
//
    private readonly sessionInfoChangedCallbacks = new Set<() => void>();
//
    constructor(
        private readonly store: CredentialsStore,
        private readonly logger: Logger,
    ) {}
//
    on(_: 'sessionInfoChanged', callback: () => void): void {
        this.sessionInfoChangedCallbacks.add(callback);
    }
//
    isLoggedIn(): boolean {
        return !!this.userKeyPassword && !!this.sessionInfo;
    }
//
    getUserKeyPassword(): string | undefined {
        return this.userKeyPassword;
    }
//
    async getCachePassword(): Promise<string> {
        if (!this.cachePassword) {
            this.cachePassword = randomBytes(32).toString('base64');
            await this.persistCredentials();
        }
        return this.cachePassword;
    }
//
    get uid(): string | undefined {
        return this.sessionInfo?.uid;
    }
//
    get accessToken(): string | undefined {
        return this.sessionInfo?.accessToken;
    }
//
    get refreshToken(): string | undefined {
        return this.sessionInfo?.refreshToken;
    }
//
    async load(): Promise<void> {
        const raw = await this.store.load();
        if (!raw) {
            this.logger.debug('No session loaded');
            return;
        }
        this.cachePassword = raw.cachePassword;
        this.userKeyPassword = raw.userKeyPassword;
        this.sessionInfo = raw.session;
        this.notifySessionInfoChanged();
    }
//
    async setUserKeyPassword(userKeyPassword: string): Promise<void> {
        this.userKeyPassword = userKeyPassword;
        await this.persistCredentials();
        this.notifySessionInfoChanged();
    }
//
    async setSessionInfo(info: SessionInfo): Promise<void> {
        this.sessionInfo = info;
        await this.persistCredentials();
        this.notifySessionInfoChanged();
    }
//
    async signOut(): Promise<void> {
        this.logger.debug('Signing out');
        this.userKeyPassword = undefined;
        this.sessionInfo = undefined;
        await this.store.remove();
        this.notifySessionInfoChanged();
    }
//
    private async persistCredentials(): Promise<void> {
        if (!this.userKeyPassword || !this.sessionInfo) {
            return;
        }
        await this.store.save({
            cachePassword: this.cachePassword,
            userKeyPassword: this.userKeyPassword,
            session: this.sessionInfo,
        });
    }
//
    private notifySessionInfoChanged(): void {
        this.sessionInfoChangedCallbacks.forEach((callback) => callback());
    }
}

src/api/httpClient.ts

import type { ProtonDriveHTTPClientBlobRequest, ProtonDriveHTTPClientJsonRequest } from '@protontech/drive-sdk';
//
import type { ApiClient } from 'proton-drive-sdk-account';
//
export class HTTPClient {
    constructor(private readonly apiClient: ApiClient) {}
//
    async fetchJson(options: ProtonDriveHTTPClientJsonRequest): Promise<Response> {
        return this.apiClient.authenticatedRequest(options.url, {
            method: options.method,
            ...(options.json !== undefined ? { json: options.json } : {}),
            ...(options.body !== undefined && options.json === undefined ? { body: options.body } : {}),
            headers: options.headers,
            timeout: options.timeoutMs,
            signal: options.signal,
            throwHttpErrors: false,
        });
    }
//
    async fetchBlob(options: ProtonDriveHTTPClientBlobRequest): Promise<Response> {
        return this.apiClient.authenticatedRequest(options.url, {
            method: options.method,
            body: options.body,
            headers: options.headers,
            timeout: options.timeoutMs,
            signal: options.signal,
            throwHttpErrors: false,
        });
    }
}

src/api/driveAccountAdapter.ts

import type { ProtonDriveAccount, ProtonDriveAccountAddress } from '@protontech/drive-sdk';
//
import type { Addresses } from 'proton-drive-sdk-account';
//
export class DriveAccountAdapter implements ProtonDriveAccount {
    constructor(private readonly addresses: Addresses) {}
//
    getOwnPrimaryAddress(): Promise<ProtonDriveAccountAddress> {
        return this.addresses.getOwnPrimaryAddress();
    }
//
    getOwnAddresses(): Promise<ProtonDriveAccountAddress[]> {
        return this.addresses.getOwnAddresses();
    }
//
    getOwnAddress(emailOrAddressId: string): Promise<ProtonDriveAccountAddress> {
        return this.addresses.getOwnAddress(emailOrAddressId);
    }
//
    hasProtonAccount(email: string): Promise<boolean> {
        return this.addresses.hasProtonAccount(email);
    }
//
    getPublicKeys(email: string, forceRefresh?: boolean) {
        return this.addresses.getPublicKeys(email, forceRefresh);
    }
}

src/cache/sqliteCache.ts

The SDK wants two key-value caches (entities and crypto material). Proton’s CLI implements them on bun:sqlite; this is the same thing on node:sqlite. The crypto values are wrapped by driveCryptoCacheAdapter.ts, the file you copied from Proton's CLI during setup.

import { DatabaseSync } from 'node:sqlite';
//
import type { EntityResult, ProtonDriveCache } from '@protontech/drive-sdk';
//
export class SQLiteCache implements ProtonDriveCache<string> {
    private db: DatabaseSync;
//
    constructor(cacheFile: string) {
        this.db = new DatabaseSync(cacheFile);
        this.db.exec('CREATE TABLE IF NOT EXISTS entities (key TEXT PRIMARY KEY, value TEXT)');
        this.db.exec('CREATE TABLE IF NOT EXISTS entities_labels (label TEXT, key TEXT, UNIQUE (label, key))');
    }
//
    async clear() {
        this.db.exec('DELETE FROM entities');
        this.db.exec('DELETE FROM entities_labels');
    }
//
    async setEntity(key: string, data: string, tags?: string[]) {
        this.db.prepare('INSERT OR REPLACE INTO entities (key, value) VALUES (?, ?)').run(key, data);
        this.db.prepare('DELETE FROM entities_labels WHERE key = ?').run(key);
        for (const tag of tags || []) {
            this.db.prepare('INSERT OR REPLACE INTO entities_labels (label, key) VALUES (?, ?)').run(tag, key);
        }
    }
//
    async getEntity(key: string): Promise<string> {
        const result = this.db.prepare('SELECT value FROM entities WHERE key = ?').get(key) as
            | { value: string }
            | undefined;
        if (!result) {
            throw Error(`Entity ${key} not found`);
        }
        return result.value;
    }
//
    async *iterateEntities(keys: string[]): AsyncGenerator<EntityResult<string>> {
        for (const key of keys) {
            try {
                const value = await this.getEntity(key);
                yield { key, ok: true, value };
            } catch (error) {
                yield { key, ok: false, error: `${error}` };
            }
        }
    }
//
    async *iterateEntitiesByTag(tag: string): AsyncGenerator<EntityResult<string>> {
        const rows = this.db.prepare('SELECT key FROM entities_labels WHERE label = ?').all(tag) as {
            key: string;
        }[];
        yield* this.iterateEntities(rows.map((row) => row.key));
    }
//
    async removeEntities(keys: string[]) {
        for (const key of keys) {
            this.db.prepare('DELETE FROM entities WHERE key = ?').run(key);
            this.db.prepare('DELETE FROM entities_labels WHERE key = ?').run(key);
        }
    }
}

src/store/metadataStore.ts

The clipboard itself. One table of nodes, one table of key-value metadata, WAL mode so the WebDAV reads never block the event writes.

import { DatabaseSync } from 'node:sqlite';
//
export interface NodeRow {
    uid: string;
    parentUid: string | null;
    name: string;
    type: 'file' | 'folder';
    /** Decrypted (claimed) size in bytes; null when unknown. */
    size: number | null;
    /** Epoch ms. */
    mtime: number;
    revisionUid: string | null;
    mediaType: string | null;
    nameError: boolean;
    walked: boolean;
}
//
/**
 * Local mirror of the Proton tree structure (metadata only, no content).
 * Filled by the bootstrap walk, kept fresh by tree events, read by the
 * WebDAV server. Single writer (the gateway process itself); WAL so reads
 * and writes never block each other.
 */
export class MetadataStore {
    private db: DatabaseSync;
//
    constructor(dbFile: string) {
        this.db = new DatabaseSync(dbFile);
        this.db.exec('PRAGMA journal_mode = WAL');
        this.db.exec(`
            CREATE TABLE IF NOT EXISTS nodes (
                uid TEXT PRIMARY KEY,
                parent_uid TEXT,
                name TEXT NOT NULL,
                type TEXT NOT NULL,
                size INTEGER,
                mtime INTEGER NOT NULL,
                revision_uid TEXT,
                media_type TEXT,
                name_error INTEGER NOT NULL DEFAULT 0,
                walked INTEGER NOT NULL DEFAULT 0
            )
        `);
        this.db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_nodes_parent_name ON nodes (parent_uid, name)');
        this.db.exec('CREATE INDEX IF NOT EXISTS idx_nodes_parent ON nodes (parent_uid)');
        this.db.exec('CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)');
    }
//
    /**
     * Insert/update of a node. On a name conflict inside the same folder
     * (Proton allows duplicates that a POSIX tree cannot represent) the
     * node gets a deterministic uid-based suffix.
     */
    upsertNode(row: Omit<NodeRow, 'walked'>): void {
        const existing = this.db.prepare('SELECT walked FROM nodes WHERE uid = ?').get(row.uid) as
            | { walked: number }
            | undefined;
        const walked = existing ? existing.walked : 0;
//
        const insert = this.db.prepare(`
            INSERT INTO nodes (uid, parent_uid, name, type, size, mtime, revision_uid, media_type, name_error, walked)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            ON CONFLICT(uid) DO UPDATE SET
                parent_uid = excluded.parent_uid,
                name = excluded.name,
                type = excluded.type,
                size = excluded.size,
                mtime = excluded.mtime,
                revision_uid = excluded.revision_uid,
                media_type = excluded.media_type,
                name_error = excluded.name_error
        `);
        const args = (name: string) => [
            row.uid,
            row.parentUid,
            name,
            row.type,
            row.size,
            row.mtime,
            row.revisionUid,
            row.mediaType,
            row.nameError ? 1 : 0,
            walked,
        ];
        try {
            insert.run(...args(row.name));
        } catch (err) {
            if (!`${err}`.includes('UNIQUE')) {
                throw err;
            }
            insert.run(...args(`${row.name}~${row.uid.slice(-8)}`));
        }
    }
//
    /** Deletes a node including its entire subtree. */
    deleteNode(uid: string): void {
        this.db
            .prepare(
                `WITH RECURSIVE subtree(uid) AS (
                    SELECT uid FROM nodes WHERE uid = ?
                    UNION ALL
                    SELECT n.uid FROM nodes n JOIN subtree s ON n.parent_uid = s.uid
                )
                DELETE FROM nodes WHERE uid IN (SELECT uid FROM subtree)`,
            )
            .run(uid);
    }
//
    getNode(uid: string): NodeRow | null {
        const row = this.db.prepare('SELECT * FROM nodes WHERE uid = ?').get(uid);
        return row ? toNodeRow(row) : null;
    }
//
    getChildren(parentUid: string): NodeRow[] {
        return (this.db.prepare('SELECT * FROM nodes WHERE parent_uid = ? ORDER BY name').all(parentUid) as unknown[]).map(
            toNodeRow,
        );
    }
//
    /** Resolves a node by POSIX path ('/a/b/c'), segment by segment from the root. */
    resolvePath(rootUid: string, posixPath: string): NodeRow | null {
        const segments = posixPath.split('/').filter(Boolean);
        let current = this.getNode(rootUid);
        for (const segment of segments) {
            if (!current) {
                return null;
            }
            const next = this.db
                .prepare('SELECT * FROM nodes WHERE parent_uid = ? AND name = ?')
                .get(current.uid, segment);
            current = next ? toNodeRow(next) : null;
        }
        return current;
    }
//
    nextUnwalkedFolder(): NodeRow | null {
        const row = this.db.prepare("SELECT * FROM nodes WHERE type = 'folder' AND walked = 0 LIMIT 1").get();
        return row ? toNodeRow(row) : null;
    }
//
    markWalked(uid: string): void {
        this.db.prepare('UPDATE nodes SET walked = 1 WHERE uid = ?').run(uid);
    }
//
    /** For TreeRefresh: force a re-walk without throwing the tree away. */
    markAllUnwalked(): void {
        this.db.prepare("UPDATE nodes SET walked = 0 WHERE type = 'folder'").run();
    }
//
    setMeta(key: string, value: string): void {
        this.db.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)').run(key, value);
    }
//
    getMeta(key: string): string | null {
        const row = this.db.prepare('SELECT value FROM meta WHERE key = ?').get(key) as { value: string } | undefined;
        return row ? row.value : null;
    }
//
    stats(): { files: number; folders: number; totalSize: number; missingSize: number } {
        const row = this.db
            .prepare(
                `SELECT
                    SUM(CASE WHEN type = 'file' THEN 1 ELSE 0 END) AS files,
                    SUM(CASE WHEN type = 'folder' THEN 1 ELSE 0 END) AS folders,
                    SUM(CASE WHEN type = 'file' THEN COALESCE(size, 0) ELSE 0 END) AS totalSize,
                    SUM(CASE WHEN type = 'file' AND size IS NULL THEN 1 ELSE 0 END) AS missingSize
                FROM nodes`,
            )
            .get() as { files: number | null; folders: number | null; totalSize: number | null; missingSize: number | null };
        return {
            files: row.files ?? 0,
            folders: row.folders ?? 0,
            totalSize: row.totalSize ?? 0,
            missingSize: row.missingSize ?? 0,
        };
    }
}
//
function toNodeRow(raw: unknown): NodeRow {
    const row = raw as Record<string, unknown>;
    return {
        uid: row.uid as string,
        parentUid: (row.parent_uid as string | null) ?? null,
        name: row.name as string,
        type: row.type as 'file' | 'folder',
        size: (row.size as number | null) ?? null,
        mtime: row.mtime as number,
        revisionUid: (row.revision_uid as string | null) ?? null,
        mediaType: (row.media_type as string | null) ?? null,
        nameError: !!(row.name_error as number),
        walked: !!(row.walked as number),
    };
}

src/store/nodeMapper.ts

import { NodeType, type NodeEntity } from '@protontech/drive-sdk';
//
import type { NodeRow } from './metadataStore';
//
/**
 * Maps an SDK NodeEntity onto our metadata row.
 * - mtime: claimedModificationTime (what the uploading client reported,
 *   i.e. what users and restic expect), falling back to the server-side
 *   modificationTime.
 * - size: claimedSize = decrypted size (what will actually flow through
 *   the WebDAV GET); null when unknown, which we log, because a wrong
 *   length breaks the mount.
 */
export function mapNode(node: NodeEntity): Omit<NodeRow, 'walked'> {
    const isFolder = node.type === NodeType.Folder;
    const revision = node.activeRevision?.ok ? node.activeRevision.value : undefined;
//
    const mtimeDate = isFolder
        ? (node.folder?.claimedModificationTime ?? node.modificationTime)
        : (revision?.claimedModificationTime ?? node.modificationTime);
//
    return {
        uid: node.uid,
        parentUid: node.parentUid ?? null,
        name: node.name.ok ? node.name.value : `_unreadable-name_${node.uid.slice(-8)}`,
        type: isFolder ? 'folder' : 'file',
        size: isFolder ? null : (revision?.claimedSize ?? null),
        mtime: mtimeDate.getTime(),
        revisionUid: revision?.uid ?? null,
        mediaType: node.mediaType ?? null,
        nameError: !node.name.ok,
    };
}

src/events/provider.ts

import type { LatestEventIdProvider } from '@protontech/drive-sdk';
//
import type { MetadataStore } from '../store/metadataStore';
//
const META_PREFIX = 'lastEventId:';
//
/**
 * Checkpoint of the last processed event ID per scope, persisted in the
 * metadata store, so the SDK resumes across restarts exactly where we left
 * off (pattern borrowed from the official CLI's PersistedEventsProvider).
 */
export class StoreEventIdProvider implements LatestEventIdProvider {
    constructor(private readonly store: MetadataStore) {}
//
    async getLatestEventId(treeEventScopeId: string): Promise<string | null> {
        return this.store.getMeta(META_PREFIX + treeEventScopeId);
    }
//
    async setLatestEventId(treeEventScopeId: string, eventId: string): Promise<void> {
        this.store.setMeta(META_PREFIX + treeEventScopeId, eventId);
    }
}

src/events/applyEvent.ts

import { DriveEventType, type DriveEvent, type Logger, type ProtonDriveClient } from '@protontech/drive-sdk';
//
import type { MetadataStore } from '../store/metadataStore';
import { mapNode } from '../store/nodeMapper';
import type { StoreEventIdProvider } from './provider';
//
/**
 * Applies tree events to the metadata store and persists the event ID
 * afterwards (order matters: apply first, checkpoint second, so a crash
 * can at worst cause reprocessing, never a gap).
 *
 * TreeRefresh (rare, server requests a full resync) marks all folders as
 * unwalked; the walker picks that up.
 */
export function createEventHandler(
    sdk: ProtonDriveClient,
    store: MetadataStore,
    provider: StoreEventIdProvider,
    logger: Logger,
    onTreeRefreshNeeded?: () => void,
) {
    return async (event: DriveEvent): Promise<void> => {
        switch (event.type) {
            case DriveEventType.NodeCreated:
            case DriveEventType.NodeUpdated: {
                if (event.isTrashed) {
                    store.deleteNode(event.nodeUid);
                    logger.info(`Event ${event.type}: ${event.nodeUid} trashed, removed from cache`);
                } else {
                    const node = await sdk.getNode(event.nodeUid);
                    store.upsertNode(mapNode(node));
                    logger.debug(`Event ${event.type}: ${event.nodeUid} updated`);
                }
                break;
            }
            case DriveEventType.NodeDeleted: {
                store.deleteNode(event.nodeUid);
                logger.info(`Event NodeDeleted: ${event.nodeUid} removed from cache`);
                break;
            }
            case DriveEventType.TreeRefresh: {
                logger.warn('TreeRefresh event: full re-scan required, folders marked unwalked');
                store.markAllUnwalked();
                onTreeRefreshNeeded?.();
                break;
            }
            case DriveEventType.FastForward:
                break;
            case DriveEventType.TreeRemove:
                logger.warn(`TreeRemove event for scope ${event.treeEventScopeId}`);
                return;
            default:
                logger.warn(`Ignoring unknown event: ${JSON.stringify(event)}`);
                return;
        }
//
        if (event.eventId && event.eventId !== 'none') {
            await provider.setLatestEventId(event.treeEventScopeId, event.eventId);
        }
    };
}

src/walk.ts

import { SDKEvent, type Logger, type ProtonDriveClient } from '@protontech/drive-sdk';
//
import type { MetadataStore } from './store/metadataStore';
import { mapNode } from './store/nodeMapper';
//
const MAX_RETRIES = 8;
//
/**
 * Walks all not-yet-walked folders and fills the metadata store.
 * Resumable: every completed folder is marked; after an interruption the
 * walk continues where it stopped. Respects the SDK's throttle signals
 * with exponential backoff.
 */
export async function walkTree(sdk: ProtonDriveClient, store: MetadataStore, logger: Logger): Promise<void> {
    let throttled = false;
    const offThrottled = sdk.onMessage(SDKEvent.RequestsThrottled, () => {
        throttled = true;
        logger.warn('Proton signals throttling; walker slowing down');
    });
    const offUnthrottled = sdk.onMessage(SDKEvent.RequestsUnthrottled, () => {
        throttled = false;
        logger.info('Throttling lifted; walker resuming normal pace');
    });
//
    let foldersDone = 0;
    try {
        let folder;
        while ((folder = store.nextUnwalkedFolder())) {
            await withRetries(logger, `folder "${folder.name}"`, async () => {
                for await (const child of sdk.iterateFolderChildren(folder!.uid)) {
                    store.upsertNode(mapNode(child));
                }
            });
            store.markWalked(folder.uid);
            foldersDone++;
//
            if (foldersDone % 20 === 0) {
                const s = store.stats();
                logger.info(
                    `Walk progress: ${foldersDone} folders walked; ${s.files} files, ${s.folders} folders, ${(s.totalSize / 1e9).toFixed(2)} GB`,
                );
            }
            if (throttled) {
                await sleep(15_000);
            }
        }
    } finally {
        offThrottled();
        offUnthrottled();
    }
//
    const s = store.stats();
    logger.info(
        `Walk done: ${s.files} files, ${s.folders} folders, ${(s.totalSize / 1e9).toFixed(2)} GB` +
            (s.missingSize ? ` — WARNING: ${s.missingSize} files without a known size` : ''),
    );
}
//
async function withRetries(logger: Logger, what: string, fn: () => Promise<void>): Promise<void> {
    for (let attempt = 1; ; attempt++) {
        try {
            return await fn();
        } catch (err) {
            if (attempt >= MAX_RETRIES) {
                throw err;
            }
            const delayMs = Math.min(2 ** attempt * 1000, 120_000);
            logger.warn(`Error at ${what} (attempt ${attempt}/${MAX_RETRIES}), retry in ${delayMs / 1000}s: ${err}`);
            await sleep(delayMs);
        }
    }
}
//
function sleep(ms: number): Promise<void> {
    return new Promise((resolve) => setTimeout(resolve, ms));
}

src/init.ts

The wiring loom. Everything meets here once and gets handed out to the commands.

// Node 22 lacks the Uint8Array.fromBase64/toBase64/toHex methods that
// @protontech/crypto uses.
import '@protontech/crypto/polyfill';
//
import { randomBytes } from 'node:crypto';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
//
import { CryptoProxy } from '@protontech/crypto';
import { Api as CryptoApi } from '@protontech/crypto/proxy/endpoint/api.ts';
import { OpenPGPCryptoWithCryptoProxy, ProtonDriveClient } from '@protontech/drive-sdk';
//
import { ApiClient, initAccount } from 'proton-drive-sdk-account';
//
import { DriveAccountAdapter } from './api/driveAccountAdapter';
import { HTTPClient } from './api/httpClient';
import { DriveCryptoCacheAdapter } from './cache/driveCryptoCacheAdapter';
import { SQLiteCache } from './cache/sqliteCache';
import { config } from './config';
import { Credentials } from './credentials/credentials';
import { FileSessionStore } from './credentials/fileStore';
import { StoreEventIdProvider } from './events/provider';
import { getLogger } from './logger';
import { MetadataStore } from './store/metadataStore';
//
export async function init() {
    await mkdir(config.dataDir, { recursive: true, mode: 0o700 });
//
    const logger = getLogger('gateway');
//
    CryptoApi.init({});
    CryptoProxy.setEndpoint(new CryptoApi(), (endpoint) => endpoint.clearKeyStore());
    const openPGPCryptoModule = new OpenPGPCryptoWithCryptoProxy(CryptoProxy);
//
    const credentials = new Credentials(new FileSessionStore(config.dataDir, logger), logger);
    await credentials.load();
//
    const apiClient = new ApiClient({
        baseUrl: config.baseUrl,
        appVersion: config.appVersion,
        credentials,
        logger: getLogger('api'),
        headers: {
            'x-pm-drive-sdk-version': config.sdkVersion,
        },
    });
//
    const { auth, srp, addresses } = await initAccount({
        authClientId: config.authClientId,
        apiClient,
        credentials,
        cryptoProxy: CryptoProxy,
        logger: getLogger('account'),
    });
//
    const clientUid = await getOrGenerateClientUid();
//
    const store = new MetadataStore(path.join(config.dataDir, 'metadata.sqlite'));
    const eventIdProvider = new StoreEventIdProvider(store);
//
    const sdk = new ProtonDriveClient({
        config: {
            baseUrl: config.baseUrl,
            clientUid,
        },
        httpClient: new HTTPClient(apiClient),
        entitiesCache: new SQLiteCache(path.join(config.dataDir, 'cache-entities.sqlite')),
        cryptoCache: new DriveCryptoCacheAdapter(new SQLiteCache(path.join(config.dataDir, 'cache-crypto.sqlite'))),
        openPGPCryptoModule,
        account: new DriveAccountAdapter(addresses),
        srpModule: srp,
        latestEventIdProvider: eventIdProvider,
    });
//
    return { logger, credentials, auth, addresses, sdk, store, eventIdProvider };
}
//
async function getOrGenerateClientUid(): Promise<string> {
    const file = path.join(config.dataDir, 'clientUid.json');
    try {
        const stored = JSON.parse(await readFile(file, 'utf8')) as { clientUid?: string };
        if (stored.clientUid) {
            return stored.clientUid;
        }
    } catch {
        // First run or corrupt file: generate a new UID.
    }
    const clientUid = `${config.clientUidPrefix}-${randomBytes(8).toString('hex')}`;
    await writeFile(file, JSON.stringify({ clientUid }), { mode: 0o600 });
    return clientUid;
}

src/webdav/server.ts

The heart of the thing, scars included. Note the AbortController and the Range handling: those two blocks are bug one and bug two from the story above, in fix form.

import http from 'node:http';
import { Writable } from 'node:stream';
//
import type { Logger, ProtonDriveClient } from '@protontech/drive-sdk';
//
import type { MetadataStore, NodeRow } from '../store/metadataStore';
//
/**
 * Read-only WebDAV server for restic/rclone:
 * - PROPFIND/HEAD answered entirely from the local metadata store (zero
 *   Proton calls);
 * - GET streams on demand via the SDK downloader (decrypted + verified);
 * - Range requests served through the same verified download route;
 * - every write method refused.
 * Binds to localhost only.
 */
export function createWebdavServer(
    sdk: ProtonDriveClient,
    store: MetadataStore,
    rootUid: string,
    logger: Logger,
): http.Server {
    return http.createServer(async (req, res) => {
        try {
            await handle(sdk, store, rootUid, logger, req, res);
        } catch (err) {
            logger.error(`WebDAV ${req.method} ${req.url} failed: ${err}`);
            if (!res.headersSent) {
                res.writeHead(500);
            }
            res.end();
        }
    });
}
//
async function handle(
    sdk: ProtonDriveClient,
    store: MetadataStore,
    rootUid: string,
    logger: Logger,
    req: http.IncomingMessage,
    res: http.ServerResponse,
): Promise<void> {
    const method = req.method ?? '';
    const path = decodeURIComponent((req.url ?? '/').split('?')[0]);
//
    if (method === 'OPTIONS') {
        res.writeHead(204, { DAV: '1', Allow: 'OPTIONS, PROPFIND, GET, HEAD' });
        res.end();
        return;
    }
//
    if (!['PROPFIND', 'GET', 'HEAD'].includes(method)) {
        res.writeHead(405, { Allow: 'OPTIONS, PROPFIND, GET, HEAD' });
        res.end();
        return;
    }
//
    const node = store.resolvePath(rootUid, path);
    if (!node) {
        res.writeHead(404);
        res.end();
        return;
    }
//
    if (method === 'PROPFIND') {
        const depth = req.headers.depth ?? 'infinity';
        if (depth !== '0' && depth !== '1') {
            res.writeHead(403);
            res.end();
            return;
        }
        const entries: { href: string; node: NodeRow }[] = [{ href: path, node }];
        if (depth === '1' && node.type === 'folder') {
            for (const child of store.getChildren(node.uid)) {
                entries.push({ href: joinPath(path, child.name), node: child });
            }
        }
        const xml = renderMultistatus(entries);
        res.writeHead(207, { 'Content-Type': 'application/xml; charset=utf-8' });
        res.end(xml);
        return;
    }
//
    // GET/HEAD
    if (node.type === 'folder') {
        res.writeHead(405, { Allow: 'OPTIONS, PROPFIND' });
        res.end();
        return;
    }
//
    const baseHeaders: http.OutgoingHttpHeaders = {
        'Last-Modified': new Date(node.mtime).toUTCString(),
        'Content-Type': node.mediaType ?? 'application/octet-stream',
        ...(node.revisionUid ? { ETag: `"${node.revisionUid}"` } : {}),
        'Accept-Ranges': 'bytes',
    };
//
    if (method === 'HEAD') {
        res.writeHead(200, { ...baseHeaders, ...(node.size != null ? { 'Content-Length': node.size } : {}) });
        res.end();
        return;
    }
//
    // Crucial: when the client (rclone/restic) closes the connection before
    // the end of the body, the SDK download must be aborted too. Otherwise
    // the SDK's download concurrency pool fills up with zombie transfers
    // and everything blocks, permanently.
    const abort = new AbortController();
    res.once('close', () => {
        if (!res.writableFinished) {
            abort.abort();
        }
    });
//
    const downloader = await sdk.getFileDownloader(node.uid, abort.signal);
    const size = downloader.getClaimedSizeInBytes() ?? node.size ?? undefined;
//
    // Ranges deliberately run through the same downloadToStream route as
    // full GETs: the SDK's seekable stream deadlocked under parallel load
    // (32/70 timeouts at 8 concurrent opens when I tested it). We stream
    // from byte 0, cut out the requested window, and abort the download as
    // soon as the range is satisfied. rclone almost always opens with
    // bytes=0- (which IS a full read), so this costs nothing; genuine
    // mid-file seeks are logged to keep that assumption honest.
    const range = parseRange(req.headers.range, size);
    if (range) {
        logger.info(`GET ${path} bytes=${range.start}-${range.end}${range.start > 0 ? ' (mid-file seek)' : ''}`);
        res.writeHead(206, {
            ...baseHeaders,
            'Content-Length': range.end - range.start + 1,
            'Content-Range': `bytes ${range.start}-${range.end}/${size ?? '*'}`,
        });
        let offset = 0;
        let satisfied = false;
        const sink = new WritableStream<Uint8Array>({
            write: async (chunk) => {
                if (satisfied || res.destroyed) {
                    return;
                }
                const chunkStart = offset;
                offset += chunk.length;
                const from = Math.max(range.start - chunkStart, 0);
                const to = Math.min(range.end + 1 - chunkStart, chunk.length);
                if (to > from) {
                    if (!res.write(chunk.subarray(from, to))) {
                        await new Promise((resolve) => res.once('drain', resolve));
                    }
                }
                if (offset > range.end) {
                    satisfied = true;
                    abort.abort();
                }
            },
        });
        const controller = downloader.downloadToStream(sink);
        try {
            await controller.completion();
        } catch (err) {
            if (!satisfied && !abort.signal.aborted) {
                throw err;
            }
        }
        res.end();
        return;
    }
//
    logger.info(`GET ${path} (${size ?? 'unknown size'} bytes)`);
    res.writeHead(200, { ...baseHeaders, ...(size != null ? { 'Content-Length': size } : {}) });
    // downloadToStream decrypts AND verifies integrity — the reason we
    // prefer this route over the seekable stream for full reads.
    const controller = downloader.downloadToStream(Writable.toWeb(res) as WritableStream);
    try {
        await controller.completion();
    } catch (err) {
        if (!abort.signal.aborted) {
            throw err;
        }
        logger.debug(`GET ${path} aborted by client`);
        return;
    }
    res.end();
}
//
function joinPath(base: string, name: string): string {
    return (base.endsWith('/') ? base : base + '/') + name;
}
//
function parseRange(header: string | undefined, size: number | undefined): { start: number; end: number } | null {
    if (!header) {
        return null;
    }
    const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
    if (!match || (!match[1] && !match[2])) {
        return null;
    }
    if (!match[1]) {
        // suffix range 'bytes=-N': last N bytes; not servable without a known size
        if (size == null) {
            return null;
        }
        const n = parseInt(match[2], 10);
        return { start: Math.max(0, size - n), end: size - 1 };
    }
    const start = parseInt(match[1], 10);
    const end = match[2] ? parseInt(match[2], 10) : size != null ? size - 1 : NaN;
    if (Number.isNaN(end) || end < start) {
        return null;
    }
    return { start, end };
}
//
function renderMultistatus(entries: { href: string; node: NodeRow }[]): string {
    const responses = entries
        .map(({ href, node }) => {
            const isFolder = node.type === 'folder';
            const hrefOut = encodeHref(isFolder && !href.endsWith('/') ? href + '/' : href);
            return `<D:response>
<D:href>${hrefOut}</D:href>
<D:propstat><D:prop>
<D:displayname>${escapeXml(node.name)}</D:displayname>
<D:resourcetype>${isFolder ? '<D:collection/>' : ''}</D:resourcetype>
${isFolder ? '' : `<D:getcontentlength>${node.size ?? 0}</D:getcontentlength>`}
<D:getlastmodified>${new Date(node.mtime).toUTCString()}</D:getlastmodified>
${node.revisionUid ? `<D:getetag>"${escapeXml(node.revisionUid)}"</D:getetag>` : ''}
${node.mediaType && !isFolder ? `<D:getcontenttype>${escapeXml(node.mediaType)}</D:getcontenttype>` : ''}
</D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat>
</D:response>`;
        })
        .join('\n');
    return `<?xml version="1.0" encoding="utf-8"?>\n<D:multistatus xmlns:D="DAV:">\n${responses}\n</D:multistatus>`;
}
//
function encodeHref(path: string): string {
    return path
        .split('/')
        .map((segment) => encodeURIComponent(segment))
        .join('/');
}
//
function escapeXml(value: string): string {
    return value
        .replaceAll('&', '&amp;')
        .replaceAll('<', '&lt;')
        .replaceAll('>', '&gt;')
        .replaceAll('"', '&quot;');
}

src/commands/login.ts

import { writeFile } from 'node:fs/promises';
import path from 'node:path';
//
import { config } from '../config';
import { init } from '../init';
//
// Each sign-in URL is valid for ~10 minutes; after that this flow starts a
// fresh attempt with a new URL on its own (up to ~4 hours). The current URL
// is also always available in data/signin-url.txt.
const MAX_ATTEMPTS = 24;
//
async function main() {
    const { auth, addresses, credentials } = await init();
//
    if (credentials.isLoggedIn()) {
        const address = await addresses.getOwnPrimaryAddress();
        console.log(`Already signed in as: ${address.email}`);
        return;
    }
//
    const urlFile = path.join(config.dataDir, 'signin-url.txt');
    for (let attempt = 1; ; attempt++) {
        try {
            await auth.authViaWeb(async (signInUrl) => {
                console.log(`\n[attempt ${attempt}/${MAX_ATTEMPTS}] Open this URL on another device (laptop/phone):\n`);
                console.log(`    ${signInUrl}\n`);
                console.log('This machine waits for the sign-in to complete (max 10 minutes per URL)...');
                await writeFile(urlFile, signInUrl + '\n', { mode: 0o600 });
            });
            break;
        } catch (err) {
            if (attempt >= MAX_ATTEMPTS || !`${err}`.includes('timed out')) {
                throw err;
            }
            console.log('URL expired; retrying with a fresh one...');
        }
    }
    await writeFile(urlFile, '');
//
    const address = await addresses.getOwnPrimaryAddress();
    console.log(`\nSigned in as: ${address.email}`);
    console.log('Session stored in data/auth-session.json (0600).');
}
//
main().then(
    () => process.exit(0),
    (err) => {
        console.error('Login failed:', err);
        process.exit(1);
    },
);

src/commands/list.ts

import { init } from '../init';
//
// Smoke test: list the children of the My Files root through the whole
// stack (auth, crypto, cache, SDK). If this works, the plumbing works.
async function main() {
    const { sdk, credentials } = await init();
//
    if (!credentials.isLoggedIn()) {
        console.error('Not signed in. Run first: npm run login');
        process.exit(1);
    }
//
    const started = Date.now();
    const root = await sdk.getMyFilesRootFolder();
    console.log(`Root: uid=${root.uid} name=${root.name}`);
//
    let count = 0;
    for await (const child of sdk.iterateFolderChildren(root.uid)) {
        const name = child.name.ok ? child.name.value : `<unreadable: ${child.uid}>`;
        console.log(`  ${child.type}\t${name}`);
        count++;
    }
    console.log(`\n${count} items in ${((Date.now() - started) / 1000).toFixed(1)}s`);
}
//
main().then(
    () => process.exit(0),
    (err) => {
        console.error('List failed:', err);
        process.exit(1);
    },
);

src/commands/bootstrap.ts

import { DriveEventType } from '@protontech/drive-sdk';
//
import { init } from '../init';
import { mapNode } from '../store/nodeMapper';
import { walkTree } from '../walk';
//
// One-time (resumable) metadata bootstrap:
// 1. fetch and store the root;
// 2. record the baseline event ID BEFORE the walk (iterateEvents without a
//    lastEventId yields a FastForward carrying the current ID), so changes
//    made during the walk will still arrive later via events;
// 3. walk the full tree into the metadata store.
async function main() {
    const { sdk, store, eventIdProvider, credentials, logger } = await init();
//
    if (!credentials.isLoggedIn()) {
        console.error('Not signed in. Run first: npm run login');
        process.exit(1);
    }
//
    const started = Date.now();
    const root = await sdk.getMyFilesRootFolder();
    store.upsertNode({ ...mapNode(root), parentUid: null });
    store.setMeta('rootUid', root.uid);
    store.setMeta('treeEventScopeId', root.treeEventScopeId);
    logger.info(`Root: ${root.uid} (scope ${root.treeEventScopeId})`);
//
    if (!(await eventIdProvider.getLatestEventId(root.treeEventScopeId))) {
        for await (const event of sdk.iterateEvents(root.treeEventScopeId)) {
            if (event.type === DriveEventType.FastForward) {
                await eventIdProvider.setLatestEventId(root.treeEventScopeId, event.eventId);
                logger.info(`Baseline event ID recorded: ${event.eventId}`);
            }
            break;
        }
    }
//
    await walkTree(sdk, store, logger);
//
    store.setMeta('bootstrapCompletedAt', new Date().toISOString());
    const s = store.stats();
    console.log(
        `\nBootstrap done in ${((Date.now() - started) / 1000).toFixed(1)}s: ` +
            `${s.files} files, ${s.folders} folders, ${(s.totalSize / 1e9).toFixed(2)} GB of metadata cached.`,
    );
    if (s.missingSize > 0) {
        console.log(`WARNING: ${s.missingSize} files without a claimedSize — they get special handling in the WebDAV layer.`);
    }
}
//
main().then(
    () => process.exit(0),
    (err) => {
        console.error('Bootstrap failed:', err);
        process.exit(1);
    },
);

src/commands/serve.ts

import { init } from '../init';
import { createEventHandler } from '../events/applyEvent';
import { walkTree } from '../walk';
import { createWebdavServer } from '../webdav/server';
//
const PORT = parseInt(process.env.GATEWAY_WEBDAV_PORT || '8021', 10);
const HOST = '127.0.0.1';
const EVENT_POLL_MS = parseInt(process.env.GATEWAY_EVENT_POLL_SECONDS || '60', 10) * 1000;
//
// Daemon: keeps the metadata cache fresh via events and serves the tree as
// read-only WebDAV on localhost. An interrupted bootstrap walk is finished
// first.
async function main() {
    const { sdk, store, eventIdProvider, credentials, logger } = await init();
//
    if (!credentials.isLoggedIn()) {
        console.error('Not signed in. Run first: npm run login');
        process.exit(1);
    }
    const rootUid = store.getMeta('rootUid');
    const scopeId = store.getMeta('treeEventScopeId');
    if (!rootUid || !scopeId) {
        console.error('No metadata cache found. Run first: npm run bootstrap');
        process.exit(1);
    }
//
    let walkRequested = store.nextUnwalkedFolder() !== null;
    let walking = false;
    const runWalkIfNeeded = async () => {
        if (!walkRequested || walking) {
            return;
        }
        walkRequested = false;
        walking = true;
        try {
            logger.info('Unwalked folders found; (re)starting walk');
            await walkTree(sdk, store, logger);
        } catch (err) {
            logger.error(`Walk failed, retrying on next poll: ${err}`);
            walkRequested = true;
        } finally {
            walking = false;
        }
    };
//
    const handler = createEventHandler(sdk, store, eventIdProvider, logger, () => {
        walkRequested = true;
    });
//
    let polling = false;
    const pollEvents = async () => {
        if (polling) {
            return;
        }
        polling = true;
        try {
            const lastEventId = await eventIdProvider.getLatestEventId(scopeId);
            for await (const event of sdk.iterateEvents(scopeId, lastEventId ?? undefined)) {
                await handler(event);
            }
        } catch (err) {
            logger.warn(`Event poll failed (next attempt in ${EVENT_POLL_MS / 1000}s): ${err}`);
        } finally {
            polling = false;
        }
        await runWalkIfNeeded();
    };
//
    await runWalkIfNeeded();
    await pollEvents();
    const pollTimer = setInterval(pollEvents, EVENT_POLL_MS);
//
    const server = createWebdavServer(sdk, store, rootUid, logger);
    server.listen(PORT, HOST, () => {
        const s = store.stats();
        logger.info(
            `WebDAV gateway listening on http://${HOST}:${PORT}/ — ` +
                `${s.files} files, ${s.folders} folders, ${(s.totalSize / 1e9).toFixed(2)} GB (event poll every ${EVENT_POLL_MS / 1000}s)`,
        );
    });
//
    const shutdown = () => {
        logger.info('Shutting down...');
        clearInterval(pollTimer);
        server.close(() => process.exit(0));
        setTimeout(() => process.exit(0), 5000).unref();
    };
    process.on('SIGINT', shutdown);
    process.on('SIGTERM', shutdown);
}
//
main().catch((err) => {
    console.error('Serve failed:', err);
    process.exit(1);
});

Bringing it online

npm run login       # prints a sign-in URL, open it on any device, done once
npm run bootstrap   # one-time metadata walk (resumable, took me 4m10s)
npm run serve       # the daemon: event sync + WebDAV on 127.0.0.1:8021
# rclone: one boring webdav remote pointing at localhost
rclone config create proton-gateway webdav \
    url http://127.0.0.1:8021 vendor other
# the mount restic will read
mkdir -p /mnt/proton-gateway
rclone mount proton-gateway: /mnt/proton-gateway \
    --read-only --vfs-cache-mode off --allow-other --daemon
# and from here it's just restic being restic
restic backup /mnt/proton-gateway --no-scan

Run serve under whatever supervision you like (systemd unit, OpenRC service, a docker-compose with restart: unless-stopped). It's a single stateless-ish process; kill it and restart it whenever, the event checkpoint means it picks up exactly where it left off.

Sources and credits

  • Proton Drive SDK (MIT) — the SDK, the incubating account package, and the CLI whose source doubles as the only documentation you need.
  • Proton’s SDK announcement and the June 2026 update confirming the native Linux client is on its way.
  • rclone — still doing an honest day’s work here, just against localhost now.
  • restic — the only component of this story that never once misbehaved.

The usual note: this is a personal tool published for personal use, exactly as Proton’s SDK guidelines permit. It can’t write, so the worst it can do to your Drive is read it beautifully. My deployment runs on the same Alpine VPS as everything else I’ve written about; your paths may vary. And if Proton is reading this: the seekable stream issue is real, reproducible, and yours whenever you want it.


메타데이터
post_id
97052e4e49cd
slug
proton-drive-kept-throttling-my-backups-so-i-built-the-linux-client-proton-hasnt-shipped-yet-97052e4e49cd
url
https://medium.com/@rfrancocantero/proton-drive-kept-throttling-my-backups-so-i-built-the-linux-client-proton-hasnt-shipped-yet-97052e4e49cd
canonical_url
https://medium.com/@rfrancocantero/proton-drive-kept-throttling-my-backups-so-i-built-the-linux-client-proton-hasnt-shipped-yet-97052e4e49cd
author_url
https://medium.com/@rfrancocantero
status
ok
fetched_at
2026-07-09 03:40:04