Repository navigation
Conversation
chore: back-merge main into develop
chore: back-merge main into develop
chore: back-merge main into develop
chore: back-merge main into develop
chore: back-merge main into develop
chore: back-merge main into develop
chore: back-merge main into develop
The icons move to `assets/` at the root, where both builds can reach them: mkdocs serves only what is under `docs/`, so a hook stages them into `docs/assets/`, and the frontend's `prebuild` copies the favicon into `public/`. `favicon.ico` is generated from a small-size variant of the mark, because the full one collapses into a smudge at 16 pixels. Tracked through git-lfs, except `icon-128.png`: Docker Hub reads it through `raw.githubusercontent.com`, which serves the LFS pointer rather than the bytes -- verified, not assumed -- so the overview image would render broken with nothing in this repository failing to say so.
0002, 0003 and 0004 each added one column and nothing else. Their whole content was ADD COLUMN, plus a matching edit in load.rs, in save.rs, and in two hand-written statements a test existed solely to keep in step. Nothing ever queried admin_host or latency_min in SQL, so a column per field bought nothing and charged a migration for every field the format gained. configurations.settings and proxies.document now hold the documents; admin_tokens and mocks are folded into them. What stays a column is what SQL uses: name and revision for the compare-and-swap, ordinal for proxy order, config for the foreign key. load.rs loses twenty-five per-column helpers, and the rows are parsed by the same Deserialize impls the YAML loader uses, so a stored row is held to exactly the rules a configuration file is held to. The backfill in 0005 is checked by tests/migrate.rs, which plants rows in the old shape, applies the migration, and asserts the result parses back into the same configuration with the same revision. It has to: the revision is a hash of the canonical YAML and every write compare-and-swaps against it, so a backfill that changed one would refuse the next write as a conflict that never happened. One property moves rather than disappears: the unique index on (config, token) is gone, and rule V26 refuses duplicate token values on every write instead.
Two fields the browser dashboard needs: whether to serve it, and what heading to show. Both default to something an existing configuration gets on upgrade without being edited -- on, and "Doppel". Both are Option and skipped when absent, so the canonical YAML of a configuration that mentions neither is unchanged and no stored revision moves. Neither needed a migration, which is the first dividend of storing the document as JSON; the conformance fixture now carries both, so a store that went back to needing a column per field would fail there rather than in production. AdminTitle is a type rather than a String: non-empty, at most 64 characters, no control characters, counted in characters so the bound does not depend on the language the title is written in. It deliberately accepts markup -- escaping belongs where the HTML is written, not in a type that would forbid a legitimate heading to paper over a rendering bug.
The dashboard has to know what the caller may do, because a button for an action the server will refuse is worse than no button: the operator finds out by trying. Nothing else in the API says anything about the caller's rights. Answers 200 for everybody, anonymous included -- an endpoint whose purpose is to report that the caller may do nothing cannot itself demand a right, and it discloses nothing that attempting the six actions would not. Every field is authorize() evaluated, never a second reading of the access blocks: a copy of that decision is a copy that can disagree with the one enforcing it. the_report_agrees_with_what_the_request_actually_answers issues every action for every token and asserts a reported false comes back 401 or 403 and a reported true does not, which is what catches the drift. The per-proxy map is omitted, not emptied, for a caller who may not list: keyed by proxy name it would be a proxy listing by another route, and access.rs is explicit that a caller without read access must not be able to tell a real proxy from an invented one.
Compiled into the binary and served from the admin listener's root: the proxy list, a form over the whole proxy document including its mocks, status and reload. Most of what the admin API does, without curl. Vite, React and Tailwind, embedded by a build script rather than by a crate -- forty lines, no new dependency. A build with no `frontend/dist` still compiles and answers 503 at `/`, so `cargo install` works on a machine with no Node; CI sets `DOPPEL_REQUIRE_DASHBOARD_ASSETS` so that concession cannot apply there. Usable without a token wherever the API is: the dialog opens once and has "Continue without a token", and signing out returns to the anonymous view rather than a wall. Controls for actions the caller lacks are disabled with the reason, not hidden -- the page asks `GET /api/v1/access` what the caller may do instead of guessing. Settings reach the page as the contents of a JSON script element, which is what allows `script-src 'self'` with no `unsafe-inline`; every less-than in that JSON is escaped, because `admin.title` accepts markup on purpose. Not indexable three ways -- header, meta element and `robots.txt` -- because crawlers fail differently. The Playwright suite earned itself on the first run by finding three bugs nothing else could: every rights-gated control disabled for the life of the page (a selector returning a stable reference, so the report's arrival re-rendered nothing), a template table rendering empty cells (the API calls the field `name`, the model called it `file`), and the code editor crashing the built bundle with React error #130 while working on the dev server. Also here: the Makefile, because the assets are embedded at compile time and `npm run build` alone changes nothing about a binary; the npm packages in `THIRD-PARTY.md`; the dashboard's documentation page; and the server's complaint attached to the field it is about rather than only to the banner.
`make image` did not work on a Mac. It cross-compiled for musl on the host, and `ring` builds C, so that needs `aarch64-linux-musl-gcc` -- which nothing installs by default and whose absence arrives as "Compiler family detection failed ... ToolNotFound" from a build script rather than as anything about cross-compiling. The Dockerfile now decides. A binary staged at `dist/<platform>/doppel` or `dist/doppel` is used; when neither is there, one is compiled in a builder stage for the platform being built, and that stage is discarded -- the image stays Alpine plus the binary, 66 MB. So `docker build .` works from a checkout on any host, and the Makefile is back to knowing nothing about Linux binaries: `make image` builds the dashboard and calls docker. Three things the tests of it turned up: - `TARGETPLATFORM` is `linux/arm64/v8` on the classic builder and `linux/arm64` under buildx, so a directory named after the first is a binary nobody finds. `TARGETOS`/`TARGETARCH` are consulted as well. - A glob tolerates a missing file but not a missing parent, so `dist/<platform>/` on a laptop failed the COPY outright. Copying `dist*` and searching inside it is what makes "use it if it is there" work at all -- alongside `.dockerignore` itself, which is the source that always matches. - The release job now passes `BUILDER=alpine:3.24`: it always stages binaries, and without this it would pull a Rust image for both platforms to run a `cp`. Both refusals are deliberate and tested by hand: no dashboard in `frontend/dist` stops the compile rather than shipping a binary whose own root answers 503, and a toolchain-free builder with nothing staged says which argument caused it.
Three defects, all visible in a browser and none of them to any test that existed. **The dark theme rendered a white page.** Every component carried its own dark text colour and nothing coloured the page, so a dark theme put near-white headings on white. The page's colours are its own now, and `color-scheme` follows the choice so the browser's own controls -- a select, a scrollbar, a caret -- stop being light on a dark page. The new scenario measures contrast between painted pixels rather than checking for a class: the failure it guards measured about 1.1:1 against a 4.5:1 threshold. Painted pixels because Tailwind 4 emits oklch(), which neither a regex nor fillStyle will convert -- only a paint does. **"Add" on a header row did nothing.** The rows were derived from the map on every render, and a new row has no key, so the map could not hold it and the next render dropped it. The rows are local state now, reseeded only when the map arrives holding something they do not describe, and adjusted during render rather than in an effect -- so there is no pass showing the old rows and nothing fighting the input. **Notices never went away.** Three reloads left three of them stacked in the corner for the rest of the session. They expire on their own now, a failure lasting longer than a success, timed in the store rather than in the component that renders them: a page that navigates away would otherwise leave one behind forever. Also covers the form the way it is actually used. One scenario fills in every field of a proxy -- including a mock with all three selector maps, a JSON body and response headers -- and reads the document back through the API rather than off the screen. Another loads a proxy and saves it untouched, which must leave the document identical: a control that renders one value and writes another shows up there and nowhere else.
`/status` was both an endpoint and a page. The dashboard has a Status tab at that path, and a reload rather than a click reached the endpoint and rendered its JSON -- which is the conflict this removes: everything the API serves lives under `/api/`, and everything outside it belongs to the page. That division is what makes a client-side route survive a reload. A GET outside `/api/` and `/static/` answers with the page, so `/proxies/alpha` can be bookmarked; a path under `/api/` still answers in the error envelope, so a mistyped endpoint does not hand a client an HTML document to parse; and a missing asset stays a 404, because answering it with the page means a typo'd script tag fails somewhere else entirely. Breaking, and recorded as such: a healthcheck pointed at the old paths will 404.
The proxy table and the edit form were laid out for a narrower page than they get, and the list did not refetch when a token arrived -- so signing in left the anonymous view on screen until something else caused a fetch.
The page's own chrome. `Doppelganger` in two tones and an italic serif when nothing named this Doppel -- two spans rather than an image, so it scales with the heading, needs no asset and no `img-src`, and says the same thing to a screen reader. A deployment that named itself gets its name. The footer is one line: copyright at the left, links at the right. The year comes from the build rather than from the browser's clock, because the page ships inside the binary and the year it was published in is a fact about the build -- `httpdate` turns the stamp into a year, and it is already in the tree under hyper. And the editors: sized, coloured, and the templates form laid out in a grid rather than a flex row that mixed a field carrying a hint with one that did not.
`GET /api/v1/schema` serves the configuration's JSON Schema, generated from the same `ToSchema` impls the OpenAPI document is, cached for ten minutes and unauthenticated: it describes the shape of a configuration, not a configuration. It exists so the page's rules are the binary's rules. Every pattern, bound and enum the form refuses a value by is read from that document at load, so a field cannot drift from what the server will accept -- the failure this replaces was a regex duplicated in TypeScript and then relaxed in Rust. What needs more than one field to judge is still the server's answer on save, mapped back onto the field it is about.
…ntation Three things that make the form readable, and the reference they point at: - Syntax colouring where a value has a syntax: a mock's path pattern as a regex, a proxy document as YAML, and Jinja expressions wherever a value is rendered through the template engine -- including inside a JSON body, which needed the two grammars composed by hand. Prism gives overlapping greedy patterns to whichever starts first, so a single top-level Jinja token silently misses every expression inside a string. - An (i) beside nearly every field, linking to that field's entry in a generated parameter reference, with the version of the running binary in the URL. The page is generated from the schema by `scripts/parameters_doc.py` -- 82 parameters, 73 with an example -- with a `--check` gate so it cannot drift, and `scripts/check_docs_links.py` resolves the links the page builds against the built site. A footnote marker rather than a button: it navigates, and a button that navigates lies to a keyboard. - A YAML editing mode for the whole proxy: one editor with colouring, a Tab that indents rather than leaves the field, formatting on save and on a button, a link to the documentation, and live validation against the running schema -- a message under the editor rather than a save that fails. `@cfworker/json-schema` rather than ajv, which compiles a schema with `new Function` and cannot run under a policy with no `unsafe-eval`; `yaml` rather than `js-yaml`, whose `argparse` is Python-2.0 and outside the dependency licence policy.
`/etc/doppel/main.yaml` as a single-file bind mount cannot be saved to: a save writes a temporary file and renames it over the target, and a rename onto a bind-mounted file is EBUSY. Reproduced in a container before and after -- `mv: can't rename '.probe': Resource busy` -- and the fix is in three places: the compose file and the documentation mount the directory, and the error names the mount and says which to change instead of reporting the errno. `main.example.yaml` leaves `admin.title` out, and says why in a comment.
Six changes from watching the page be used, and one of them is the removal of another: templates were editable in the form, and that was the wrong shape -- a file store inside a form over a document, with a Save of its own that did not wait for the form's, and a rule the page could not enforce (the server refuses an upload no saved mock declares). They stay in the admin API, where `doppel config push` and any script already write them. A mock that answers with a template file shows which file, in a field it will not let you edit, and can be moved onto a text or JSON body instead. The rest: - A proxy can be renamed through an update, and its template directory moves with it. A collision is refused before anything is written, and a rename that moved the files but could not save the document reports both names. - A save that would *remove* something asks first, and names what: each injected header, access override, mock, entry of a mock's four maps, and a template file the answer was switched away from. A changed value is not confirmed -- it is visible in the field the operator is looking at. Deleting a proxy asks too. - Refresh on a refusal, beside the button that enters a token. A form reads once when it opens, so signing in afterwards used to leave the refusal on screen with nothing to press. - The page stopped reporting its own policy violations to the console. `react-simple-code-editor` renders a `<style>` per editor and the policy allowed no inline stylesheet, so an operator reading the console for their own problems found dozens of ours. The policy names that one stylesheet by hash, recomputed by a test so an upgrade of the package fails the build rather than the console. - The wordmark does not take a selection, and the title is a link home.
`docker build .` refused on a fresh checkout: the compile branch needs frontend/dist, because the admin crate embeds whatever is there and an empty directory yields a binary that starts, serves the API and answers 503 at its own root. The refusal said to run `make frontend` first, which is a thing you have to know. It builds the dashboard itself now -- nodejs and npm installed in the builder stage for exactly that, and only on the path that needs them. Building it on the host stays faster and stays what `make image` does; it is no longer required. The context grew by what `vite build` reads: the package files, index.html, the vite and tsconfig, src/, scripts/, and the favicon the prebuild step copies out of assets/. Listed one by one rather than admitting frontend/, because node_modules is larger than everything else in this context together. The one refusal left is the honest one: neither a built dashboard nor the sources to build it, which means the context is not what it should be.
The verification section named the Rust three, from when those were the gate. `make gate` is ten targets now -- it builds the dashboard, runs both Rust suites, jest, the bundle budgets, mkdocs and three generators -- so running it after each task spends minutes re-checking what the task did not touch. Same rule as before, with the table the section was missing: what to run for a crate, for frontend/src, for a browser behaviour, for docs, for the schema, for a dependency. The gate stays where it was: at the end, once.
`authorize()` answers every action as public while `admin.public` is set, so a proxy's `access` block decides nothing there -- the process already says as much among its startup advisories. The form offered the four fields anyway, which is the page disagreeing with the binary serving it: an operator sets `delete` to a group, saves, and the next anonymous request deletes the proxy. The section is left out entirely rather than disabled. There is no state in which those fields could be filled in usefully, and a disabled control invites the question of what would enable it. A proxy whose document still holds overrides -- the state an operator reaches by turning `public: true` on over proxies they had already restricted -- says so in one line where the section was. Without that, the YAML mode shows an `access` block the form has no section for, and nothing accounts for the difference. Nothing is dropped: the document keeps what it held, and a save preserves it. Covered by an e2e test with its own instance, over both answers: `alpha` holds an override and `beta` holds none. Mutation-checked twice -- rendering the section unconditionally, and never rendering the line -- and each failed.
Four screenshots: the proxy list at the top of the page, the token dialog where the access rules are explained, the form beside the paragraph that describes it, and the YAML mode after the document it shows. They live in `docs/screenshots/` rather than `assets/`. The icons are in `assets/` because two builds need them -- mkdocs and the frontend's favicon -- and these are read by mkdocs alone, so the directory that says so is the docs tree. `docs/assets/` was not an option: it is git-ignored, being what the build hook stages the icons into. `*.webp` goes through git-lfs, for the reason the icons do: a screenshot is a new whole file rather than a diff, and every visible change to the page brings one. Every workflow that builds the documentation already checks out with `lfs: true`, which is what stops this from shipping a pointer file as a broken image. The year and the version are blurred out of the footers. They are the two things in a screenshot that date it, and a reader who compares them against the version they are running learns nothing except that the picture is older.
`config/` without a leading slash matches a directory of that name at any depth, so it covered `crates/doppel-core/src/config/` as well as the one the compose file mounts. Nothing broke: git keeps tracking files it already knows, and those eight are tracked -- but every editor that reads `.gitignore` drew the crate's own configuration module as excluded, and a new file added there would have needed `git add -f`. Anchored, along with `main.yaml` beside it, which had the same shape.
…pect Two paths that were answered wrongly, both found by using them rather than by reading them: - `GET /metrics` returned the dashboard. Moving the exposition under `/api/v1/` was recorded as breaking on the grounds that a scrape of the old path would 404 -- it did not, because everything outside `/api/` and `/static/` is answered by the page, so a scraper received `200 text/html` and 1412 bytes of HTML. Measured against a running container. `/metrics` is the exposition again: that path is `metrics_path`'s default in Prometheus and in every agent and annotation that scrapes one, and it cannot collide with a page. - A trailing slash was a 404 everywhere. Axum stopped redirecting between the two spellings in 0.8, so `/metrics/` answered nothing while `/metrics` answered, and a scrape config or a curl carrying a slash got nothing with the endpoint sitting right there. Trimmed before routing, because a route layer runs after routing has already decided there is nothing there. The proxy listener gets none of the second: a proxied path is relayed byte for byte, `/orders/` and `/orders` are two resources upstream, and one of this project's own mocks matches `^/api/v1/resource/9/$`.
A rewritten `Location` was root-relative: an upstream answering `Location: https://api.example.com/v2/orders/7` under that base produced `/orders/7`. Correct, and enough for a client that resolves it against the URL it used -- but nothing else. Anything that logs, stores or prints the header gets a path with no host in it, and the case the relative form cannot express was left escaping altogether: a redirect to the upstream's own host *outside* the proxied path was relayed pointing at the upstream, so the client walked off past every fault and every mock. Both now name Doppel. Outside the base the path is kept as the upstream wrote it, which is what `nginx`'s `proxy_redirect` does; whether Doppel serves that path is a question about the configuration, and one that shows up in the logs rather than as a client quietly talking to the backend. A redirect to another host is still left alone -- Doppel does not proxy it, and naming itself would be a lie. That needs an address Doppel cannot infer, because `Host` is a claim by the caller and building a redirect out of it hands the caller the redirect. So: 1. `DOPPEL_EXTERNAL_URL` 2. `server.external_url` 3. `server.host` and `server.port`, with a wildcard bind read as loopback The third makes the common case work with no configuration at all, and is the one place this can be wrong: behind a port mapping or an ingress the client used neither that address nor that port. Startup logs which address it settled on, so that is one line away from being checked. The variable is read like `DOPPEL_ADMIN_TOKENS` -- validated at startup, never merged into `Config`, because the revision is computed over the document and folding the environment in would make two instances reading one document disagree about it. `ExternalUrl` is held to the same rules as an upstream base through one shared `parse_base`, so the two cannot drift. Five unit tests over the rewriting rules and four over the address resolution, plus two through the built binary -- the wiring is what an operator reported broken, and only an end-to-end test covers it. The shared test upstream learned one redirect to its own authority for them. Mutation-checked by dropping `with_external_url` at the call site: both end-to-end tests fail, one seeing the old relative form.
This is the `mocks` flake, root cause and fix. It failed about one run in twenty-five with `ECONNRESET` on a request the server never logged, the server still alive and still listening -- 6 failures across 540 runs while I measured it. `free_port` bound `127.0.0.1:0`, took the port the kernel offered and closed the listener. `std` sets `SO_REUSEADDR` on every `TcpListener`, and macOS then allows `127.0.0.1:P` to be bound while another process holds `0.0.0.0:P` -- the more specific address wins for connections to it. So the probe could draw a port a server under test was already serving on, shadow it for the microseconds before the listener dropped, and reset whatever connection landed in that window. The `mocks` suite is the one that runs `main.example.yaml` with its `host: 0.0.0.0`, which is why the flake lived there. Proven rather than reasoned: a wildcard listener serving, a second socket bound to `127.0.0.1` on the same port, a request to it, the shadow closed without accepting -- `[Errno 54] Connection reset by peer` at the client, and the real server never saw the connection. That is the fingerprint, down to the missing log line. A wildcard probe cannot do it: the kernel refuses a second wildcard bind on a busy port with `EADDRINUSE`, so a collision becomes the lost-port race `start_with_env` already retries. 500 runs, 0 failures. Also ruled out along the way, each by measurement, so nobody re-treads them: descriptor exhaustion, pipe-buffer exhaustion, ephemeral port exhaustion, and a "wedge" from an earlier session that turned out to be the probe's own single-threaded keep-alive upstream serialising eight forwards.
A `PUT` that changed an upstream answered `200`, wrote the document, and left the proxy forwarding to the old host. Reproduced on a running instance with two upstreams: the API read the new URL back, `/api/v1/status` reported the old revision, and the traffic did not move until `POST /api/v1/config/reload`. The API agreed with the operator while the process disagreed with both, and nothing anywhere said the two had parted. `POST`, `PUT` and `DELETE` now promote the stored configuration before replying, through the same function and the same mutex the reload endpoint uses, so the three cannot swap runtimes in the wrong order. Authorization is not repeated: the caller was authorized for the write, and this is the second half of it rather than a promotion of its own. When the promotion fails the response names both halves -- stored, not running, reload retries -- in the shape `renamed_but_stranded` already uses for the other write that cannot be undone. The write stands, because it is not this process's to take back. Reload keeps the job it is actually for: a configuration changed *behind* the API, which is the one thing no handler can promote for itself -- `main.yaml` edited by hand, `doppel config push`, another instance on the same database. Three tests changed sides, and each says something. `status_reports_what_is_running_not_what_is_stored` still holds, now written straight to the store, which is where that property lives. The one that said a proxy created over HTTP "serves traffic after a reload" says without a reload. And the form's end-to-end scenario had to start reading its own proxy back as `reader`: it writes `access.read: reader` through the form, and that override is now in force by the time the save answers -- root got a 403, which is the override doing exactly what the page said it would. Two new: the runtime's own view after a create, an update and a delete, and the whole chain through the built binary -- two upstreams, both listeners, a `PUT`, and a proxied request that comes back from the new one. Mutation-checked by dropping the call from `update`: both fail, one showing `/api/v1/status` still naming the old upstream.
…ponses `/api/swagger-ui/` answered `303 See Other` with `location: /api/swagger-ui/` -- itself. A browser followed that until it gave up. The trailing-slash rewrite added yesterday was in front of it: `utoipa-swagger-ui` answers the bare path with a redirect to the slashed one, the layer trimmed the slash back off, and the pair became a loop. Measured as exactly that before the fix. `tower-http`'s `NormalizePathLayer` trims unconditionally and cannot exempt a subtree, so the trimming is ours now: fifteen lines, one exception for `/swagger-ui`, and `/` left alone because an empty path matches nothing. The UI is `/swagger-ui/` and the document is `/openapi.json`, both outside `/api/`. Neither is a resource of the API -- one is a page served to a browser, the other describes every version this binary knows -- and both are where the tools that consume them look by default, the way `/metrics` is. It also stops an ingress routing `/api/*` to a JSON service from handing a browser HTML. The listener compresses what it sends, `br` or `gzip` by negotiation, `br` first when a client takes both. The dashboard is 140 KB of JavaScript and CSS and was going out uncompressed on every visit -- embedded as bytes, served as bytes -- and the Swagger UI's own stylesheet is another 150 KB. That is `tower-http` again, which is why it stays a dependency: same crate, different feature, already in the graph through utoipa-swagger-ui. Seven crates join the notices, all inside the licence policy. The proxy listener is deliberately left out: it relays the upstream's own encoding, because that is part of what a client is being tested against. The e2e suite gains the coverage that would have caught the loop, in a real browser: the UI renders and lists an operation out of the document with no request over 400, the bare path reaches it without looping, the exposition is Prometheus text under both spellings, the document names the paths this binary serves, an asset comes back `br`, `gzip` or plain by what was asked for, and the page at `/` carries the *substituted* configuration rather than the placeholder that ships in `index.html`. The harness hands out the proxy URL now, because an exposition with no traffic behind it has no series to assert on.
The one file this listener cannot cache: the assets are content-hashed and cached for a year, and `index.html` is rebuilt per request with the configuration spliced into it, so its comments and indentation were paid for on every page load. 1422 bytes to 748. `html-minifier-terser` in a `transformIndexHtml` hook, build-time only, and deliberately conservative -- two things in that file are load-bearing and would fail quietly if minification touched them. `id="doppel-config"` with double quotes is how `dashboard.rs` finds the element to splice into and what `build.rs` asserts is present, so `removeAttributeQuotes` is off and named in the comment for whoever considers turning it on. `minifyJS` is off as well: the element is `application/json`, and handing JSON to a JS minifier is a way to find out what it does with it. Verified where it would break rather than where it is easy: the binary builds (so `build.rs` still finds the element), 119 jest tests pass, and the browser suite reads the substituted configuration out of the served page -- title, version and `titleIsDefault` from the configuration Doppel was started with, not from the file.
…s exist
Six changes, one theme: a scrape should answer the questions an operator has
during an incident, and answer them from a process that has served nothing yet.
`doppel_proxy_request_duration_seconds` carries `replace`, `loss` and
`upstream_error`, each `1` or `0`. Three independent flags rather than one enum,
because they are not exclusive: a mock can answer a request its own loss roll then
drops, and `upstream_error` covers a transport failure, a timeout and a relayed
5xx. Its buckets now reach 15, 30 and 60 seconds -- `latency` injection is asked
to be slow on purpose, so those are real measurements rather than outliers.
`doppel_admin_request_duration_seconds{route,method,status}` is the admin API's
own latency, labelled by the route template: `/api/v1/proxies/{name}`, never
`/api/v1/proxies/alpha`, so a hundred proxies are one series and a query string is
none. The middleware sits inside routing, which is what makes `MatchedPath`
available, and inside compression, so it measures the work rather than the
deflating. Its ladder stops at five seconds, because nothing here has any business
above that.
Four series exist from startup, before anything happens:
doppel_build_info{version="..."} 1
doppel_dashboard_info{enabled="true"} 1
doppel_proxy_last_error_timestamp_seconds{code=""} 0
doppel_proxy_mocks{proxy="..."} N
A panel and an alert both read a never-recorded metric as "no data", which is
indistinguishable from a process nobody is scraping -- so the facts that are true
at startup are published at startup. Build and dashboard are two info metrics
rather than one: the first describes the artifact and never changes, the second
what this deployment turned on, and folding a flag into build info makes two
builds of one version look different. The last error is a timestamp because the
question is "is this still happening", which `time() - metric` answers at any
scrape interval; a counter needs a rate window to say anything about now.
`doppel_proxy_mocks` follows the configuration in force rather than the one the
process started with: it is published from `RuntimeHolder`, so startup, a reload
and a write through the admin API all go through it. A proxy that leaves the
configuration is set to zero rather than abandoned -- the exporter cannot delete a
series, and a stale one reads as a proxy that still exists with three mocks.
Nine unit tests, one for the middleware, and the e2e suite asserts both halves in
a real browser: that the always-present series are present in a process that has
served nothing, and that two requests to one route with different parameters and a
query string produce one templated series with a count of two. Mutation-checked by
labelling with the raw path, and by leaving a departed proxy's gauge alone.
The field sat against the sentence explaining it, so the dialog read as one block. `mt-3`, the same gap the buttons below already had. Screenshot replaced, with the year and the version blurred out of the footer as on the others. Tokens through the API, both ways round, which is the half the page cannot show: - Private. The header names the caller -- `root`/`admin`, `reader`/`user` -- and the rights report matches what a write then does. An unrecognised token is *equal* to sending nothing, asserted by comparing the two responses, because answering differently would confirm which tokens exist. A refused write is 401 without a token and 403 with one that may not write, each naming the action, and nothing is written either way. - Public. There is no way to set a token from the page, so the remaining route is one this browser kept from when the deployment was private: it must not resurrect the sign-in flow, and it does not -- no dialog, no Sign in, no Sign out, still anonymous, still allowed everything. Through the API a stale token is ignored rather than punished, which is what "unauthenticated" has to mean: a client that keeps sending its old header must not start getting 401s because a deployment went public.
`rewrite_redirects` keeps a client inside Doppel when the upstream answers a redirect. A body does the same thing one layer down and was not covered: a page, a script or a JSON document naming `https://api.example.com/v2/orders` sends the client straight to the backend on its next request -- past every injected fault and every mock, with nothing logged and nothing failing. `nginx` has `sub_filter` for this. `rewrite_urls`, per proxy, on by default. A URL under the proxied path loses that prefix, because that is where Doppel serves it; one on the same host outside the path keeps its own, exactly as a rewritten redirect does; and Doppel's own path prefix survives, so a deployment behind `https://gw.example.com/doppel/` stays behind it. **Only the exact host**, and that is the part worth reading twice. The needle includes the scheme, and a match is rejected when the next character could continue a hostname -- a letter, a digit, a dot or a hyphen. So `https://cdn.api.example.com` is a different host and is left alone, and `https://api.example.com.evil.test` is not turned into Doppel's address with somebody else's suffix glued on, which is what a plain string replacement would have done. Both have tests. Three limits, each relaying the body untouched rather than guessing: text only by content type, uncompressed only, and bounded by the proxy's `body_limit` -- a bigger body streams on from where the buffering stopped, since rewriting needs the whole thing. A body declared as text that is not valid UTF-8 goes out as it came. A rewritten body is not the entity the upstream sent, so `ETag` and the digest headers go with it and `Content-Length` is restated. A conditional request carrying the upstream's validator would otherwise be answered `304` for content the client has never seen. Nine unit tests over the rules, one through the built binary -- an upstream page naming its own host and a `cdn.` sibling that must survive it -- and the form and the generated parameter reference carry the field. Mutation-checked by disabling the rewrite at the call site: the end-to-end test fails with the upstream's own address in the link.
…it Doppel A deployment that names itself got its name in the page's sans, beside a footer that read as Doppel's own. Both now belong to it: - The title is set in the wordmark's italic serif at the same size, split into words and toned like `Doppel` + `ganger` -- the first word strong, the rest in the accent colour. Three boundaries, because a name uses all of them: a space, an underscore or a hyphen, and a case change. Every character survives; `billing_api` reads as `billing_api` with `_api` toned, not as two words with the underscore eaten. An acronym is one word (`APIGateway`), and a single word is not drawn in the accent colour for no reason. - The footer says "Built with Doppel <version>" before the copyright, links to "Doppel Documentation" rather than to "Documentation", and drops the repository link -- on `billing-api (staging)`, "Repository" reads as a link to the billing API's own, which it is not. All of it only when `admin.title` is set. An unnamed Doppel is Doppel's own page and keeps the footer it had, which the e2e suite now asserts from both sides. The token dialog says "Access token": the token is what the API takes from any caller, and `admin` in that heading read as a claim about which token it wants. And the stored token's hour slides. Every request the page makes with a token restarts it, so an operator working through the dashboard is not asked to type it again in the middle -- while one who walked away still loses it an hour after their last request, which is the claim the lifetime was always making. It cannot resurrect an entry that has already aged out: `loadToken` decides that, and `touchToken` asks it first.
CI failed on `a rejected document lands on the field the server complained about`, and the failure was in a helper rather than in the page: `fieldMessage` read `aria-describedby` with a single `getAttribute`, so it held `null` while the server's refusal was still in flight. The assertion after it retries, which is why this was the line that fell over. That run took 34 seconds for a suite that takes 15 on a laptop, and six local repeats of the scenario passed. Both helpers wait now. `paintedRgbAll` had the same shape -- `all()` returns whatever matches at that instant, while the spans it is pointed at are the highlighter's and appear on a React update rather than with the keystroke -- and was found by looking for the pattern rather than by waiting for it to fail.
feat: a browser dashboard on the admin listener
The pre-release pass, and it found four things that were not true any more. CHANGES.md described the journey rather than the destination. Three entries said `/metrics` came back, the Swagger UI moved and every endpoint went under `/api/` -- and the net effect against 0.4.1 is that `GET /status` is now `/api/v1/status` and nothing else moved, which is checked against the routes at the tag rather than remembered. The dashboard entry still claimed it manages mock templates, which it stopped doing before it shipped. Every entry is now inside the twenty-five words the changelog asks for, sixteen in total. `docs/usage/configuration.md` was missing `server.external_url` and `rewrite_urls`; both existed only in the generated reference, and the rule is that every field in the configuration appears in the hand-written one too. `DOCKERHUB.md` claimed the dashboard edits templates, and said nothing about `DOPPEL_EXTERNAL_URL` -- which is precisely the environment where Doppel's guess at its own address is wrong, since a port mapping is invisible from inside the container. It now has the `docker run` form, the compose line commented out for the case where the ports match, and the same fact is in `docs/usage/docker.md`, because a fact that changed in one and not the other is how those two rot. The overview's example tag goes back to the placeholder `1.2.3`: the release workflow rewrites it, so a real version there is a second number to remember. `pre-release-check` gains a step for that file. It is the only page published somewhere this repository cannot see, nothing fails when it goes stale, and it has now been wrong once -- which is the definition of a check worth writing down. Against the code, against the Docker page, against the dashboard, plus the placeholder and Docker Hub's 25000-byte truncation. The compose file's comments are shorter by a fifth, and its `DOPPEL_EXTERNAL_URL` says why it is there: 8080 inside, 58080 published, and a rewritten redirect would otherwise name a port nobody can reach.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Doppel 1.0.0. The browser dashboard is the headline, and the rest of this cycle
is what running it found.
What an operator gets
A dashboard on the admin listener's root, compiled into the binary: the proxy
set, a form over every field of a proxy including its mocks, status and reload.
It is a client of the admin API and bound by the same token rules, so it offers
only what the caller may actually do -- and it works without a token wherever the
API does.
Two endpoints it needed, both useful without it:
GET /api/v1/accessreports thecalling token's own rights, and
GET /api/v1/schemaserves the configuration'sJSON Schema, which is what the page checks a field against as it is typed. One
statement of every rule, in the binary.
Doppel now knows its own address (
server.external_url,DOPPEL_EXTERNAL_URL,or
host:port) and uses it twice: a rewrittenLocationnames it, and so do theaddresses in the text bodies a proxy relays (
rewrite_urls, exact host only).Both exist to stop a client walking off to the real backend past every injected
fault and every mock.
A write through the admin API is in force when it answers. It used to write the
document and leave the proxy forwarding to the old upstream until somebody
reloaded, which is the one defect here an operator reported from the outside.
Metrics answer more, and answer before anything has happened:
doppel_build_info,doppel_dashboard_info,doppel_proxy_last_error_timestamp_seconds{code=""}anddoppel_proxy_mocksexist from startup, because a panel reads a never-recorded metric the same way it
reads a process nobody is scraping. The proxy histogram carries
replace,lossandupstream_error; the admin API has its own, labelled by routetemplate.
Breaking
GET /statusisGET /api/v1/status./metrics,/openapi.jsonand/swagger-ui/are where they were in 0.4.1 -- they moved and moved back duringthis cycle, and the changelog records the net effect rather than the journey.
Why 1.0.0
The
Changedsection carries a breaking entry, and after 1.0 that is a majorbump; before it, the same entry would have been a minor one. The project has been
run against a real deployment now, which is the other half of calling it 1.0:
every fix in this cycle after the dashboard landed came from using it rather than
from reading it.
Verification
make gateclean with the PostgreSQL suite included -- 795 Rust tests, 127 jest,eslint, both TypeScript projects, the size budgets, a strict docs build, the
schema, the parameter reference, the documentation links and the licence check --
and
make e2eclean, 81 Playwright tests. The release scripts were dry-run:release_notes.py 1.0.0composes a body from the section this branch writes.CI runs the whole thing again here. The gate above was captured before the
release commit itself, which changes
Cargo.toml,Cargo.lockand CHANGES.mdand nothing else.
After the merge
The tag is cut from
main, which is the only place a final tag may come from,and pushing it is what publishes: