Skip to content

v0.1.22 HTTPS by default, importable TLS certificates, a network map that draws itself, derived alert suppression, LDAP/AD sign-in, unmonitored-host discovery, MCP system health, half the memory

Choose a tag to compare

@horryworks horryworks released this 05 Aug 03:37
· 33 commits to main since this release

📋 Full release notes: RELEASE_NOTES.md

Images (published by CI to GHCR; :latest now points at this release):

ghcr.io/horryworks/yagra-core:v0.1.22
ghcr.io/horryworks/yagra-poller:v0.1.22
ghcr.io/horryworks/yagra-web:v0.1.22

⚠️ This release moves the WebUI to HTTPS on port 443 and removes the plain-HTTP listener. Read the breaking changes below before upgrading — http://<host>:3000 stops answering, everyone is signed out, and SSO needs its redirect URI updated in two places.

Breaking changes

  • The WebUI is now HTTPS, on port 443, and there is no plain-HTTP listener. Everything the UI
    carries — the login password, bearer tokens, and device credentials on their way to being
    encrypted — used to cross the network in the clear by default. v0.1.9 shipped instructions for
    fixing that and a commented-out configuration block; a year later nobody had uncommented it. So
    the secure shape is now the one you get by doing nothing.
    • http://<host>:3000 no longer answers. If your .env still sets YAGRA_WEB_PORT=3000 you
      keep port 3000 and it becomes https://<host>:3000 — the port stayed, the scheme changed.
      Delete the line to land on 443.
    • A redirect was considered and deliberately rejected. Most webhook senders do not follow
      redirects, and those that do turn a 301 on POST into a GET, so
      POST /api/v1/ingest/webhook/:source_id would have gone on returning success while events
      stopped arriving. Connection-refused is the failure you can see. Move webhook senders and any
      other machine client
      to https://<host>/api/v1/…, or to core's unchanged plaintext
      http://<host>:8080/api/v1/….
    • Everyone is signed out and loses their saved UI state. http://host:3000 and
      https://host are different origins, so the session token, dashboard layouts, theme and table
      preferences do not carry over. There is no migration for this that would be worth its
      complexity; sign in again and the layouts are rebuilt as you go.
    • SSO stops working until two things are updated. The stored OIDC redirect URI is an absolute
      URL and must now use the new scheme and port, and the same value has to be updated at the
      identity provider. Settings ▸ Auth shows a warning when the stored value no longer matches the
      address you are browsing from. Yagra will not rewrite it for you — changing where an IdP is
      permitted to send an authorization code is not something an upgrade should do on your behalf.
    • Your browser will warn on first visit. The certificate is self-signed until you import one,
      and because Yagra cannot know the hostname you will use, the name usually will not match either.
      Import a real certificate at Settings ▸ TLS, or regenerate the self-signed one with the
      correct names from the same page.
    • Set YAGRA_WEB_TLS=off to keep serving plain HTTP from the container — the supported shape
      when an external reverse proxy or load balancer already terminates HTTPS in front of it.
    • Core's own API port is unchanged: still plaintext, still published on the LAN. That is
      deliberate sequencing, not an oversight — closing it in the same upgrade that introduces an
      untrusted certificate would break every Prometheus scrape and API script at once, with two
      overlapping causes. Once those clients are on the TLS edge with a certificate they trust, set
      YAGRA_API_BIND=127.0.0.1 to take it off the network. Settings ▸ TLS shows the current state.
    • If you maintain your own compose file, core's container now has a fixed group as well as a
      fixed user — both 10001.
      The certificate bundle is written 0640 owned by that group, and
      the web container joins it with group_add: ["10001"] in order to read it. Leave the group to
      the base image and nginx traverses the directory, is refused the file itself, and the WebUI
      never comes up while every other signal stays green. The shipped compose files already do this.

