# GSoC 2026 Proposal Draft – Idea 9: LAN Sync – Ahmed Idani

**URL:** <https://discourse.joplinapp.org/t/gsoc-2026-proposal-draft-idea-9-lan-sync-ahmed-idani/49222>\
**Category:** GSoC Proposal Drafts\
**Created:** [19 March 2026 00:09 UTC](https://discourse.joplinapp.org/t/gsoc-2026-proposal-draft-idea-9-lan-sync-ahmed-idani/49222 "2026-03-19T00:09:44Z")\
**Posts on this page:** 6\
**Page:** 1

<div class="post-metadata">

**Author:** ![Idani\_Ahmed](https://avatars.discourse-cdn.com/v4/letter/i/ebca7d/32.png) [@Idani\_Ahmed](https://discourse.joplinapp.org/u/Idani_Ahmed)\
**Post date:** [19 March 2026 00:09 UTC](https://discourse.joplinapp.org/t/gsoc-2026-proposal-draft-idea-9-lan-sync-ahmed-idani/49222/1 "2026-03-19T00:09:44Z")

</div>

# GSoC 2026 Proposal Draft – Idea 9: LAN Sync – Ahmed Idani

## Links

- **Project idea** : [Idea #9: LAN Sync](https://github.com/joplin/gsoc/blob/main/ideas.md#9-lan-sync)
- **GitHub** : [Ahmed-Idani](https://github.com/Ahmed-Idani)
- **Forum introduction** : [Introducing Idani\_Ahmed](https://discourse.joplinapp.org/t/welcome-to-gsoc-2026-with-joplin/48974/15)
- **Pull requests submitted to Joplin** (all merged):
  - [#13994](https://github.com/laurent22/joplin/pull/13994) — Fix insert time command not respecting locale settings
  - [#14530](https://github.com/laurent22/joplin/pull/14530) — Fix AppImage relaunch failure on Linux
  - [#14649](https://github.com/laurent22/joplin/pull/14649) — Fix sync crash when switching from Joplin Server to WebDAV

- **Other experience** : Part-time software engineer at [Bakchich Ads](https://ba9chich.com/en) (Next.js + Strapi + PayPal), [real-time voting system](https://medium.com/@idaniahmed72/building-a-real-time-voting-system-bf83ff07013a) (Express + MongoDB, 500+ concurrent users), infrastructure engineering at [Open Organic Robotics](https://oorb.io/) (Azure AKS, now in production)

## 1. Introduction

I'm Ahmed Idani, a 4th-year engineering student at [INSAT](https://insat.rnu.tn/) (National Institute of Applied Science and Technologies) in Tunis, studying Computer Networks and Telecommunications. Networking protocols are something I study in depth as part of my degree, not a side interest. I hold a CCNA certification, and my coursework covers TCP/IP, distributed systems, network security, and cloud computing.

I've been contributing to open source through Joplin since late 2025. I started with a locale handling fix ([#13994](https://github.com/laurent22/joplin/pull/13994)), then fixed an Electron relaunch bug on Linux ([#14530](https://github.com/laurent22/joplin/pull/14530)), and most recently fixed a sync crash affecting users who switch from Joplin Server to WebDAV ([#14649](https://github.com/laurent22/joplin/pull/14649)). That last one required me to understand how sync targets are registered, how `ShareService` interacts with the `Synchronizer`, and how `SyncTargetRegistry` decides what each target supports. That's exactly the area this project extends.

Outside of Joplin, I've worked on infrastructure and networking professionally. At [Sotetel](https://www.sotetel.tn/en/) I assisted with firewall migrations (Cisco to Fortinet) and Layer 2 networking. At [Open Organic Robotics](https://oorb.io/) I was part of the v1 team as an intern, where I engineered multi-tenant isolation on Azure AKS with per-user namespaces, Calico network policies, and RBAC for 200+ concurrent users. The platform is now in production and was tested by engineers from Google and Meta at Deep Learning Indaba 2025. That experience with network-level isolation is directly relevant to designing secure peer communication on a shared LAN. I also work part-time as a software engineer at [Bakchich](https://ba9chich.com/en), where I shipped an ads platform with PayPal and Flouci payment integrations using Next.js and Strapi.

## 2. Project Summary

**Problem** : Every sync option in Joplin needs either the internet or a manually configured server. There's no way to sync two devices directly over a local network without external tools. The File System sync target works over a network share, but it requires manual setup, has no device discovery, and doesn't work on mobile.

**What I'll implement** : A new sync target called "LAN Sync" that lets two Joplin instances on the same Wi-Fi discover each other automatically, pair with a PIN, and sync directly. No cloud, no accounts, no external software. One device acts as a lightweight HTTPS host, the other syncs against it using the existing `Synchronizer`.

**Expected outcome** : A user opens Joplin on their desktop, enables "LAN Sync" as host. They open Joplin on their Android phone, it discovers the desktop, they confirm a PIN, and their notes sync. After sync, both devices have identical data and work independently offline.

**Out of scope** : Multi-device mesh sync (a second guest could technically sync to the same host, but testing and validating conflict behavior with 3+ devices is a separate effort, so I'm keeping it to two for v1). iOS hosting (no JVM on iOS for NanoHttpd; v1 supports iOS as guest using cross-platform React Native code for `FileApiDriverLAN` and `react-native-zeroconf`). Custom encryption (Joplin's E2EE encrypts items at the `BaseItem` serialization layer before they reach any `FileApi` driver, so LAN Sync gets encryption for free). Full PKI/CA infrastructure and formal security audit (post-GSoC hardening tasks; v1 uses self-signed TLS with fingerprint pinning after pairing).

## 3. Technical Approach

### Architecture

 ![Architecture-Overview.drawio](https://canada1.discourse-cdn.com/flex028/uploads/cozic/original/3X/b/9/b98efdee42d1b831210aab4efc9256d0b5ce0613.png)

I'm using a **host-guest model**. One device runs a lightweight HTTP server (`LanSyncServer`) that wraps `FileApiDriverLocal` — the same driver the File System sync target uses. The other device discovers it via mDNS and syncs against it using a new `FileApiDriverLAN` that speaks HTTP.

I went with this instead of true P2P because it maps directly onto Joplin's existing sync architecture. The `Synchronizer` already knows how to talk to a `FileApi` driver. I just write a driver that talks HTTP instead of touching the filesystem. No changes needed to the sync algorithm, conflict resolution, or delta detection.

The host doesn't run a Synchronizer itself. It's a passive file store, similar to how Dropbox or a WebDAV server just holds files without initiating sync. The guest's Synchronizer handles everything: it pushes local changes to the host (PUT), deletes remote items the guest removed (DELETE), then pulls any changes from the host via delta (GET). After a sync cycle, both devices have the same data and are fully independent.

**Why this isn't just "run a WebDAV server on your LAN":**

1. Zero config — mDNS handles discovery, no URLs to type in
2. No extra software — built into Joplin itself
3. Works on Android — the phone can discover and sync natively
4. Simple pairing — a 6-digit PIN, not username/password management
5. Encrypted by default — self-signed TLS with certificate pinning after pairing

### Components

**1. Peer Discovery (`LanDiscoveryService`)** — DNS-SD (RFC 6763) over mDNS for zero-config discovery. Service type: `_joplin-sync._tcp.local.` (same protocol used by AirPrint, Chromecast, Spotify Connect). On desktop: `bonjour-service` npm package (TypeScript, 11M+ weekly downloads, actively maintained, provides DNS-SD semantics on top of `multicast-dns`). On Android: `react-native-zeroconf` wrapping `NsdManager`, with its DNSSD backend as a more reliable alternative on devices with buggy NSD implementations. On iOS: same `react-native-zeroconf` package wrapping Apple's Bonjour framework. The discovery list displays each peer as `displayName (...last4ofDeviceId)` — e.g., "Ahmed's Desktop (...a3f7)" — so the user can cross-check against the host's screen to guard against rogue host impersonation on shared networks.

```typescript
import Bonjour, { Service } from "bonjour-service";

interface LanPeer {
  deviceId: string;
  displayName: string;
  host: string;
  port: number;
  protocolVersion: number;
}

class LanDiscoveryService {
  private bonjour_: Bonjour = null;
  private service_: any = null;
  private browser_: any = null;
  private peers_: Map<string, LanPeer> = new Map();
  private onPeersChanged_: () => void = null;

  // Host advertises itself on the LAN. bonjour-service handles
  public async advertise(deviceId: string, port: number, displayName: string) {
    // Destroy any existing instance before creating a new one to avoid leaking
    // the old multicast-dns socket and its event listeners.
    if (this.bonjour_) {
      this.bonjour_.destroy();
    }
    this.bonjour_ = new Bonjour();
    this.service_ = this.bonjour_.publish({
      name: displayName,
      type: "joplin-sync",
      port: port,
      txt: { deviceId, name: displayName, v: "1" },
    });
  }

  public async startDiscovery() {
    if (this.bonjour_) {
      this.bonjour_.destroy();
    }
    this.bonjour_ = new Bonjour();
    this.browser_ = this.bonjour_.find({ type: "joplin-sync" });

    this.browser_.on("up", (service: Service) => {
      const txt = service.txt as Record<string, string>;
      if (!txt.deviceId) return;
      this.peers_.set(txt.deviceId, {
        deviceId: txt.deviceId,
        displayName: txt.name || service.name,
        host: service.host,
        port: service.port,
        protocolVersion: parseInt(txt.v, 10) || 1,
      });
      if (this.onPeersChanged_) this.onPeersChanged_();
    });

    this.browser_.on("down", (service: Service) => {
      const txt = service.txt as Record<string, string>;
      if (txt.deviceId) {
        this.peers_.delete(txt.deviceId);
        if (this.onPeersChanged_) this.onPeersChanged_();
      }
    });
  }

  public peers(): LanPeer[] {
    return Array.from(this.peers_.values());
  }

  public async stop() {
    if (this.service_) this.service_.stop();
    if (this.browser_) this.browser_.stop();
    if (this.bonjour_) this.bonjour_.destroy();
  }
}

```

**2. Trust & Pairing** — One-time PIN exchange. Host generates a **6-digit PIN** (1M combinations vs 10K for 4-digit), displays it on screen for **2 minutes** before it expires and rotates. Guest sends the PIN; host returns a 256-bit auth token stored in `sync.12.authToken` (secure). All subsequent requests use Bearer token auth.

The host applies **exponential backoff** on failed PIN attempts (1s after 3 failures, 30s after 8) and **locks pairing for 5 minutes** after 10 failures — following the same `rate-limiter-flexible` pattern Joplin Server uses for login brute-force protection (`limiterLoginBruteForce.ts`). Before completing pairing, the host shows a **confirmation dialog** with the guest's device name so the user can verify they're pairing with the right device.

The host maintains a **paired devices list** (device name, deviceId, paired date, last sync time) persisted alongside sync data. Users can revoke any device from the settings UI, which removes its token immediately.

 ![sync-flow.drawio (1)](https://canada1.discourse-cdn.com/flex028/uploads/cozic/original/3X/b/3/b3f2f2a36281734ba16f3f54946dd1cd2651ddcf.png)

**3. LAN Sync HTTP Server (`LanSyncServer`)** — Lightweight HTTPS server on the host wrapping `FileApiDriverLocal`. Uses a **self-signed TLS certificate** generated at first launch using the [`selfsigned`](https://www.npmjs.com/package/selfsigned) npm package (the same approach used by webpack-dev-server), so all traffic between host and guest is encrypted. Node's built-in `crypto` module can parse certificates (`X509Certificate`) but cannot create them, so `selfsigned` handles key pair generation and certificate signing. The guest pins the host's certificate fingerprint after pairing, rejecting any cert mismatch on subsequent connections. Maps 1:1 to the driver interface:

| Endpoint | Maps to | What it does |
| --- | --- | --- |
| `GET /api/stat/:path` | `stat()` | File metadata |
| `GET /api/list/:path` | `list()` | Directory listing (`PaginatedList`) |
| `GET /api/liststat/:path` | `readDirStats()` | Flat `ItemStat[]` for `basicDelta` |
| `GET /api/get/:path` | `get()` | Download content |
| `PUT /api/put/:path` | `put()` | Upload content |
| `DELETE /api/delete/:path` | `delete()` | Remove item |
| `POST /api/mkdir/:path` | `mkdir()` | Create directory |
| `GET /api/info` | — | Server version & capabilities |

```typescript
import { createServer as createHttpsServer, ServerOptions } from "https";
import { IncomingMessage, ServerResponse } from "http";
import { resolve as pathResolve, sep as pathSep } from "path";
import * as selfsigned from "selfsigned";
import FileApiDriverLocal from "./file-api-driver-local";

class LanSyncServer {
  private server_: any = null;
  private driver_: FileApiDriverLocal;
  private basePath_: string;
  private port_: number;
  private authTokens_: Set<string>;
  private tlsKey_: string;
  private tlsCert_: string;

  public constructor(
    basePath: string,
    port: number,
    authTokens: Set<string>,
    tlsKey: string,
    tlsCert: string,
  ) {
    this.basePath_ = pathResolve(basePath);
    this.port_ = port;
    this.authTokens_ = authTokens;
    this.tlsKey_ = tlsKey;
    this.tlsCert_ = tlsCert;
    this.driver_ = new FileApiDriverLocal();
  }

  // Generates a self-signed TLS certificate at first launch.
  // Call once and persist key + cert in secure settings (sync.12.tlsKey, sync.12.tlsCert).
  // selfsigned is used because Node's built-in crypto can parse certs (X509Certificate)
  // but has no API for creating them.
  public static generateTlsCredentials(): { key: string; cert: string } {
    const attrs = [{ name: "commonName", value: "joplin-lan-sync" }];
    const pems = selfsigned.generate(attrs, {
      keySize: 2048,
      days: 3650, // 10 years — avoids forcing users to re-pair
      algorithm: "sha256",
    });
    return { key: pems.private, cert: pems.cert };
  }

  public async start() {
    const tlsOptions: ServerOptions = {
      key: this.tlsKey_,
      cert: this.tlsCert_,
    };
    this.server_ = createHttpsServer(tlsOptions, (req, res) =>
      this.handleRequest_(req, res),
    );
    this.server_.listen(this.port_, "0.0.0.0");
  }
  private sanitizePath_(itemPath: string): string {
    if (itemPath.includes("\0")) throw new Error("Invalid path");
    const resolved = pathResolve(this.basePath_, itemPath);
    const base = this.basePath_.endsWith(pathSep)
      ? this.basePath_
      : this.basePath_ + pathSep;
    if (resolved !== this.basePath_ && !resolved.startsWith(base)) {
      throw new Error("Path traversal detected");
    }
    return resolved;
  }

  private async handleRequest_(req: IncomingMessage, res: ServerResponse) {
    const token = req.headers.authorization?.replace("Bearer ", "");
    if (!token || !this.authTokens_.has(token)) {
      res.writeHead(401);
      res.end(JSON.stringify({ error: "Unauthorized" }));
      return;
    }

    const url = new URL(req.url, `https://${req.headers.host}`);
    const itemPath = decodeURIComponent(
      url.pathname.replace(/^\/api\/\w+\//, ""),
    );

    try {
      const fullPath = this.sanitizePath_(itemPath);

      if (req.method === "GET" && url.pathname.startsWith("/api/stat/")) {
        const stat = await this.driver_.stat(fullPath);
        res.writeHead(200, { "Content-Type": "application/json" });
        res.end(JSON.stringify(stat));
      } else if (
        req.method === "GET" &&
        url.pathname.startsWith("/api/list/")
      ) {
        const items = await this.driver_.list(fullPath);
        res.writeHead(200, { "Content-Type": "application/json" });
        res.end(JSON.stringify(items));
      } else if (
        req.method === "GET" &&
        url.pathname.startsWith("/api/liststat/")
      ) {
        const stats = await this.driver_.fsDriver().readDirStats(fullPath);
        const items = stats.map((s: any) => ({
          path: s.path,
          updated_time: s.mtime.getTime(),
          isDir: s.isDirectory(),
        }));
        res.writeHead(200, { "Content-Type": "application/json" });
        res.end(JSON.stringify(items));
      } else if (req.method === "GET" && url.pathname.startsWith("/api/get/")) {
        const content = await this.driver_.get(fullPath, {});
        res.writeHead(200, { "Content-Type": "application/octet-stream" });
        res.end(content);
      } else if (req.method === "PUT" && url.pathname.startsWith("/api/put/")) {
        const body = await this.readBody_(req);
        await this.driver_.put(fullPath, body, {});
        res.writeHead(200);
        res.end(JSON.stringify({ ok: true }));
      } else if (
        req.method === "DELETE" &&
        url.pathname.startsWith("/api/delete/")
      ) {
        await this.driver_.delete(fullPath);
        res.writeHead(200);
        res.end(JSON.stringify({ ok: true }));
      } else if (
        req.method === "POST" &&
        url.pathname.startsWith("/api/mkdir/")
      ) {
        await this.driver_.mkdir(fullPath);
        res.writeHead(200);
        res.end(JSON.stringify({ ok: true }));
      } else {
        res.writeHead(404);
        res.end(JSON.stringify({ error: "Not found" }));
      }
    } catch (error) {
      res.writeHead(500);
      res.end(JSON.stringify({ error: error.message }));
    }
  }

  private readBody_(req: IncomingMessage): Promise<Buffer> {
    return new Promise((resolve, reject) => {
      const chunks: Buffer[] = [];
      req.on("data", (chunk: Buffer) => chunks.push(chunk));
      req.on("end", () => resolve(Buffer.concat(chunks)));
      req.on("error", reject);
    });
  }

  public async stop() {
    if (this.server_) this.server_.close();
  }
}

```

**4. FileApi Driver (`FileApiDriverLAN`)** — Guest-side HTTP client implementing the standard driver interface. One subtlety I found reading the code: `list()` returns a `PaginatedList` (`{items, hasMore, context}`) but `basicDelta()` expects its `getDirStatFn` callback to return a flat `ItemStat[]` that it can `.sort()` on. `FileApiDriverLocal` handles this by calling `fsDriver().readDirStats()` directly in its `delta()` method. So I need the separate `/api/liststat` endpoint.

The driver uses separate header helpers per request type. `PUT` sends note content which can be binary (attachments, encrypted blobs), so it uses `application/octet-stream` rather than `application/json` to avoid corrupting binary payloads.

```typescript
class FileApiDriverLAN {
  private baseUrl_: string;
  private authToken_: string;

  public constructor(baseUrl: string, authToken: string) {
    this.baseUrl_ = baseUrl;
    this.authToken_ = authToken;
  }

  private authHeader_() {
    return { Authorization: `Bearer ${this.authToken_}` };
  }

  private jsonHeaders_() {
    return { ...this.authHeader_(), "Content-Type": "application/json" };
  }

  private binaryHeaders_() {
    return { ...this.authHeader_(), "Content-Type": "application/octet-stream" };
  }

  public async stat(path: string) {
    const resp = await fetch(
      `${this.baseUrl_}/api/stat/${encodeURIComponent(path)}`,
      { headers: this.jsonHeaders_() },
    );
    if (resp.status === 404) return null;
    return resp.json();
  }

  public async list(path: string) {
    const resp = await fetch(
      `${this.baseUrl_}/api/list/${encodeURIComponent(path)}`,
      { headers: this.jsonHeaders_() },
    );
    return resp.json();
  }

  public async get(path: string, options: any) {
    if (!options) options = {};
    const resp = await fetch(
      `${this.baseUrl_}/api/get/${encodeURIComponent(path)}`,
      { headers: this.authHeader_() },
    );
    if (resp.status === 404) return null;
    if (options.target === "file") {
      const buffer = Buffer.from(await resp.arrayBuffer());
      await shim.fsDriver().writeFile(options.path, buffer);
      return;
    }
    return resp.text();
  }

  public async put(path: string, content: any, options: any) {
    if (!options) options = {};
    if (options.source === "file") {
      content = await shim.fsDriver().readFile(options.path, "Buffer");
    }
    await fetch(`${this.baseUrl_}/api/put/${encodeURIComponent(path)}`, {
      method: "PUT",
      headers: this.binaryHeaders_(),
      body: content,
    });
  }

  public async delete(path: string) {
    await fetch(`${this.baseUrl_}/api/delete/${encodeURIComponent(path)}`, {
      method: "DELETE",
      headers: this.jsonHeaders_(),
    });
  }

  public async mkdir(path: string) {
    await fetch(`${this.baseUrl_}/api/mkdir/${encodeURIComponent(path)}`, {
      method: "POST",
      headers: this.jsonHeaders_(),
    });
  }

  public async delta(path: string, options: any) {
    const getStatFn = async (p: string) => {
      const resp = await fetch(
        `${this.baseUrl_}/api/liststat/${encodeURIComponent(p)}`,
        { headers: this.jsonHeaders_() },
      );
      return resp.json();
    };
    return basicDelta(path, getStatFn, options);
  }
}

```

**5. Sync Target Registration (`SyncTargetLAN`)** — Follows the same pattern as `SyncTargetFilesystem` (~50 lines). Registered in `BaseApplication.ts` at line 724 alongside existing targets. Settings in `builtInMetadata.ts` following the `sync.{ID}.settingName` convention.

```typescript
export default class SyncTargetLAN extends BaseSyncTarget {
  public static id() {
    return 12;
  }
  public static targetName() {
    return "lan";
  }
  public static label() {
    return _("LAN Sync");
  }
  public static description() {
    return _("Sync with another device on your local network");
  }
  public static supportsConfigCheck() {
    return true;
  }
  public static unsupportedPlatforms() {
    return [];
  }

  public async isAuthenticated() {
    return !!Setting.value("sync.12.authToken");
  }

  public async initFileApi() {
    const host = Setting.value("sync.12.host");
    const port = Setting.value("sync.12.port");
    const authToken = Setting.value("sync.12.authToken");
    const baseUrl = `https://${host}:${port}`;

    const driver = new FileApiDriverLAN(baseUrl, authToken);
    const fileApi = new FileApi("", driver);
    fileApi.setLogger(this.logger());
    fileApi.setSyncTargetId(SyncTargetLAN.id());
    return fileApi;
  }

  public async initSynchronizer() {
    return new Synchronizer(
      this.db(),
      await this.fileApi(),
      Setting.value("appType"),
    );
  }

  public static async checkConfig(options: any) {
    try {
      const baseUrl = `https://${options.host}:${options.port}`;
      const driver = new FileApiDriverLAN(baseUrl, options.authToken);
      const fileApi = new FileApi("", driver);
      fileApi.setSyncTargetId(SyncTargetLAN.id());
      fileApi.requestRepeatCount_ = 0;
      const result = await fileApi.stat("");
      if (!result) throw new Error("Could not reach LAN sync host");
      return { ok: true, errorMessage: "" };
    } catch (e) {
      return { ok: false, errorMessage: e.message };
    }
  }
}

```

### Changes to the Joplin codebase

| File | Change |
| --- | --- |
| `packages/lib/SyncTargetLAN.ts` | New file — sync target class |
| `packages/lib/file-api-driver-lan.ts` | New file — HTTP client driver |
| `packages/lib/LanSyncServer.ts` | New file — HTTP server |
| `packages/lib/LanDiscoveryService.ts` | New file — mDNS discovery |
| `packages/lib/BaseApplication.ts` | Add `SyncTargetRegistry.addClass(SyncTargetLAN)` |
| `packages/lib/models/settings/builtInMetadata.ts` | Add `sync.12.*` settings: `sync.12.host`, `sync.12.port`, `sync.12.authToken` (secure), `sync.12.tlsKey` (secure), `sync.12.tlsCert` (secure, guest cert pinning) |
| `packages/app-desktop/gui/` | Settings UI for LAN sync |
| `packages/app-mobile/` | Mobile settings + native modules for mDNS/HTTP server |

### Libraries

| Library | Platform | Purpose |
| --- | --- | --- |
| [`bonjour-service`](https://www.npmjs.com/package/bonjour-service) | Desktop (Node.js) | DNS-SD discovery. TypeScript, 11M+ weekly downloads, actively maintained |
| [`selfsigned`](https://www.npmjs.com/package/selfsigned) | Desktop (Node.js) | Self-signed TLS cert generation. Used by webpack-dev-server. Node's built-in `crypto` can parse certs but cannot create them |
| [`react-native-zeroconf`](https://www.npmjs.com/package/react-native-zeroconf) | Android | mDNS via NsdManager, with DNSSD backend for reliability |
| [`NanoHttpd`](https://github.com/NanoHttpd/nanohttpd) | Android | Embedded HTTP server for mobile hosting. Mature and widely embedded (included in Android's source tree), though no longer actively maintained |
| [`react-native-http-bridge-refurbished`](https://www.npmjs.com/package/react-native-http-bridge-refurbished) | Android | Bridges NanoHttpd to React Native JS. If it doesn't meet our needs, writing a thin custom native module around NanoHttpd is straightforward |
| [`react-native-zeroconf`](https://www.npmjs.com/package/react-native-zeroconf) | iOS | mDNS discovery via Bonjour (built into iOS). Same cross-platform library used on Android — enables iOS as guest with no extra native code |

### Potential challenges

- **Android mDNS reliability** : NsdManager has known platform bugs on some devices. Mitigation: `react-native-zeroconf` supports a DNSSD backend that embeds mDNSResponder directly, bypassing NsdManager. If the library itself proves incompatible, I'll write a custom native module wrapping NsdManager with the specific API surface I need.
- **React Native New Architecture** : `react-native-zeroconf` hasn't confirmed compatibility with TurboModules/Fabric (RN 0.76+). I'll test this early in the Android phase and either contribute a compatibility patch upstream or build a minimal native module.
- **Clock sync between devices** : `FileApi.remoteDate()` calculates clock offset by writing a temp file and reading its timestamp back. This should work over HTTP since the host returns the file's server-side timestamp.
- **Network changes mid-sync** : If the user switches Wi-Fi networks during sync, HTTP requests will fail. The `Synchronizer` already handles this via `tryAndRepeat()` retry logic and graceful error reporting.

### Security considerations

The threat model assumes an attacker on the same LAN (coffee shop Wi-Fi, shared office, campus network) who can sniff traffic, send requests to the host, and see mDNS broadcasts.

| Threat | Severity | Mitigation |
| --- | --- | --- |
| **PIN brute-force** (6-digit = 1M combinations) | High | Exponential backoff + lockout after 10 failures + 2-minute PIN expiry. Same `rate-limiter-flexible` pattern as Joplin Server's `limiterLoginBruteForce.ts` |
| **Token sniffing** (eavesdrop on sync traffic) | Medium-High | Self-signed TLS via `selfsigned` — host generates cert at first launch, guest pins the fingerprint after pairing. All sync traffic encrypted |
| **Rogue host** (attacker advertises fake service) | Medium | Discovery list shows `displayName (...deviceId)` for cross-checking. Host-side confirmation dialog before completing pairing |
| **Stale trust** (lost/stolen device still has token) | Medium | Paired devices list in settings UI with per-device revoke. Token removed immediately on revocation |
| **Path traversal** (`../../etc/passwd` via API) | High | `sanitizePath_()` resolves full path via `path.resolve()`, then checks against `basePath_ + path.sep` (not just `startsWith(basePath_)`) to prevent prefix-match bypass. Null bytes rejected |

Replay attacks are not mitigated separately — TLS prevents token capture in the first place, and an attacker who already has the token has full access regardless of replay protection.

### End-to-end sync flow

The guest's `Synchronizer` drives the entire sync cycle. It talks to `FileApiDriverLAN` the same way it talks to WebDAV or filesystem drivers — pushing local changes to the host (PUT), deleting remotely removed items (DELETE), then pulling remote changes via delta (GET + `/api/liststat`). The `basicDelta` context is persisted and restored by the `Synchronizer` between sessions, exactly as it does for the filesystem and WebDAV targets.

 ![sync-flow.drawio (1)](https://canada1.discourse-cdn.com/flex028/uploads/cozic/original/3X/b/3/b3f2f2a36281734ba16f3f54946dd1cd2651ddcf.png)

## 4. Implementation Plan

**Community Bonding (before coding starts):** Read Syncthing's discovery protocol source, set up desktop + Android dev environments, discuss API design with @Daeraxa, prototype mDNS discovery on localhost.

| Week | Milestone | Details |
| --- | --- | --- |
| 1 | **LanSyncServer** | HTTPS server with all endpoints (stat, list, liststat, get, put, delete, mkdir), TLS cert generation via `selfsigned`, Bearer token auth middleware, `sanitizePath_()` path traversal protection |
| 2 | **FileApiDriverLAN** | HTTPS client driver, wire up to `FileApi`, get `basicDelta` working with `/api/liststat` |
| 3 | **SyncTargetLAN + tests** | Register sync target (ID 12), add settings to `builtInMetadata.ts`, unit + integration tests, two desktop instances syncing on localhost |
| 4 | **mDNS discovery** | `LanDiscoveryService` with `bonjour-service`, desktop peer discovery working end-to-end |
| 5 | **Pairing + Settings UI** | 6-digit PIN exchange with rate limiting (exponential backoff + lockout), certificate fingerprint pinning, token storage, paired devices list with revoke, desktop settings panel with host/guest toggle and discovered peers list |
| 6 | **Midterm** | Desktop-to-desktop LAN sync working end-to-end with discovery and pairing |
| 7 | **Desktop polish** | `checkConfig()`, error messages, handle network changes and timeouts, security testing (PIN brute-force, path traversal, token sniffing with TLS) |
| 8 | **Android: discovery** | Integrate `react-native-zeroconf`, test mDNS on real Android device |
| 9 | **Android: sync + server** | `FileApiDriverLAN` on React Native, foreground service (`connectedDevice` type) with NanoHttpd |
| 10 | **Mobile: UI + testing** | Mobile settings screen, pairing flow, desktop-to-Android sync testing, verify iOS guest-only (discovery + sync) |
| 11 | **Final polish + docs** | Documentation, final E2E testing, address mentor feedback, buffer for unexpected issues |

## 5. Deliverables

- **LAN Sync sync target** — fully functional on Windows, Linux, Android, and iOS (guest-only)
- **mDNS device discovery** — zero-config peer finding on all supported platforms
- **PIN-based pairing** — secure one-time trust establishment
- **Settings UI** — integrated into existing sync settings on desktop and mobile
- **Tests** — unit tests for driver + server, integration tests for full sync cycles, discovery tests on loopback. Following Joplin conventions: one `describe()` per file, `test.each` for variations, minimal mocking
- **Documentation** — user-facing docs for setting up LAN Sync

## 6. Availability

- **Weekly availability** : **30 hours per week minimum, with capacity to scale up to 40 hours**. My summer is fully free — no exams, no internship, no other commitments from June through August. The project requires 175 hours over 11 coding weeks (~16 hours/week), so I have nearly double the capacity needed — plenty of buffer for unexpected complexity or iterating on mentor feedback.
- **Timezone** : UTC+1 (Tunisia)
- **Communication** : Daily status updates on the forum GSoC thread. Weekly written progress reports with screenshots or demos. Available on Discord for ad-hoc discussion with mentors.
- **Other commitments** : None during the GSoC period.
- **I'm only applying to Joplin. If accepted, this is what I'm doing.**

---

<div class="post-metadata">

**Author:** ![laurent](https://avatars.discourse-cdn.com/v4/letter/l/ce7236/32.png) [@laurent](https://discourse.joplinapp.org/u/laurent)\
**Post date:** [19 March 2026 14:31 UTC](https://discourse.joplinapp.org/t/gsoc-2026-proposal-draft-idea-9-lan-sync-ahmed-idani/49222/2 "2026-03-19T14:31:55Z")

</div>

The approach of reusing the existing FileApi and Synchronizer makes sense. It would be useful to clarify a few edge cases around discovery and security (e.g. behaviour on network changes, LAN trust model), but overall that's a very good start.

---

<div class="post-metadata">

**Author:** ![Idani\_Ahmed](https://avatars.discourse-cdn.com/v4/letter/i/ebca7d/32.png) [@Idani\_Ahmed](https://discourse.joplinapp.org/u/Idani_Ahmed)\
**Post date:** [19 March 2026 17:04 UTC](https://discourse.joplinapp.org/t/gsoc-2026-proposal-draft-idea-9-lan-sync-ahmed-idani/49222/3 "2026-03-19T17:04:34Z")

</div>

Thanks Laurent! That's encouraging to hear. And I'm already thinking through those, like how to handle it when the host switches networks mid-sync, how to clean up unreachable devices from the discovery list, or what to do when a paired device shouldn't be trusted anymore. I'll keep digging into these and update the proposal.

---

<div class="post-metadata">

**Author:** ![CalebJohn](https://yyz2.discourse-cdn.com/flex028/user_avatar/discourse.joplinapp.org/calebjohn/32/356_2.png) [@CalebJohn](https://discourse.joplinapp.org/u/CalebJohn)\
**Post date:** [22 March 2026 08:46 UTC](https://discourse.joplinapp.org/t/gsoc-2026-proposal-draft-idea-9-lan-sync-ahmed-idani/49222/4 "2026-03-22T08:46:53Z")

</div>

> [@Idani\_Ahmed](#):
>
> iOS support (Apple background networking constraints)

Background sync is not implemented for Android or iOS, so background networking constraints are not a blocker here.

---

<div class="post-metadata">

**Author:** ![Idani\_Ahmed](https://avatars.discourse-cdn.com/v4/letter/i/ebca7d/32.png) [@Idani\_Ahmed](https://discourse.joplinapp.org/u/Idani_Ahmed)\
**Post date:** [22 March 2026 12:55 UTC](https://discourse.joplinapp.org/t/gsoc-2026-proposal-draft-idea-9-lan-sync-ahmed-idani/49222/5 "2026-03-22T12:55:07Z")

</div>

You're right that background constraints aren't the issue since sync only runs while the app is open. Looking at it again, guest-side iOS support should actually just work, the React Native code (FileApiDriverLAN + react-native-zeroconf) is cross-platform. The only iOS limitation is hosting, since NanoHttpd is Android-only. I'll update the proposal to include iOS as guest.

---

<div class="post-metadata">

**Author:** ![Idani\_Ahmed](https://avatars.discourse-cdn.com/v4/letter/i/ebca7d/32.png) [@Idani\_Ahmed](https://discourse.joplinapp.org/u/Idani_Ahmed)\
**Post date:** [23 March 2026 00:46 UTC](https://discourse.joplinapp.org/t/gsoc-2026-proposal-draft-idea-9-lan-sync-ahmed-idani/49222/6 "2026-03-23T00:46:38Z")

</div>

Updated the proposal and I wanted to flag what changed.

@CalebJohn incorporated your feedback into the update as well, iOS as guest is now explicitly supported and reflected throughout the proposal (libraries table, deliverables, and week 10 milestone).

@laurent your comment pushed me to think more carefully about the security model. I ended up going further than I originally planned , upgraded from plain HTTP to HTTPS with a self-signed cert and fingerprint pinning, added a proper threat model table with a concrete mitigation for each attack vector, and modelled the brute-force protection after Joplin Server's own `limiterLoginBruteForce.ts`. While reviewing the code I also caught a path traversal bypass in `sanitizePath_()` — a plain `startsWith(basePath_)` check lets through paths like `/joplin/sync-evil/...`, fixed by checking against `basePath_ + path.sep` instead.

I have also incorporated the iOS guest fix from my last reply, plus a few other things I improved along the way: switched from raw `multicast-dns` to `bonjour-service` which handles DNS-SD record construction properly, bumped the PIN from 4 to 6 digits, and fixed `PUT` to use `application/octet-stream` instead of `application/json` since note content can be binary.

Really appreciate the feedback, I believe that the proposal is in a much better place now. Happy to discuss anything further before the deadline.
