Recently PostsOdds & Ends

Secure cross-platform Python grpc daemon design

These are working notes for my hobby project light, which provides programmatic access for Light devices without using the web dashboard. Since this is going to handle potentially sensitive user data this can’t be vibecoded.

Currently light is an API/CLI. I have one confirmed consumer - Light Phone Manager, which is an Electron app implementing a GUI presentation layer on top of my CLI, shelling out and reading JSON responses from it. light is implemented in Python which raises a lot of issues in this scenario:

  • LPM has to bundle a light executable for every platform (not too bad, but annoying)
  • LPM reinvokes the executable on every single command, meaning every command has the overhead of starting the interpreter, importing everything, etc. which adds noticeable latency to all light-dependent operations within the app.

This note explores options to avoid this.

Requirements:

  • must be cross-platform, preferably with as much portability and reusability as possible
  • must be secure (data encrypted in transit)
  • must be language-agnostic

Design notes

The chosen design is a grpc daemon (or service). The existing CLI json output flow will remain intact, and its use cases will be relegated to simple scripts (e.g. a bash script handling batch uploads).

Background

grpc is a remote-procedure call (RPC) framework developed by Google which lets a client application directly call functions on a server across a network as if they were local.

Interface and transport

grpc uses protocol buffers as its interface definition language. These are defined in .proto files, and this is the contract by which all communications happen. Code generation tools take .proto files and automatically generate stubs for any language.

Messages are sent over HTTP/2.

RPC types

kindsignature shape
unaryrpc F(req) returns (Resp)
server-streamingrpc F(req) returns (stream Msg)
client-streamingrpc F(stream Msg) returns (Resp)
bidirectional streamingrpc F(stream A) returns (stream B)

How these map to current light commands:

  • unary: read-only stuff like getting tracks, notes
  • server-streaming: uploading tracks (returns per-file results as uploads happen)
  • client-streaming: currently none, but this would be used if the client ever streamed file bytes (extremely likely in the near future)
  • bidirectional: none. (a sample use case would be real-time chat applications, collaborative editing tools, etc. - not relevant)

Client/server implementation

Client

The client will need a channel and stubs. The channel is the connection to the host:port for the grpc server, and the stubs are the autogenerated RPCs from the protobuf.

channel = grpc.insecure_channel("127.0.0.1:50510")

It’s lazy (==TODO== ?), auto-reconnects if dropped, and can carry concurrent calls. insecure_channel here means no TLS, which is ok because this is on loopback. (Ensure the daemon loudly refuses non-loopback binds.)

Server

On the server end, it needs a servicer and a threadpool.

The generated code provides a base class with one empty method per RPC. I would subclass them and fill them in.

  • grpc.server(...) would run incoming calls on a threadpool.
  • Each call is one pooled thread invoking my RPC methods. I’d set threadpool to have 1 worker for forced serialization.

Security

  • No TLS.
    • Loopback = no network to protect.
  • Bind to localhost only.
  • Assign one random token per daemon start.
    • Client sends it as authorization metadata on every call, and a serverside interceptor checks it and rejects everything else.
    • Get the token to the client without a disk file.
  • Don’t keep password in memory - just session token.
  • A same-user process wins; nothing we can do about that.