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

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

Links

1. Introduction

I'm Ahmed Idani, a 4th-year engineering student at INSAT (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), then fixed an Electron relaunch bug on Linux (#14530), and most recently fixed a sync crash affecting users who switch from Joplin Server to WebDAV (#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 I assisted with firewall migrations (Cisco to Fortinet) and Layer 2 networking. At Open Organic Robotics 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, 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

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.

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.

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 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
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.

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.

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 Desktop (Node.js) DNS-SD discovery. TypeScript, 11M+ weekly downloads, actively maintained
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 Android mDNS via NsdManager, with DNSSD backend for reliability
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 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 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.

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.

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.

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.

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

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.

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.