indikit.web¶
The FastAPI web bridge: one shared upstream IndiClient relayed to browsers
as typed JSON over a WebSocket, plus a REST snapshot and the bundled panel.
/ws is the whole write surface and /api is a full read of instrument state,
so both sit behind the shared token whenever one is configured, and /ws is
additionally checked against an Origin allowlist before the handshake is
accepted. /health, /, /debug and the static panel stay open. The policy
itself is WebSecurity, under Security below.
App¶
indikit.web.app ¶
FastAPI application factory for the INDIkit web bridge.
:func:create_app builds a FastAPI app that serves, on top of one shared
:class:~indikit.client.IndiClient:
GET /- the built reference panel if present, else the debug inspector page;GET /debug- the self-contained debug inspector page;GET /health- liveness, the browser contract's version, upstream connection state, and counters for the slow-sink drops and the upstream parser;GET /api/devicesand/api/devices/{device}[/{name}]- a read-only JSON snapshot of the property cache;WS /ws- the live bridge: a snapshot on connect, then streamed updates, with browser-sent frames forwarded upstream.
The client is injectable so tests drive the app over an in-memory transport; by
default a real TCP client to indiserver is created.
The auth boundary runs around /ws and /api. /ws is the write
surface, and /api/devices/* is a full read of instrument state - site
coordinates, hardware inventory, mount and focuser positions - so both sit behind
the token whenever one is configured. /health stays open because the image's
HEALTHCHECK calls it unauthenticated and it exposes one boolean and a handful
of counters - no addresses, no device names, and no release version, which
would hand an unauthenticated caller the exact build to look up advisories
against while telling a legitimate one nothing the protocol integer does not
already say; /, /debug and the static panel are open-source HTML with nothing
instrument-specific in them. There is deliberately no ambient credential (no
cookie, no session), so cross-origin JavaScript cannot authenticate to /api at
all and there are no state-changing HTTP routes to protect; see
:mod:indikit.web.security for what guards /ws.
create_app ¶
create_app(*, client: IndiClient | None = None, indi_host: str = 'localhost', indi_port: int = 7624, token: str | None = None, allowed_origins: Sequence[str] = (), connect_timeout: float = 10.0, reconnect_delay: float = 2.0, message_history: int = _MESSAGE_HISTORY, max_backlog: int = _MAX_BACKLOG) -> FastAPI
Build the web-bridge FastAPI application.
Nothing here reads the environment. indikit serve fills the tuning
arguments from :class:~indikit.settings.Settings, so the app stays
injectable and importing it costs no ambient configuration.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
client
|
IndiClient
|
An existing client to relay (used by tests); if omitted, a real
:class: |
None
|
indi_host
|
str
|
Upstream |
'localhost'
|
indi_port
|
int
|
Upstream |
7624
|
connect_timeout
|
float
|
Seconds the created client waits per connection attempt (when |
10.0
|
reconnect_delay
|
float
|
Seconds the created client waits between attempts (when |
2.0
|
message_history
|
int
|
How many recent INDI |
_MESSAGE_HISTORY
|
max_backlog
|
int
|
How many live frames a browser may fall behind by before it is dropped. |
_MAX_BACKLOG
|
token
|
str
|
A shared secret required on |
None
|
allowed_origins
|
Sequence of str
|
Browser origins accepted on |
()
|
Returns:
| Name | Type | Description |
|---|---|---|
app |
FastAPI
|
The configured application; its lifespan starts and stops the bridge. |
Source code in src/indikit/web/app.py
137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 | |
Bridge¶
indikit.web.bridge ¶
Bridge: fan one shared IndiClient out to many browser WebSockets.
The bridge owns a single upstream connection to indiserver (via the M3
:class:~indikit.client.IndiClient) and relays its activity to every connected
browser as JSON. Property changes, log messages, and connection-state transitions
are broadcast to every attached browser; a browser's inbound frames are parsed back
into typed models and forwarded upstream. A newly-attached browser is first seeded
with the current cache so it starts with full state.
Server -> browser frames are either an INDI message ({"tag": ...}, mirroring
the protocol models) or a small bridge control frame ({"event": ...},
modelled in :mod:indikit.web.control_frames) for things the INDI protocol
has no message for: the version of the browser contract, upstream connection
state, and the rejection of a frame the browser sent. The first frame on every
socket is the hello, ahead of the seeded properties, so a browser knows what
it is talking to before it has to interpret anything. Browser -> server frames
are always INDI messages, and only the three a client is allowed to send.
A browser is a subscriber, never something the upstream reader awaits. Every
broadcast originates in :class:IndiClient's single connection task - property
events and messages through the reader, the connection frame through the connection
loop - so awaiting a socket from :meth:Bridge._broadcast would let one browser
under TCP back-pressure stall the upstream stream for everyone, eventually stalling
the parser and tearing down a healthy connection. Instead each browser gets a
bounded queue and its own pump task, and :meth:Bridge._broadcast is a plain
synchronous def that appends and returns.
That same synchrony is what makes seeding atomic: :meth:Bridge.attach reads the
cache, builds the seed and registers the subscriber with no await anywhere
between, so no event can land in the window that used to exist between the snapshot
and the registration. Keep attach and _broadcast synchronous - that is
the whole argument, and a refactor that quietly makes either one a coroutine
reopens the hole.
Subscription
dataclass
¶
Subscription(bridge: Bridge, sink: Sink, seed_vectors: tuple[Vector, ...], seed_frames: tuple[str, ...], preamble: tuple[str, ...], queue: deque[_Slot] = deque(), coalescible: dict[tuple[str, str], _Slot] = dict(), ready: Event = asyncio.Event(), closed: Event = asyncio.Event(), dropped_for_backlog: bool = False, task: Task[None] | None = None)
One attached browser: its seed, its queued frames, and its pump task.
Returned by :meth:Bridge.attach and closed by :meth:aclose. Not
constructed directly.
Attributes:
| Name | Type | Description |
|---|---|---|
bridge |
Bridge
|
The bridge this subscription is attached to. |
sink |
Sink
|
The awaitable that sends one text frame to this browser. |
seed_vectors |
tuple of Vector
|
The cached properties to send first, held as references rather than as serialized JSON so N attaching browsers do not buffer N copies of the cache before a byte drains. They are serialized by the pump, at drain rate. |
seed_frames |
tuple of str
|
The retained |
preamble |
tuple of str
|
Frames sent before the seeded properties: the |
queue |
deque of _Slot
|
Live frames queued since the attach, drained after the seed. |
coalescible |
dict
|
|
ready |
Event
|
Set when |
closed |
Event
|
Set once the pump has exited, however it exited. |
dropped_for_backlog |
bool
|
Set by :meth: |
task |
Task or None
|
The pump; assigned by :meth: |
send_control ¶
send_control(frame: str) -> None
Queue a bridge control frame for this browser alone.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
frame
|
str
|
The JSON control frame to send. |
required |
Source code in src/indikit/web/bridge.py
174 175 176 177 178 179 180 181 182 | |
enqueue ¶
enqueue(frame: str, key: tuple[str, str] | None) -> bool
Append a frame, or fold it into the queued frame for the same key.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
frame
|
str
|
The JSON text to send. |
required |
key
|
tuple of str or None
|
The |
required |
Returns:
| Name | Type | Description |
|---|---|---|
replaced |
bool
|
Whether this frame overwrote a queued one rather than joining the queue. The caller counts that for BLOBs, where a dropped frame is a whole exposure the browser will never see. |
Source code in src/indikit/web/bridge.py
184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 | |
invalidate ¶
invalidate(device: str, name: str | None) -> None
Forget the coalescible slot(s) for a property, or for a whole device.
Called for every def and del: a later set folded into a slot
sitting ahead of a queued retraction or redefinition would overtake it,
and the browser would apply them out of order. This is the correctness
condition the whole coalescing rests on.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
device
|
str
|
The device whose queued |
required |
name
|
str or None
|
The property, or |
required |
Source code in src/indikit/web/bridge.py
237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 | |
pop ¶
pop() -> str
Remove and return the next queued frame.
Returns:
| Name | Type | Description |
|---|---|---|
frame |
str
|
The JSON text to send. |
Source code in src/indikit/web/bridge.py
259 260 261 262 263 264 265 266 267 268 269 270 | |
aclose
async
¶
aclose() -> None
Stop this subscription's pump and deregister it. Idempotent.
Source code in src/indikit/web/bridge.py
272 273 274 275 276 277 278 279 280 281 | |
Bridge ¶
Bridge(client: IndiClient, server: str = __version__, *, message_history: int = _MESSAGE_HISTORY, max_backlog: int = _MAX_BACKLOG)
Relay between one IndiClient and many browser WebSocket sinks.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
client
|
IndiClient
|
The shared upstream client the bridge relays. |
required |
server
|
str
|
The server version stamped into every socket's |
__version__
|
message_history
|
int
|
How many recent INDI |
_MESSAGE_HISTORY
|
max_backlog
|
int
|
How many live frames a browser may fall behind by before it is dropped;
defaults to :data: |
_MAX_BACKLOG
|
Source code in src/indikit/web/bridge.py
304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 | |
dropped_slow_sinks
property
¶
dropped_slow_sinks: int
How many browsers have been dropped for falling too far behind.
Surfaced on /health because a browser cannot tell an overflow drop
from a network fault - both are a closed socket it reconnects from - so
the diagnosis has to be readable server-side without scraping logs.
coalesced_blobs
property
¶
coalesced_blobs: int
How many queued BLOB frames were replaced before a browser read them.
One per browser per skipped image: the bridge delivers the latest
exposure, not every exposure (see :meth:Subscription.enqueue for why),
and this is the only place that shows it happening. A rising count on an
otherwise healthy bridge says a browser is being served images slower
than the camera produces them, which is a fact about the socket and not
a fault.
Only BLOB coalescing is counted. A temperature readout coalesces constantly and by design, so a count over every frame kind would run away from the first minute and tell an operator nothing.
hello_frame
property
¶
hello_frame: str
The hello frame this bridge leads every socket with.
Built once at construction: it is the same text for every browser, and the version it carries cannot change while the process runs.
start
async
¶
start() -> None
Subscribe to the client and open its upstream connection.
Returns once the connection attempt is under way rather than once it
succeeds: the bridge has to come up whether or not indiserver is
reachable, so a browser gets the panel and a disconnected indicator
instead of a server that never finishes starting. The client keeps
retrying in the background and announces the connection when it lands.
Source code in src/indikit/web/bridge.py
354 355 356 357 358 359 360 361 362 363 364 365 366 | |
aclose
async
¶
aclose() -> None
Stop every pump and close the upstream connection.
A pump cancelled mid-send may leave a half-written socket; this is
shutdown, and the route that owns the socket closes it in its own
finally.
Source code in src/indikit/web/bridge.py
368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 | |
attach ¶
attach(sink: Sink) -> Subscription
Register a browser and seed it with the current cache.
Synchronous on purpose, and it must stay that way. Reading the cache,
building the seed and joining the subscriber set happen with no await
between them, so the client's task cannot run in the middle and no event
can be lost between the snapshot and the registration. create_task
only schedules, so the pump has not started when this returns.
The seed holds vector references. The property store merges a set
into the cached vector in place, so a definition serialized at pump time
may show a newer value than the property had at attach time. That
converges rather than corrupts: the queued set frames were serialized
eagerly, in order, and the last one always equals the current object, so
the browser ends on the right value and only skips intermediate ones -
which is what INDI set means anyway. A property deleted in the window
arrives as its definition followed by the queued delProperty, in that
order.
The hello frame leads, ahead of the seeded properties: it names the
contract version everything after it is written in, so it cannot follow
the frames a browser would need it to interpret.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
sink
|
Sink
|
An awaitable that sends one text frame to the browser. |
required |
Returns:
| Name | Type | Description |
|---|---|---|
subscription |
Subscription
|
The browser's handle; close it with
:meth: |
Source code in src/indikit/web/bridge.py
385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 | |
sink_count ¶
sink_count() -> int
Return how many browsers are currently attached.
Returns:
| Name | Type | Description |
|---|---|---|
count |
int
|
The number of live subscriptions. |
Source code in src/indikit/web/bridge.py
433 434 435 436 437 438 439 440 441 | |
connection_frame
staticmethod
¶
connection_frame(connected: bool) -> str
Build the control frame announcing upstream connection state.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
connected
|
bool
|
Whether the bridge is connected to |
required |
Returns:
| Name | Type | Description |
|---|---|---|
frame |
str
|
A JSON control frame ( |
Source code in src/indikit/web/bridge.py
443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 | |
handle_incoming
async
¶
handle_incoming(text: str, sub: Subscription | None = None) -> None
Parse a browser frame and forward it upstream.
A frame that cannot be parsed, that a client is not allowed to send, or
that the upstream refuses is reported back to the browser that sent it as
an {"event": "error"} control frame, and the socket stays open.
Silence would be a regression: sends used to be queued for a later
connection, so a browser that hears nothing has no reason not to assume
its write landed.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
text
|
str
|
A JSON INDI message from the browser. |
required |
sub
|
Subscription
|
The sender, so a rejection reaches it alone. |
None
|
Source code in src/indikit/web/bridge.py
565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 | |
Control frames¶
The non-INDI half of the browser contract: hello, connection and error,
plus BRIDGE_PROTOCOL_VERSION, the version of that contract. See
Protocol concepts
for what a client does with a version mismatch.
indikit.web.control_frames ¶
The bridge's control frames: the non-INDI half of the browser contract.
Everything the bridge sends a browser is either an INDI message
({"tag": ...}, from :mod:indikit.protocol.models) or one of the frames
here ({"event": ...}), which exist for the things INDI 1.7 has no message
for: the version of the browser contract itself, the upstream connection state,
and the rejection of a frame the browser sent.
They are models rather than hand-built dictionaries for the same reason the INDI
messages are: web/packages/client/src/types.ts is a hand-authored mirror of
the Python models, and a frame assembled with json.dumps at three call sites
has no schema for that mirror to be checked against. :data:BridgeFrame is
discriminated on event exactly as IndiMessage is on tag, so the
union is closed and tests/test_wire_contract.py can snapshot its schema.
Versioning. :data:BRIDGE_PROTOCOL_VERSION versions this contract - the
JSON a browser sees - and has nothing to do with INDI's own version
attribute on getProperties, which is frozen at 1.7. It is announced once per
socket in the :class:HelloFrame, ahead of every other frame, so a browser
learns what it is talking to before it has to interpret anything. A mismatch is
never fatal in either direction: INDI has always been additive-tolerant, the
client drops an event it does not know, and turning a cosmetic version skew
into a dark panel mid-session helps nobody.
HelloFrame ¶
Bases: _Frame
The first frame on every /ws socket: what the browser is talking to.
Attributes:
| Name | Type | Description |
|---|---|---|
event |
str
|
Always |
protocol |
int
|
The browser contract's version; see :data: |
server |
str
|
The INDIkit version serving this socket, for a UI to display and for a
bug report to quote. It has no model default on purpose: a default
lands in |
ConnectionFrame ¶
Bases: _Frame
The upstream indiserver link went up or down.
Attributes:
| Name | Type | Description |
|---|---|---|
event |
str
|
Always |
connected |
bool
|
Whether the bridge currently has a live upstream connection. |
ErrorFrame ¶
Bases: _Frame
A frame this browser sent did not go upstream.
Sent to that browser alone, never for something the bridge accepted, and the socket stays open. Silence would be worse: a refused write is not retried anywhere, so a browser that hears nothing has no reason not to believe it landed.
Attributes:
| Name | Type | Description |
|---|---|---|
event |
str
|
Always |
code |
str
|
A stable machine-readable reason, e.g. |
message |
str
|
Human-readable detail, suitable for a UI log. |
tag |
str or None
|
The rejected message's INDI tag, or |
dump_frame ¶
dump_frame(frame: BridgeFrame) -> str
Serialise one control frame to the JSON text a browser receives.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
frame
|
BridgeFrame
|
The frame to serialise. |
required |
Returns:
| Name | Type | Description |
|---|---|---|
text |
str
|
The frame as compact JSON, matching what
:func: |
Source code in src/indikit/web/control_frames.py
120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 | |
Security¶
indikit.web.security ¶
The access controls in front of the web bridge: an origin allowlist and a token.
WS /ws is the bridge's entire write surface: a frame arriving there becomes
an INDI new* message on the upstream connection, which is how a browser slews a
mount or opens a shutter. Browsers do not apply the same-origin policy to
WebSockets and CORS does not cover them either, so without a check here any page an
operator happens to visit can open ws://localhost:8000/ws and drive the
instrument - cross-site WebSocket hijacking. The Origin check is therefore the
control on that surface, not defence in depth on top of one.
Two deliberate decisions live in :meth:WebSecurity.origin_allowed:
- A missing
Originis allowed. A browser always sends one on a WebSocket handshake, so refusing a request without one stops no browser; what it does stop is every non-browser peer - a Node consumer of@indikit/client,curl, the interop suite, Starlette's ownTestClient- none of which sends the header. An attacker outside a browser sets the header to anything it likes, so the requirement would cost real users and buy nothing. X-Forwarded-*is not consulted. A header any client can forge is not an authorization input. Behind a reverse proxy that rewritesHost, name the browser-facing origin explicitly with--allow-origin.
Everything here is a pure function over header strings, so it is testable without a
FastAPI request and has no import cycle with :mod:indikit.web.app.
WebSecurity
dataclass
¶
WebSecurity(token: str | None = None, allowed_origins: frozenset[str] = frozenset())
The bridge's access policy: which origins may connect, and with what token.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
token
|
str or None
|
A shared secret required on |
None
|
allowed_origins
|
frozenset of str
|
Browser origins accepted in addition to the request's own origin, e.g.
|
frozenset()
|
token_required
property
¶
token_required: bool
Whether a token must be supplied to reach /ws and /api.
build
classmethod
¶
build(token: str | None, allowed_origins: Iterable[str]) -> WebSecurity
Build a policy from loose CLI/environment values.
An empty token string means "no token", which is what an unset environment variable and an unpassed option both arrive as.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
token
|
str or None
|
The shared secret, or |
required |
allowed_origins
|
Iterable of str
|
Extra origins to accept; blank entries are ignored. |
required |
Returns:
| Name | Type | Description |
|---|---|---|
security |
WebSecurity
|
The configured policy. |
Source code in src/indikit/web/security.py
82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 | |
origin_allowed ¶
origin_allowed(origin: str | None, host: str | None) -> bool
Report whether a handshake's Origin may open a connection.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
origin
|
str or None
|
The request's |
required |
host
|
str or None
|
The request's |
required |
Returns:
| Name | Type | Description |
|---|---|---|
allowed |
bool
|
|
Source code in src/indikit/web/security.py
109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 | |
token_ok ¶
token_ok(supplied: str | None) -> bool
Report whether a supplied token matches the configured one.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
supplied
|
str or None
|
The token read from the request, or |
required |
Returns:
| Name | Type | Description |
|---|---|---|
ok |
bool
|
|
Source code in src/indikit/web/security.py
139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 | |
is_loopback ¶
is_loopback(host: str) -> bool
Report whether a bind address reaches only this machine.
Used by the CLI to refuse a network-facing bind that has no token on it. An empty host is not loopback: to a socket API it means "every interface", which is the exposure this check exists to catch.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
host
|
str
|
A bind address as passed to |
required |
Returns:
| Name | Type | Description |
|---|---|---|
loopback |
bool
|
|
Source code in src/indikit/web/security.py
36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 | |