New Features

  • Import your own TLS certificate from the WebUI. Settings ▸ TLS shows what is being served —
    subject, issuer, the names it covers, expiry and fingerprint — and takes a PEM certificate chain
    and private key, either pasted or from a file. The new certificate is live within seconds, with
    nothing restarted. Yagra will not accept a pair that does not go together, has already expired,
    or carries no subject alternative name, and says which of those it is rather than failing at the
    next handshake. Encrypted private keys and .pfx bundles are refused with the openssl command
    that converts them. The self-signed certificate can be regenerated with the hostnames and IP
    addresses you actually use, and renews itself before it expires.
    • The private key is envelope-encrypted at rest like every other secret and is never returned by
      the API. The certificate is downloadable, so you can hand it to a Prometheus ca_file, a
      curl --cacert, or an operating-system trust store.
    • System Health says when it is about to expire. An expired certificate takes the whole UI
      down — including the page you would use to fix it — so it is one more row there, and reaches the
      MCP get_system_health tool with it. Degraded is reserved for something somebody has to act
      on: expired, or an imported certificate inside its last 30 days. A self-signed one nearing
      expiry renews itself and is not reported as a problem. The same fact is on the Prometheus
      endpoint as yagra_web_tls_expires_in_days, for your own alerting.
    • This does not manage the NATS bus certificate, which the bus reads for itself at startup.
  • An AI client can now ask whether Yagra itself is healthy. The MCP surface had no way to see
    the monitoring system's own state, which matters more than it sounds: with no way to tell that a
    poller is offline or a store unreachable, a model reads missing data as a healthy quiet and
    reports that the fleet is fine. Six new tools close that (/mcp, off by default):
    • get_system_health(section=…) — the poller fleet and per-pool summary, poll-loop counters,
      which nodes one poller holds, which poller owns one node, recent core↔poller outages,
      per-store reachability, core/poller host resources and their trends, forwarding delivery
      status, whether stored credentials still decrypt, the running version, and which optional
      tiers are enabled.
    • get_report_runs, get_audit (who changed or acknowledged what),
      fleet_state_history, get_dns_chain, and run_rca — an LLM explanation of one
      incident, the same one the WebUI's "Explain this incident" produces.
    • get_fleet_summary(kind="coverage") answers which nodes have actually reported recently,
      with a watchlist of the ones that have not.
    • Sections require different permissions, matching the WebUI exactly: most need view,
      forwarding status needs manage-config, credential health needs manage-credentials, the audit
      log needs view-audit, and run_rca needs ack-alerts. Read-only does not mean
      readable-by-anyone. Reports and the state timeline refuse a group-scoped token rather than
      showing it the whole fleet, as the REST endpoints do.
  • The Network map now draws the network, not a list of parent links you typed in. Yagra derives
    the connectivity graph from what devices report: CDP/LLDP adjacency, and nodes that have an
    interface address in the same IP subnet. Two nodes sharing a subnet are adjacent as a matter of
    fact, so a map appears without anyone entering a single link. Each edge carries the evidence
    behind it — LLDP, CDP, or shared subnet — and the map labels and legends them. Redundant paths are
    kept rather than collapsed: a server reached through two routers shows both links. Drawing the
    map is all this does on its own
    ; alert suppression keeps following the dependency graph you
    maintain by hand until you deliberately hand it over, which is the next feature down.
  • A new read endpoint, GET /api/v1/topology/links, returns that graph in keyset pages, with a
    summary of everything the derivation observed but declined to turn into a link (unmatched
    neighbours, ambiguous management addresses, segments with no identifiable router). A group-scoped
    caller sees only links whose both endpoints are visible to them.
  • The network map now finds the links that share no subnet. Until now a link was derived from
    two devices holding an address in the same prefix, which structurally cannot see a point-to-point
    /32 (a PPPoE Dialer, a tunnel endpoint), an unnumbered OSPF link, or a peering across a
    segment whose addressing has not been collected. Yagra now also reads each device's OSPF
    neighbours and BGP peers
    , and asks its routing table about specific destinations — so those
    links appear on the map with OSPF neighbor, BGP peer or Connected route as their evidence.
    A link seen both ways is still one link carrying both.
    • On by default, at the same hourly cadence as the other two automatic walks, and switchable
      at Settings ▸ System settings ▸ Discovery walks. Unlike the ARP walk, the tables it reads are
      sized by the device's own peering mesh, not by the network.
    • The routing table is never walked. A router carrying a full table has hundreds of thousands
      of routes, so Yagra asks about one destination at a time — and only a device that holds a host
      address of its own is asked at all, capped at 64 destinations. On a fleet of ordinary devices
      this issues no route queries whatsoever.
    • A down session still draws its link. A BGP session in active is a link with a fault, and
      that is usually the thing being investigated — making the topology disappear in step with the
      outage it exists to explain would be exactly backwards.
    • An iBGP session between loopbacks does not become a link. A BGP peer is only treated as
      adjacent when it sits on a network the reporting device terminates, so a route reflector does
      not acquire a false star to every client it peers with. The count of peers declined this way is
      reported alongside the map's other diagnostics.
    • Known limits, stated rather than half-answered: BGP4-MIB is IPv4-only, so IPv6 BGP peers are
      out of scope
      ; OSPF collection is OSPFv2; and virtual links (ospfVirtNbrTable) are not read.
      One older limit also stands: a segment with more than two members where no member can be
      identified as routing for the others produces no links rather than a guessed one, and is
      counted in the map's summary instead.
  • Yagra can now tell you what is on your network that it is not watching. Turn on the new
    ARP / IPv6 neighbor cache walk (Settings ▸ System settings ▸ Discovery walks) and every
    monitored router reports the hosts it has actually spoken to. Anything not already in the
    inventory appears under Nodes ▸ Discovery ▸ Seen on the network, with the address, its MAC,
    and which device saw it on which port — and a Monitor button that turns it into a node through
    the same import path a subnet scan uses. No scan required; it is a by-product of the polling you
    already do.
    • ⚠️ Off by default, deliberately. Unlike the other two discovery walks, this one reads a
      table sized by the network rather than by the device — thousands of rows on a campus switch —
      so an upgrade will not start issuing it against your fleet. The default cadence is six hours.
    • The list says which kind of empty it is: "no device has reported a cache yet" (nobody looked)
      reads differently from "0 unmonitored addresses" (nothing to find), and if any router's cache
      hit its row budget the list declares itself a sample rather than a complete answer.
    • Endpoints are deliberately not drawn on the network map. An unmonitored host has no state
      to show, and a few thousand stateless boxes would bury the nodes that do. Importing one makes it
      a node, and the ordinary derivation picks it up from there.
    • New: GET /api/v1/discovered-endpoints (keyset-paged, group-scoped through the observing node)
      and POST /api/v1/discovered-endpoints/{id}/import, plus the MCP tool
      list_discovered_endpoints.
    • Rows age out seven days after they were last seen, and the table is capped fleet-wide.
  • The interface-address walk finally has a UI. It has been running since it shipped, but the
    settings card only ever knew about CDP/LLDP. Settings ▸ System settings now shows all three
    discovery walks — neighbours, interface addresses and ARP — each with its own switch and cadence.
  • One box can be excluded from derived suppression entirely. Tick Never suppress against a
    node on Topology ▸ Dependencies (or PUT /api/v1/nodes/{id}/suppression-opt-out) and its
    alert always stands on its own, whatever the discovered graph says. The node keeps its place in
    the graph, so everything behind it still resolves through it. This only ever removes
    suppression, so it cannot cause an outage to go unreported — which is why it is a per-node switch
    where per-edge approval was rejected.
  • The comparison now shows how much of the fleet the derived graph actually covers: how many
    nodes would get an upstream, how many are excluded by hand, and how long the deployment has been
    comparing. Advisory, not a gate — the one blocking condition is still an unplaced poller.
  • See what the derived graph would do to your alerts, before it does anything.
    Topology ▸ Dependencies gained a mode switch with three positions. The hand-authored graph
    is the default and is what every existing deployment stays on. Comparing changes nothing about
    alerting and shows, node by node, where the graph Yagra derived and the one you maintain by hand
    disagree — plus the two numbers that matter: how many active alerts the derived graph would
    newly suppress
    (the risky direction — each of those is an alert that would stop being raised)
    and how many it would stop suppressing. The derived graph hands suppression over. Nothing moves
    between these on its own; an upgrade lands on the mode you were already on.
  • Dependency suppression can now have more than one upstream per node. The derived graph gives a
    node every neighbour that sits one hop closer to a poller, so a server reached through a redundant
    pair of routers gets both as parents — and its alert keeps standing while either one is alive.
    This is what is_suppressed's "suppressed only when every parent is down" rule was written for;
    a single hand-typed parent_id could never express it.
  • Correct a wrong link instead of working around it. New endpoints record operator decisions
    about a link — pin it into existence, hide it, or declare which end is upstream — and those
    always beat what was derived, on every recomputation. GET/POST /api/v1/topology/link-overrides
    and DELETE /api/v1/topology/link-overrides/{id}. A pinned link never expires the way an
    unobserved derived link does.
  • GET /api/v1/topology/shadow returns the whole comparison: edge counts, the differing edges
    in each direction, the affected active alerts, the nodes acting as graph roots, and any pools
    whose poller could not be placed.
  • The MCP get_topology tool takes a kind parameter: dependency (the default, and what
    every existing call keeps doing), links for the connectivity graph, and overrides and shadow
    for the operator decisions and the comparison. Asked whether derived suppression is safe to
    enable, an AI client can now answer from the same data an operator sees.
  • Pollers report their own interface addresses, and can be given an anchor node. Direction in
    the derived graph comes from distance to a poller, so Yagra has to know where each poller sits. It
    works that out from the addresses the poller reports — but ⚠️ a poller running in a container
    reports a container-network address that matches no monitored node
    , which is the normal case
    rather than an unusual one. Settings ▸ Pollers gained an Anchor node column for naming where
    such a poller really attaches (PUT /api/v1/pollers/{id}/anchor), and GET /api/v1/pollers now
    returns mgmt_addrs and anchor_node_id. Switching to the derived graph is refused while a
    pool that has nodes has an unplaced poller — such a pool would contribute no roots, so nothing in
    it would ever be suppressed while the screen showed the feature as on.
  • PUT /api/v1/settings/topology sets the mode (manual / shadow / derived). There is
    deliberately no matching GET: the current mode is part of the /topology/shadow response.
  • Sign in with an LDAP or Active Directory account. Configure your directory at
    Settings ▸ Auth ▸ Directory (LDAP/AD) and people log in with their corporate credentials at the
    ordinary login form — there is no second button and no separate URL. Yagra searches for the person
    with a service account and then re-binds as the entry it found, so no DN pattern has to be guessed;
    group membership maps to a Yagra role through the same mapping the SSO provider uses, matching a
    group by its full DN or just its name. An account is created on first successful sign-in.
    Local accounts are always tried first, so a directory that is unreachable can never lock an
    administrator out — keep one local admin and a rollback stays survivable. LDAPS and StartTLS only,
    with a field for your private CA; there is deliberately no way to skip certificate verification.
    A Test button reports each stage separately and, given a username, shows the DN, the groups and
    the role that person would receive — including when the answer is "denied", which the login
    form otherwise reports as an ordinary wrong password. Nothing changes for a deployment that does
    not configure a directory.
  • SAML is answered with a documented bridge rather than an implementation. DEPLOYMENT.md now
    describes putting Keycloak or Dex in front as a SAML→OIDC bridge, and says why Yagra does not
    verify XML signatures itself.

Improvements

  • Yagra-core and Yagra-poller use far less memory, and stop growing. Both binaries now use the
    mimalloc allocator instead of the system one. Measured on a 50,000-node deployment over a
    20-minute window, at identical polling throughput: core's resident set averaged 183 MB instead
    of 397 MB
    , and grew by 6 MB instead of 162 MB across the window; the poller's stopped
    creeping upward at all. The old behaviour was not a leak but the system allocator holding on to
    per-thread arenas it never returned — which meant core's footprint kept climbing for as long as it
    was watched, and could not be sized with confidence. If you provisioned a host from the previous
    profile, it will now sit comfortably under it. Building with --no-default-features restores the
    system allocator.
  • Upgrading a poller now downloads half as much. The poller image stored its binary twice: once
    where it was copied in, and again because granting it CAP_NET_RAW rewrote the file into a second
    layer — and both layers change with every release. Placing the binary and granting the capability
    are now one layer, so the per-release download for yagra-poller drops from 10.2 MB to 4.7 MB.
    Most of that is the duplicate going away; the rest is a smaller binary. The image behaves
    identically and raw-socket ICMP is unaffected.
    • ⚠️ yagra-core moved the other way, and it is only fair to say so: the release build now
      uses fat link-time optimization, which grew core's binary from 11.9 to 13 MiB compressed. On
      top of codegen-units = 1 there is no duplicate code left for LTO to collapse, so what it adds
      is inlining across crate boundaries — and inlining duplicates code. The poller's binary went
      the other way, which is where the "smaller binary" above comes from. Taken together these two
      changes still subtract about 4.4 MB from an upgrade that pulls both images, but core alone
      costs a little more than it did.
  • The Dependency / root-cause dashboard widget now lists each root cause with the alerts rolled
    up under it, biggest first, instead of an indented parent→child tree. The dependency graph is no
    longer a tree — a node can have two upstreams — and a tree could only have shown one of them.
  • Interface addresses are collected and their changes recorded, the same way CDP/LLDP adjacency
    already was: one current set per node plus an append-on-change history, visible under
    Settings ▸ Data retention as Interface address changes. Collection is on by default at the
    same hourly cadence, with its own toggle at PUT /api/v1/settings/neighbors
    (l3_enabled / l3_interval_secs; omitting them leaves those settings unchanged).
  • LLDP neighbours now carry the peer's management address. That is what lets an adjacency be
    matched to a monitored node, and it is why the map can be built from L2 at all. ⚠️ The first poll
    after upgrading records one extra neighbour-change row per LLDP-speaking node, because the
    recorded set genuinely gained a field. Devices that do not implement LLDP-MIB record nothing new.
  • An API token owned by an LDAP account now expires with its owner's silence, the same way an
    SSO-owned one already did. A directory disabling somebody is not something Yagra is told about, so
    the owner going quiet is the only signal there is — previously that rule was written for OIDC
    alone, and a token owned by a disabled directory account would have kept working indefinitely.
    YAGRA_PAT_OIDC_IDLE_DAYS keeps its name and now governs both.

Bug Fixes

  • A Troubleshoot analysis started over MCP now appears in the audit log. The identical run
    started from the WebUI or the REST API was recorded; the one launched through /mcp left no
    trace at all, because auditing is REST middleware that the MCP surface does not pass through. Any
    deployment with /mcp enabled has been under-recording who started analyses.
  • Signing in as a disabled SSO account now answers 401 instead of 500. The refusal was correct;
    the status code said Yagra had broken.
  • Resetting the password of an SSO or directory account is now refused with a message saying so.
    It used to answer 200 and write a hash that can never be used, telling an administrator they had
    set a password when they had not.