diff --git a/CHANGELOG.md b/CHANGELOG.md index d5cf73d6..c9415c63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,27 @@ changes; the linked API and deployment documentation contains operational detail - See `docs/operations/configuration.md#executor-tesla-key-schedule`. ### Added +- API 1.16: RIPE Atlas-style probe addressing in `GET /executors`. Each entry + reports `is_public` and the last dispatcher-observed `address_v4` and + `address_v6` with their `prefix_v4/v6` and `asn_v4/v6` from the offline ASN + database, and `address_observations` (`via` control or reflection, + `observed_at`, lookup source and reason). Only addresses the dispatcher saw + on the executor's authenticated connections are listed, never + `public_host`. Executors call the existing address reflection over each + family every 10 minutes, resolving their `dispatcher.addr` and verifying the + dispatcher certificate, so both families are observed without a configured + reflector (`[connectivity] observe_addresses = false` turns this off). + `[metadata] address_opt_out = true` makes an executor private: its + addresses are withheld from everyone but operators, while prefix, ASN and + location stay public, as for a private RIPE Atlas probe. It is independent + of `location_opt_out`. Results record the same fields in + `provenance.vantage_point.addressing`. `dbl nodes` adds `ADDRESS_V4` and + `ADDRESS_V6` columns. **Privacy:** executors are public by default, + including executors that predate the setting, so their control-connection + address, operator-only until now, is listed once the dispatcher is + upgraded. To keep one private, upgrade it and set `address_opt_out` + before the dispatcher is upgraded + (`docs/vantage-points.md#privacy`). - `dbl verify` and `client.Verify`: offline probe verification (#73, `docs/verification.md`, delivery step 3). `client.ReadCapture` reads pcap and pcapng (Ethernet, raw IP, Linux SLL/SLL2, loopback) up to 64 MiB and diff --git a/api/openapi.yaml b/api/openapi.yaml index e05abd40..9d586188 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: title: "Debuglet dispatcher HTTP API" - version: "1.15" + version: "1.16" description: "Wire contract of the dispatcher's public HTTP API. Bandwidth values are bits per second, timeouts are milliseconds, TESLA delays are seconds, TESLA anchor timestamps are Unix nanoseconds and every other timestamp is Unix seconds unless a field says otherwise. A client states the contract version it requires with the Debuglet-API-Version request header, declared on every operation below; every response carries the version this dispatcher implements in the same header. Requests are authenticated with a server-issued session: a native client presents it as an Authorization bearer token, a browser client in the session_token cookie plus the X-Debuglet-CSRF header on state-changing requests. Each operation states its own 401 and 403. Access to another account's run or payment order answers 404, so a response is no existence oracle. A dispatcher explicitly configured for the local development profile additionally serves the unauthenticated routes below as its own local operator. See docs/api.md for the access matrix and the compatibility and deprecation policy." servers: - url: "/" @@ -7042,6 +7042,42 @@ components: $ref: "#/components/schemas/ObservedList" clock: $ref: "#/components/schemas/ObservedClock" + is_public: + type: boolean + nullable: true + description: "Added in API 1.16. False when the executor set metadata.address_opt_out: the public listing then withholds address_v4 and address_v6, and nothing else. Prefixes, ASNs, location and every other field stay public, as for a private RIPE Atlas probe. An established operator always sees the addresses. True by default, including for executors that predate the setting." + address_v4: + type: string + nullable: true + description: "Added in API 1.16. The last IPv4 address the dispatcher itself observed for the executor: the peer of its control connection, or of an authenticated address reflection call over IPv4 (address_observations.v4 says which and when). Never the executor's own claim such as public_host. Null when none was observed in the current control session, or when is_public is false and the caller is not an operator. It may be a non-global address when the executor reaches the dispatcher over a private network." + address_v6: + type: string + nullable: true + description: "Added in API 1.16. As address_v4, for IPv6." + prefix_v4: + type: string + nullable: true + description: "Added in API 1.16. The prefix of the offline ASN database record covering the observed IPv4 address. Public even when is_public is false. Null when the address is unknown, not global or not in the database." + prefix_v6: + type: string + nullable: true + description: "Added in API 1.16. As prefix_v4, for IPv6." + asn_v4: + type: integer + format: int64 + minimum: 1 + maximum: 4294967295 + nullable: true + description: "Added in API 1.16. The origin ASN of prefix_v4. Public even when is_public is false." + asn_v6: + type: integer + format: int64 + minimum: 1 + maximum: 4294967295 + nullable: true + description: "Added in API 1.16. As asn_v4, for IPv6." + address_observations: + $ref: "#/components/schemas/AddressObservations" last_seen: type: integer format: int64 @@ -7279,6 +7315,107 @@ components: nullable: true description: "Dispatcher receipt time of the report in Unix seconds." description: "Added in API 1.9. All fields are null together." + AddressObservations: + type: object + nullable: true + additionalProperties: false + required: + - v4 + - v6 + properties: + v4: + $ref: "#/components/schemas/AddressObservation" + v6: + $ref: "#/components/schemas/AddressObservation" + description: "Added in API 1.16. How and when address_v4 and address_v6 were observed, one record per family. It never carries the address, so it is the same for public and private executors." + AddressObservation: + type: object + nullable: true + additionalProperties: false + required: + - source + - via + - observed_at + - lookup_source + - lookup_reason + properties: + source: + type: string + enum: + - dispatcher-observed + via: + type: string + enum: + - control + - reflection + description: "control: the peer address of the executor's control connection, preferred for its family. reflection: the peer address of the executor's last authenticated ReflectAddress call over this family." + observed_at: + type: integer + format: int64 + description: "Unix seconds of the last observation: the last heartbeat of the control session that carries the control address, or the receipt of the last reflection call." + lookup_source: + type: string + nullable: true + pattern: "^database:[^@\\s]+@[1-9][0-9]*$" + description: "The offline ASN database the prefix and ASN come from. Null when no lookup happened." + lookup_reason: + type: string + enum: + - "" + - "no_database" + - "no_address" + - "not_ip" + - "non_global" + - "opted_out" + - "not_found" + - "lookup_error" + - "invalid_record" + description: "Empty when the database knew the address; otherwise why the prefix and ASN are null." + description: "Null when no address of the family was observed in the current control session." + ProbeAddressing: + type: object + nullable: true + additionalProperties: false + required: + - is_public + - address_v4 + - address_v6 + - prefix_v4 + - prefix_v6 + - asn_v4 + - asn_v6 + - address_observations + properties: + is_public: + type: boolean + nullable: true + address_v4: + type: string + nullable: true + address_v6: + type: string + nullable: true + prefix_v4: + type: string + nullable: true + prefix_v6: + type: string + nullable: true + asn_v4: + type: integer + format: int64 + minimum: 1 + maximum: 4294967295 + nullable: true + asn_v6: + type: integer + format: int64 + minimum: 1 + maximum: 4294967295 + nullable: true + address_observations: + $ref: "#/components/schemas/AddressObservations" + description: "The listing's RIPE Atlas-style addressing fields (see ExecutorResponse) as known at admission. The addresses are recorded for a private executor too, since a result is visible only to the run's owner and operators." ExecutorList: type: array nullable: true @@ -8218,6 +8355,8 @@ components: $ref: "#/components/schemas/ResultLabelledClock" platform: $ref: "#/components/schemas/ResultLabelledPlatform" + addressing: + $ref: "#/components/schemas/ProbeAddressing" description: "Added in format 1.1. Where the run was to execute, as known at admission. Each value names its source; no label means verified. Null for runs admitted before format 1.1, and always absent from 1.0 files." nullable: true ResultSourceLabel: diff --git a/api/spec.go b/api/spec.go index e87ce530..9b43ea4e 100644 --- a/api/spec.go +++ b/api/spec.go @@ -15,7 +15,7 @@ var OpenAPI []byte const ( // Version is the HTTP API contract version as major.minor. It equals the // info.version field of OpenAPI. - Version = "1.15" + Version = "1.16" // Major is the compatibility number of Version. Clients that require a // different major version are answered with an explicit incompatibility // error instead of a best-effort response. @@ -39,7 +39,9 @@ const ( // payment features, usage allowances (GET /me/allowance, POST // /operator/accounts/{id}/allowance) and executor earnings // (GET /operator/executors/{id}/earnings). - Minor = 15 + // 1.16 added the RIPE Atlas-style is_public, address, prefix and ASN + // fields of each address family to GET /executors. + Minor = 16 // VersionHeader carries the contract version a client requires on requests // and the version the dispatcher implements on responses. VersionHeader = "Debuglet-API-Version" diff --git a/cmd/dbl/capabilities_test.go b/cmd/dbl/capabilities_test.go index d4f61b39..82e30b95 100644 --- a/cmd/dbl/capabilities_test.go +++ b/cmd/dbl/capabilities_test.go @@ -89,7 +89,7 @@ func TestNodesShowsVantageColumnsAndFiltersISDAS(t *testing.T) { mux.HandleFunc("GET /executors", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`[{"id":"old","ready":true}, -{"id":"lab","ready":true,"admission":"maintenance", +{"id":"lab","ready":true,"admission":"maintenance","is_public":false,"address_v4":null,"address_v6":null,"asn_v4":64500, "display":{"display_name":{"value":"ETH lab","source":"operator"},"city":{"value":"Zurich","source":"operator"},"country":{"value":"CH","source":"operator"},"network":{"value":null,"source":null}}, "scion_isd_as":{"value":"1-ff00:0:110","source":"executor-reported","observed_at":1}, "listeners":{"value":["udp","scion"],"source":"executor-reported","observed_at":1}}]`)) @@ -98,7 +98,7 @@ func TestNodesShowsVantageColumnsAndFiltersISDAS(t *testing.T) { code, stdout, stderr := runCLI(context.Background(), "--endpoint", fx.endpoint(), "nodes") assertCode(t, code, exitOK, stdout, stderr) lines := strings.Split(strings.TrimSpace(stdout), "\n") - const header = "ID READY NAME LOCATION ISD_AS ASN LOCATION_SOURCE LAST_SEEN VERSION PRICE_PER_BW CURRENCY PROTOCOLS ENFORCEMENT CAPACITY_BPS ATTRIBUTION IPV4 IPV6 TCP_LISTENER UDP_LISTENER" + const header = "ID READY NAME LOCATION ISD_AS ASN ADDRESS_V4 ADDRESS_V6 LOCATION_SOURCE LAST_SEEN VERSION PRICE_PER_BW CURRENCY PROTOCOLS ENFORCEMENT CAPACITY_BPS ATTRIBUTION IPV4 IPV6 TCP_LISTENER UDP_LISTENER" if len(lines) != 3 || strings.Join(strings.Fields(lines[0]), " ") != header { t.Fatalf("table: %q", stdout) } @@ -108,6 +108,12 @@ func TestNodesShowsVantageColumnsAndFiltersISDAS(t *testing.T) { if !strings.Contains(lines[2], "ETH lab Zurich,CH 1-ff00:0:110") { t.Fatalf("vantage row: %q", lines[2]) } + if fields := strings.Fields(lines[1]); fields[6] != "-" || fields[7] != "-" { + t.Fatalf("legacy addresses: %q", lines[1]) + } + if fields := strings.Fields(lines[2]); fields[7] != "private" || fields[8] != "private" { + t.Fatalf("private addresses: %q", lines[2]) + } // The default table stays compact; admission, network and listeners are // in --output json only. for _, hidden := range []string{"ADMISSION", "NETWORK", "LISTENERS", "maintenance", "udp,scion"} { diff --git a/cmd/dbl/query.go b/cmd/dbl/query.go index e5eafc58..857dbcf4 100644 --- a/cmd/dbl/query.go +++ b/cmd/dbl/query.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "net/netip" "runtime/debug" "strconv" "strings" @@ -27,8 +28,10 @@ Filters return ready matching executors only. Unknown capability reports and an unknown ISD-AS do not match. Capacity means advertised total bandwidth, not free admission capacity. NAME is the dispatcher operator's label; LOCATION prefers operator location and otherwise shows approximate database location with its source; -ISD_AS is the executor's own report. --output json also carries admission, the -operator's network label and the reported listener transports. +ISD_AS is the executor's own report. ADDRESS_V4 and ADDRESS_V6 are the addresses +the dispatcher last observed, "private" when the executor withholds them. --output +json also carries admission, the operator's network label, the reported listener +transports and each address family's prefix, ASN and observation time. Lists the dispatcher's registered executors. JSON output is always an array. ` @@ -89,7 +92,7 @@ func nodesCommand(ctx context.Context, args []string, options globalOptions, std } return emit("dbl nodes", options.Output, stdout, stderr, nodes, func(w io.Writer) error { tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) - fmt.Fprintln(tw, "ID\tREADY\tNAME\tLOCATION\tISD_AS\tASN\tLOCATION_SOURCE\tLAST_SEEN\tVERSION\tPRICE_PER_BW\tCURRENCY\tPROTOCOLS\tENFORCEMENT\tCAPACITY_BPS\tATTRIBUTION\tIPV4\tIPV6\tTCP_LISTENER\tUDP_LISTENER") + fmt.Fprintln(tw, "ID\tREADY\tNAME\tLOCATION\tISD_AS\tASN\tADDRESS_V4\tADDRESS_V6\tLOCATION_SOURCE\tLAST_SEEN\tVERSION\tPRICE_PER_BW\tCURRENCY\tPROTOCOLS\tENFORCEMENT\tCAPACITY_BPS\tATTRIBUTION\tIPV4\tIPV6\tTCP_LISTENER\tUDP_LISTENER") for _, n := range nodes { location := n.Display location.City, location.Country = n.Location() @@ -112,14 +115,32 @@ func nodesCommand(ctx context.Context, args []string, options globalOptions, std if connectivity == nil { connectivity = &wire.Connectivity{} } - fmt.Fprintf(tw, "%s\t%t\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%d\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", n.ID, n.Ready, - labelText(n.Display.DisplayName), nodeLocation(location), observedText(n.SCIONISDAS), nodeASN(n.IPMetadata), locationSource(location), + fmt.Fprintf(tw, "%s\t%t\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%d\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", n.ID, n.Ready, + labelText(n.Display.DisplayName), nodeLocation(location), observedText(n.SCIONISDAS), nodeASN(n.IPMetadata), + addressColumn(n.AddressV4, n.IsPublic), addressColumn(n.AddressV6, n.IsPublic), locationSource(location), lastSeen, n.Version, n.PricePerBw, n.Currency, protocols, enforcement, capacity, attribution, reachabilityColumn(connectivity.IPv4), reachabilityColumn(connectivity.IPv6), reachabilityColumn(connectivity.TCPListener), reachabilityColumn(connectivity.UDPListener)) } return tw.Flush() }) } +// addressColumn shows an observed address, "private" when a private executor's +// address is withheld and "-" when none is known. A value that is not an +// address is not printed. +func addressColumn(address *string, public *bool) string { + if address == nil { + if public != nil && !*public { + return "private" + } + return "-" + } + ip, err := netip.ParseAddr(*address) + if err != nil { + return "invalid" + } + return ip.String() +} + func reachabilityColumn(r wire.Reachability) string { if r.Stale || r.ExpiresAt != nil && time.Now().Unix() >= *r.ExpiresAt { return "stale" diff --git a/docs/api.md b/docs/api.md index b4f648d2..3a419f2b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -64,6 +64,8 @@ An account's allowance is `granted`, the sum of its grants, less what its `TEST` API 1.9 also adds `admission`, operator `display` metadata, the executor-reported `scion_isd_as` and `listeners` to `GET /executors`. Each value carries a source label; see [executor discovery](operations/executor-discovery.md#vantage-point-metadata). It also adds the executor's host probes: `capabilities.icmp`, `capabilities.enforcement_reason` and `clock`. Host platform detail is operator-only and is never listed; it appears only in result provenance ([host probes](operations/executor-discovery.md#host-probes)). +API 1.16 adds RIPE Atlas-style addressing to every entry: `is_public`, the dispatcher-observed `address_v4` and `address_v6`, their `prefix_v4`, `prefix_v6`, `asn_v4` and `asn_v6`, and `address_observations` with how and when each address was seen. A private executor's addresses are withheld from everyone but operators; nothing else is ([probe addresses](operations/executor-discovery.md#probe-addresses)). + ## Probe attribution API 1.11 adds two public routes for [probe verification](verification.md). They need no credential and are rate-limited per client address; an excess answers `429 rate_limited` with `Retry-After`. Behind a reverse proxy the address is the proxy's unless the proxy is listed in `[attribution] trusted_proxies` ([configuration](operations/configuration.md#dispatcher-attribution-history)). diff --git a/docs/operations/executor-discovery.md b/docs/operations/executor-discovery.md index 0d2639e9..fe3d255b 100644 --- a/docs/operations/executor-discovery.md +++ b/docs/operations/executor-discovery.md @@ -224,7 +224,8 @@ coordinates are never read or published. Missing city means country precision. `ip_metadata.observed` and `.advertised` keep the two lookup results separate. `address_source` says whether the lookup key was dispatcher-observed or -executor-reported; raw IPs are not in the public listing. Each `asn` and +executor-reported; `ip_metadata` carries no address itself. The observed +addresses are listed separately as [probe addresses](#probe-addresses). Each `asn` and `location` has `value`, `source`, `observed_at` (registration time in Unix seconds) and `reason`. `source` is `database:@`, using the file's embedded metadata. It identifies the data, not its correctness. @@ -267,6 +268,93 @@ again. Existing results retain the old source/version and values. of result format 1.1. Files predating the field remain readable. No separate live metadata table or database-update service is required. +### Probe addresses + +API 1.16 adds the addressing of a [RIPE Atlas](https://atlas.ripe.net/) probe +record to each `GET /executors` entry: + +```json +"is_public": true, +"address_v4": "192.0.2.10", +"address_v6": "2001:db8:10::7", +"prefix_v4": "192.0.2.0/24", +"prefix_v6": "2001:db8::/32", +"asn_v4": 64500, +"asn_v6": 64500, +"address_observations": { + "v4": {"source": "dispatcher-observed", "via": "control", "observed_at": 1791250000, + "lookup_source": "database:GeoLite2-ASN@1790000000", "lookup_reason": ""}, + "v6": {"source": "dispatcher-observed", "via": "reflection", "observed_at": 1791249700, + "lookup_source": "database:GeoLite2-ASN@1790000000", "lookup_reason": ""} +} +``` + +`address_v4` and `address_v6` are the last addresses of each family that the +dispatcher itself observed for the executor in its current control session. +An executor's claim is never used: `public_host` and the hello's source IP are +not observations. Two connections are observed, both authenticated with the +executor's certificate and current control-session credentials, so another +host cannot submit or spoof them: + +- `via: control` is the peer address of the control connection. It is + current while that session lives, so `observed_at` is the session's last + heartbeat. It is preferred for its own family, which keeps a multi-homed + executor's address from alternating between paths. +- `via: reflection` is the peer address of the executor's last + `ReflectAddress` call over the other family. The dispatcher records the + TCP peer of the call, never a value in the request, and `observed_at` is + the call's receipt. + +To observe the family its control connection does not use, an executor calls +the address reflection over each family every 10 minutes. It resolves the host +of its configured `dispatcher.addr` for that family (only that family when the +host is a literal) and verifies the dispatcher's certificate for that name, as +on the control connection, so a resolved address that is not the dispatcher +fails the handshake. A family with a configured `connectivity.ipv4_reflector` +or `ipv6_reflector` is not called again: its 30-second +[connectivity check](#controlled-connectivity-observations) already reflects +it. A family that does not work is simply not observed; it affects neither the +connectivity report nor admission. Operators can turn the calls off: + +```toml +[connectivity] +observe_addresses = false # default true +``` + +A replacement control session starts without the previous session's +observations, as for the connectivity checks, so an executor that moved does +not keep its old address; until the next call its other family is null. A +published address is where the executor reached the dispatcher from, which +need not be the source of every measurement packet, and it can be a +non-global address when the executor reaches the dispatcher over a private +network. + +`prefix_v4/v6` and `asn_v4/v6` are looked up from those addresses in the +[offline ASN database](#offline-asn-and-approximate-location), with the +database and any negative reason in `lookup_source` and `lookup_reason`. They +are null when no database is configured, or for a non-global or unknown +address. + +An executor can make itself private, as a RIPE Atlas host can: + +```toml +[metadata] +address_opt_out = true # default false: public +``` + +The listing then reports `is_public: false` and withholds both addresses from +everyone but established operators. Nothing else changes: prefixes, ASNs, +`address_observations`, `ip_metadata` and location stay public. Location has +its own opt-out, `location_opt_out`, independent of this one; the +[vantage-point note](../vantage-points.md#privacy) explains the choice. +Restart the executor to publish a changed preference. Executors that predate +the setting are public. The admission snapshot records the same fields, +including a private executor's addresses, as `provenance.vantage_point.addressing`. + +`dbl nodes` shows the addresses in its `ADDRESS_V4` and `ADDRESS_V6` columns +(`private` when withheld, `-` when unknown); `--output json` carries every +field. + ## Host probes Executors probe their own host at startup and with every capability report diff --git a/docs/results.md b/docs/results.md index bb805db5..d18b6596 100644 --- a/docs/results.md +++ b/docs/results.md @@ -79,6 +79,11 @@ claim it. Older readers reject 1.2 rather than discarding the relationship. Platform detail is operator-only elsewhere and appears here because a result is readable only by its owner and operators. Earlier files omit these fields, which reads as `null` (or an empty reason). +- Also within schema 1, `addressing`: the [probe addresses](operations/executor-discovery.md#probe-addresses) + of the listing at admission, `is_public`, the dispatcher-observed + `address_v4` and `address_v6` (recorded for a private executor too), their + prefixes and ASNs, and `address_observations` with how and when each was + seen. Files written before it omit the field, which reads as `null`. - Each vantage-point value names its `source`: `operator`, `executor-reported` or `dispatcher-observed` (`database:@` is reserved for later lookups). Capabilities, `scion_isd_as`, `clock`, diff --git a/docs/vantage-points.md b/docs/vantage-points.md index b2a6e916..21e7d750 100644 --- a/docs/vantage-points.md +++ b/docs/vantage-points.md @@ -19,7 +19,7 @@ admission: | --- | --- | | `operator` | Set by the operator in the dispatcher configuration. Values from an executor's own configuration, such as `public_host`, arrive over the control connection and are `executor-reported`, since the dispatcher cannot tell them from measurements. | | `executor-reported` | Measured or introspected by the executor itself. | -| `dispatcher-observed` | Seen directly by the dispatcher: the control connection's remote IP and the result of a controlled connect-back reachability test. | +| `dispatcher-observed` | Seen directly by the dispatcher: the remote IP of the control connection or of an address reflection call, and the result of a controlled connect-back reachability test. | | `database:@` | Looked up by the dispatcher in an offline database, keyed on a dispatcher-observed or advertised address. The version comes from the database file's own metadata. | Executor claims are never labelled verified. A `dispatcher-observed` value @@ -59,17 +59,48 @@ always `unknown`, never a guessed value. ## Privacy +Debuglet follows the [RIPE Atlas](https://atlas.ripe.net/docs/apis/rest-api-manual/probes/) +probe model. A RIPE Atlas probe, home probes included, is public by default: +its addresses, prefixes, ASNs and location are published. A probe's host can +make it private, which hides only its addresses. Debuglet executors are the +same kind of vantage point and are measured against the same expectations, so +the public listing publishes the same facts and offers the same opt-out. + | Data | Visibility | | --- | --- | | Network (ASN, AS name, prefix, ISD-AS, address families, reachability) | Public | -| Location, at most city and country | Public | +| Dispatcher-observed address of each family (`address_v4`, `address_v6`) | Public, unless the executor is private (`is_public` false); then operator, and the run's owner through its result provenance | +| Location, at most city and country | Public, unless the executor opts out of location | | Measurement capabilities, including the ICMP probe and fallback reason | Public | | Clock sync state, kernel error estimates and clock readiness | Public | | Host platform (OS, kernel, architecture, CPU, memory, build version) | Operator, and the run's owner through its result provenance | -| Control-connection source IP and advertised public host | Operator, and the run's owner through its result provenance | - -Location is never finer than city. An executor can opt out of location; the -dispatcher then publishes no automatic location for it. +| Advertised public host, reported (hello) source IP, connectivity endpoints and the SCION host | Operator, and the run's owner through its result provenance | + +Only addresses the dispatcher itself observed are published: the peer address +of the executor's authenticated control connection and of its authenticated +address reflection calls. The executor's own claims, such as `public_host`, +are not observations and stay operator-only. A published address is the +address the executor reaches the dispatcher from; packets it sends elsewhere +may leave from another one. An address is already public in practice whenever +the executor measures from it, since every probe it sends carries it; the +public `GET /attribution/candidates` lookup answers for an address the querier +already holds, whether the executor is public or not. + +`is_public` and the location opt-out are independent, as in RIPE Atlas, where +a private probe keeps its location public and the location is the host's own +choice. Making an executor private (`metadata.address_opt_out`) hides only +its addresses; prefix, ASN and any location stay public. Opting out of +location (`metadata.location_opt_out`) hides only the automatic city and +country; the addresses stay public unless the executor is also private. An +executor that wants neither published sets both. Location is never finer than +city. + +Executors are public by default, including executors that predate the +setting: their observed addresses appear in the listing from API 1.16 on. +Before then the control-connection source IP was operator-only. An executor +that must stay private needs a release that sends `address_opt_out` (an older +executor cannot) and the setting before it registers with a dispatcher of +this release. ## Refresh @@ -101,3 +132,6 @@ last outcome with an explicit stale flag. The admission snapshot keeps the last 6. Dual-stack egress reflection against the authenticated dispatcher (#239 part 2). Local SCION host/path metadata is reported separately; external SCION data-plane and listener reachability remain untested. +7. RIPE Atlas-style addressing in API 1.16: the observed `address_v4` and + `address_v6`, their prefixes and ASNs, and `is_public`; see + [probe addresses](operations/executor-discovery.md#probe-addresses). diff --git a/docs/versions.md b/docs/versions.md index b28e2e10..62b18f80 100644 --- a/docs/versions.md +++ b/docs/versions.md @@ -103,3 +103,5 @@ Dispatcher schema 22 records chain payment receipts and outbound chain transfers API 1.15 adds `POST /payment/quote`, the `quote` field of the payment intent response, the `economics` field of `GET /me`, `GET /me/orders`, the usage allowance routes `GET /me/allowance` and `POST /operator/accounts/{id}/allowance`, the `allowance_exceeded`, `allowance_out_of_range` and `conflict` error codes, and `GET /operator/executors/{id}/earnings`. The Go client requires 1.15 for these routes. Dispatcher schema 23 records usage allowance grants in `allowance_grants`. The allowance routes and the capped intent depend on it, so this build requires schema 23; upgrade explicitly before starting it. + +API 1.16 adds RIPE Atlas-style addressing to `GET /executors`: `is_public`, `address_v4`, `address_v6`, `prefix_v4`, `prefix_v6`, `asn_v4`, `asn_v6` and `address_observations`, and records the same fields as `addressing` in the result's admission snapshot (within result format 1.1, schema 1). The control protocol gains the optional `VantagePointReport.address_opt_out` without a version change: an older executor is public, and an older dispatcher ignores the field. Executors make the existing `ReflectAddress` call over each family to be observed in both; an older dispatcher answers it as before. diff --git a/internal/acceptance/canary/driver.go b/internal/acceptance/canary/driver.go index 8052e27a..1af8b310 100644 --- a/internal/acceptance/canary/driver.go +++ b/internal/acceptance/canary/driver.go @@ -432,7 +432,7 @@ func strictCLIDocument(data []byte, out any) error { return errors.New("invalid nodes JSON") } for _, node := range nodes { - fields, err := strictFields(node, []string{"id", "ready", "last_seen", "version", "tesla_delay_sec", "tesla_anchor_timestamp_ns", "tesla_anchor_key", "price_per_bw", "currency"}, []string{"capabilities", "admission", "display", "scion_isd_as", "listeners", "clock", "ip_metadata", "connectivity", "admission_limits", "capability_observation"}, map[string]bool{"tesla_anchor_key": true}) + fields, err := strictFields(node, []string{"id", "ready", "last_seen", "version", "tesla_delay_sec", "tesla_anchor_timestamp_ns", "tesla_anchor_key", "price_per_bw", "currency"}, append([]string{"capabilities", "admission", "display", "scion_isd_as", "listeners", "clock", "ip_metadata", "connectivity", "admission_limits", "capability_observation"}, probeAddressingFields...), nodeNullable) if err != nil { return err } @@ -483,9 +483,45 @@ func strictCLIDocument(data []byte, out any) error { return nil } +// probeAddressingFields are the RIPE Atlas-style addressing fields (API +// 1.16). The CLI emits each of them, null when the dispatcher predates them. +var probeAddressingFields = []string{"is_public", "address_v4", "address_v6", "prefix_v4", "prefix_v6", "asn_v4", "asn_v6", "address_observations"} + +var nodeNullable = func() map[string]bool { + out := map[string]bool{"tesla_anchor_key": true} + for _, key := range probeAddressingFields { + out[key] = true + } + return out +}() + +func checkNodeAddressObservations(raw json.RawMessage) error { + if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return nil + } + families, err := strictFields(raw, []string{"v4", "v6"}, nil, map[string]bool{"v4": true, "v6": true}) + if err != nil { + return err + } + for _, family := range families { + if bytes.Equal(bytes.TrimSpace(family), []byte("null")) { + continue + } + if _, err := strictFields(family, []string{"source", "via", "observed_at", "lookup_source", "lookup_reason"}, nil, map[string]bool{"lookup_source": true}); err != nil { + return err + } + } + return nil +} + // Node discovery fields keep their documented shape: each value travels // with its source label, and observations with their receipt time. func checkNodeVantage(fields map[string]json.RawMessage) error { + if raw, present := fields["address_observations"]; present { + if err := checkNodeAddressObservations(raw); err != nil { + return err + } + } if raw, present := fields["capability_observation"]; present { if _, err := strictFields(raw, []string{"state", "observed_at", "expires_at"}, nil, map[string]bool{"observed_at": true, "expires_at": true}); err != nil { return err diff --git a/internal/acceptance/canary/ip_metadata_test.go b/internal/acceptance/canary/ip_metadata_test.go index 0e26f19e..e5af59c8 100644 --- a/internal/acceptance/canary/ip_metadata_test.go +++ b/internal/acceptance/canary/ip_metadata_test.go @@ -79,3 +79,31 @@ func TestNodeIPMetadataDocumentKeepsExactFields(t *testing.T) { }) } } + +func TestNodeAddressingDocumentKeepsExactFields(t *testing.T) { + observation := `{"source":"dispatcher-observed","via":"control","observed_at":1790598500,"lookup_source":null,"lookup_reason":"no_database"}` + public := `,"is_public":true,"address_v4":"127.0.0.1","address_v6":null,"prefix_v4":null,"prefix_v6":null,"asn_v4":null,"asn_v6":null,"address_observations":{"v4":` + observation + `,"v6":null}` + old := `,"is_public":null,"address_v4":null,"address_v6":null,"prefix_v4":null,"prefix_v6":null,"asn_v4":null,"asn_v6":null,"address_observations":null` + for _, tc := range []struct { + name, fields string + valid bool + }{ + {"omitted", "", true}, + {"older dispatcher", old, true}, + {"observed", public, true}, + {"unknown family", strings.Replace(public, `"v6":null`, `"v6":null,"v5":null`, 1), false}, + {"missing family", strings.Replace(public, `,"v6":null`, "", 1), false}, + {"unknown observation field", strings.Replace(public, `"via":"control"`, `"via":"control","other":1`, 1), false}, + {"null observation time", strings.Replace(public, `"observed_at":1790598500`, `"observed_at":null`, 1), false}, + {"duplicate address", public + `,"address_v4":null`, false}, + } { + t.Run(tc.name, func(t *testing.T) { + raw := []byte(`[{"id":"` + testExecutor + `","ready":true,"last_seen":1790598500,"version":"test","tesla_delay_sec":2,"tesla_anchor_timestamp_ns":0,"tesla_anchor_key":null,"price_per_bw":0,"currency":"TEST"` + tc.fields + `}]`) + var nodes []client.Node + err := decodeCommand(commandResult{Started: true, Stdout: raw}, &nodes) + if (err == nil) != tc.valid { + t.Fatalf("valid=%t error=%v", tc.valid, err) + } + }) + } +} diff --git a/internal/dispatcher/connectivity.go b/internal/dispatcher/connectivity.go index 01fbfc1c..b847f913 100644 --- a/internal/dispatcher/connectivity.go +++ b/internal/dispatcher/connectivity.go @@ -95,6 +95,7 @@ func (d *Dispatcher) OnReflectAddress(ctx context.Context, mutation *rpc.Mutatio return nil, status.Error(codes.FailedPrecondition, "executor session unavailable") } entry.reflections[index] = reflectionReceipt{nonce: bytes.Clone(req.Nonce), address: ip.String(), observed: d.now()} + entry.probe.reflected[index] = d.addressSightingLocked(ip, entry.reflections[index].observed) return &pb.ReflectAddressResponse{Address: ip.String()}, nil } diff --git a/internal/dispatcher/executor_registry.go b/internal/dispatcher/executor_registry.go index 3cb90eac..3b3ac6cb 100644 --- a/internal/dispatcher/executor_registry.go +++ b/internal/dispatcher/executor_registry.go @@ -62,6 +62,7 @@ type RegisteredExecutor struct { connectivityObserved time.Time connectivityNext time.Time reflections [2]reflectionReceipt + probe probeAddresses // history is a ring buffer of the last lastDebugletHistory // debuglet IDs that were dispatched to this executor. @@ -245,6 +246,7 @@ func (d *Dispatcher) RegisterExecutor(ctx context.Context, owner *rpc.SessionOwn } record.display = display record.collectIPMetadata(metadataDB, hello.GetVantagePoint().GetLocationOptOut()) + record.probe.optOut = hello.GetVantagePoint().GetAddressOptOut() d.mu.Lock() if d.closed { d.mu.Unlock() diff --git a/internal/dispatcher/probe_addresses.go b/internal/dispatcher/probe_addresses.go new file mode 100644 index 00000000..1a81862b --- /dev/null +++ b/internal/dispatcher/probe_addresses.go @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 ETH Zurich + +package dispatcher + +import ( + "net/netip" + "time" + + "github.com/netsec-ethz/debuglet/pkg/wire" +) + +// addressSighting is one address the dispatcher saw on an authenticated +// connection of the executor, with the ASN lookup made when it was seen. It is +// replaced, never mutated, so snapshots may share its pointers. +type addressSighting struct { + address string // Canonical; empty when nothing was seen. + via string + at time.Time + asn wire.IPLookup[wire.ASInfo] +} + +// probeAddresses is the RIPE Atlas-style addressing state of one control +// session. The control connection's own address is not copied here: it is +// sourceIp, confirmed by every heartbeat of the session that carries it. +type probeAddresses struct { + // optOut is the executor's address_opt_out, read at registration. + optOut bool + // reflected holds the last ReflectAddress peer of each family, IPv4 + // first. A replacement session starts empty, like its reflections. + reflected [2]addressSighting +} + +func familyIndex(ip netip.Addr) int { + if ip.Unmap().Is4() { + return 0 + } + return 1 +} + +// addressSightingLocked looks the address up in the offline ASN database. The +// caller holds the registry lock; the readers are fixed before startup. +func (d *Dispatcher) addressSightingLocked(ip netip.Addr, at time.Time) addressSighting { + lookup := d.ipMetadata.Lookup(ip.String(), wire.SourceDispatcherObserved, at.Unix(), true) + return addressSighting{address: ip.String(), via: wire.AddressViaReflection, at: at, asn: lookup.ASN} +} + +// sightings returns the observed address of each family. The control +// connection's address is preferred for its own family: the session that +// carries it is alive, so it is current at the last heartbeat, and choosing it +// keeps a multi-homed executor's address from alternating between paths. A +// reflection supplies the other family, or both when the control address is +// only the executor's hello claim. +func (e *RegisteredExecutor) sightings() [2]addressSighting { + out := e.probe.reflected + ip, err := netip.ParseAddr(e.sourceIp) + if !e.sourceIPObserved || err != nil { + return out + } + asn := wire.IPLookup[wire.ASInfo]{ObservedAt: e.LastSeen.Unix(), Reason: "no_database"} + if e.ipMetadata != nil && e.ipMetadata.Observed.AddressSource == wire.SourceDispatcherObserved { + asn = e.ipMetadata.Observed.ASN + } + ip = ip.Unmap() + out[familyIndex(ip)] = addressSighting{address: ip.String(), via: wire.AddressViaControl, at: e.LastSeen, asn: asn} + return out +} + +// Addressing is the executor's RIPE Atlas-style addressing for the listing. +// The addresses are withheld unless the executor is public or the caller may +// see private detail (an established operator); prefix and ASN are not. +func (e *RegisteredExecutor) Addressing(private bool) wire.ProbeAddressing { + public := !e.probe.optOut + out := wire.ProbeAddressing{IsPublic: &public, AddressObservations: &wire.AddressObservations{}} + addresses := [2]**string{&out.AddressV4, &out.AddressV6} + prefixes := [2]**string{&out.PrefixV4, &out.PrefixV6} + asns := [2]**uint32{&out.ASNV4, &out.ASNV6} + observations := [2]**wire.AddressObservation{&out.AddressObservations.V4, &out.AddressObservations.V6} + for i, sighting := range e.sightings() { + if sighting.address == "" { + continue + } + if public || private { + address := sighting.address + *addresses[i] = &address + } + observation := &wire.AddressObservation{Source: wire.SourceDispatcherObserved, Via: sighting.via, ObservedAt: sighting.at.Unix(), LookupReason: sighting.asn.Reason} + if sighting.asn.Source != nil { + source := *sighting.asn.Source + observation.LookupSource = &source + } + if value := sighting.asn.Value; value != nil { + number, prefix := value.Number, value.Prefix + *asns[i], *prefixes[i] = &number, &prefix + } + *observations[i] = observation + } + return out +} diff --git a/internal/dispatcher/probe_addresses_test.go b/internal/dispatcher/probe_addresses_test.go new file mode 100644 index 00000000..4676a5a8 --- /dev/null +++ b/internal/dispatcher/probe_addresses_test.go @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 ETH Zurich + +package dispatcher + +import ( + "bytes" + "encoding/json" + "net" + "net/netip" + "testing" + "time" + + "github.com/netsec-ethz/debuglet/pkg/wire" + pb "github.com/netsec-ethz/debuglet/protocol" +) + +func TestAddressingPrefersControlAndHidesOnlyAddressesWhenPrivate(t *testing.T) { + seen := time.Unix(1_790_000_000, 0) + source := "database:Test-ASN@1" + reflectedV4 := addressSighting{address: "198.51.100.9", via: wire.AddressViaReflection, at: seen.Add(time.Second), + asn: wire.IPLookup[wire.ASInfo]{Value: &wire.ASInfo{Number: 64999, Name: "Other", Prefix: "198.51.100.0/24"}, Source: &source}} + reflectedV6 := addressSighting{address: "2001:db8::7", via: wire.AddressViaReflection, at: seen.Add(-time.Minute), + asn: wire.IPLookup[wire.ASInfo]{Value: &wire.ASInfo{Number: 64501, Name: "Six", Prefix: "2001:db8::/32"}, Source: &source}} + e := &RegisteredExecutor{sourceIp: "192.0.2.1", sourceIPObserved: true, LastSeen: seen, + ipMetadata: &wire.IPMetadata{Observed: wire.AddressMetadata{AddressSource: wire.SourceDispatcherObserved, + ASN: wire.IPLookup[wire.ASInfo]{Value: &wire.ASInfo{Number: 64500, Name: "Four", Prefix: "192.0.2.0/24"}, Source: &source}}}, + probe: probeAddresses{reflected: [2]addressSighting{reflectedV4, reflectedV6}}} + + got := e.Addressing(false) + if got.IsPublic == nil || !*got.IsPublic { + t.Fatalf("executor not public by default: %+v", got) + } + // The control connection wins its family even against a newer reflection. + if got.AddressV4 == nil || *got.AddressV4 != "192.0.2.1" || *got.ASNV4 != 64500 || *got.PrefixV4 != "192.0.2.0/24" { + t.Fatalf("IPv4 is not the control address: %+v", got) + } + if v4 := got.AddressObservations.V4; v4 == nil || v4.Via != wire.AddressViaControl || v4.ObservedAt != seen.Unix() || v4.Source != wire.SourceDispatcherObserved || v4.LookupSource == nil || *v4.LookupSource != source { + t.Fatalf("IPv4 observation: %+v", v4) + } + if got.AddressV6 == nil || *got.AddressV6 != "2001:db8::7" || *got.ASNV6 != 64501 || *got.PrefixV6 != "2001:db8::/32" { + t.Fatalf("IPv6 is not the reflected address: %+v", got) + } + if v6 := got.AddressObservations.V6; v6 == nil || v6.Via != wire.AddressViaReflection || v6.ObservedAt != seen.Add(-time.Minute).Unix() { + t.Fatalf("IPv6 observation: %+v", v6) + } + + e.probe.optOut = true + private := e.Addressing(false) + if private.IsPublic == nil || *private.IsPublic || private.AddressV4 != nil || private.AddressV6 != nil { + t.Fatalf("private executor published an address: %+v", private) + } + if private.ASNV4 == nil || private.PrefixV4 == nil || private.ASNV6 == nil || private.PrefixV6 == nil || private.AddressObservations.V4 == nil || private.AddressObservations.V6 == nil { + t.Fatalf("private executor lost its public network facts: %+v", private) + } + encoded, _ := json.Marshal(private) + if bytes.Contains(encoded, []byte("192.0.2.1")) || bytes.Contains(encoded, []byte("2001:db8::7")) { + t.Fatalf("private addresses leaked: %s", encoded) + } + if operator := e.Addressing(true); operator.AddressV4 == nil || operator.AddressV6 == nil { + t.Fatalf("operator view lost the addresses: %+v", operator) + } + + // A hello claim is not an observation: only reflections are published. + e.sourceIPObserved = false + if claimed := e.Addressing(true); claimed.AddressV4 == nil || *claimed.AddressV4 != "198.51.100.9" || claimed.AddressObservations.V4.Via != wire.AddressViaReflection { + t.Fatalf("claimed source address was published as observed: %+v", claimed) + } + if none := (&RegisteredExecutor{sourceIp: "192.0.2.1"}).Addressing(false); none.AddressV4 != nil || none.AddressV6 != nil || none.AddressObservations.V4 != nil || none.AddressObservations.V6 != nil || none.ASNV4 != nil { + t.Fatalf("unobserved executor has addresses: %+v", none) + } +} + +func TestAddressingRecordsReflectionPeerOfOtherFamily(t *testing.T) { + plan := siNewPlan() + f := siNewHarness(t, map[string]*siPlan{"addresses": plan}) + peer := f.connect("network", "addresses") + owner := siEntered(t, plan) + siAvailable(t, plan, owner) + _ = f.boundClient("addresses") + binding, _ := peer.client.Binding() + v6, err := net.Listen("tcp6", "[::1]:0") + if err != nil { + t.Skipf("no IPv6 loopback: %v", err) + } + f.serves.Add(1) + go func() { defer f.serves.Done(); _ = f.d.Bidi.ServeGRPCListener(f.ctx, v6) }() + before, ok := f.d.GetExecutor("network") + if !ok { + t.Fatal("executor not registered") + } + if got := before.Addressing(false); got.AddressV6 != nil || got.AddressObservations.V6 != nil { + t.Fatalf("IPv6 known before any reflection: %+v", got) + } + // The address the executor resolved for the dispatcher name is dialled; + // the dispatcher records the call's peer, not anything in the request. + endpoint := netip.MustParseAddrPort(v6.Addr().String()).String() + if _, err := peer.client.ReflectAddressAs(f.ctx, binding, endpoint, "localhost", &pb.ReflectAddressRequest{ExecutorId: "network", Nonce: bytes.Repeat([]byte{9}, 32)}); err != nil { + t.Fatal(err) + } + after, ok := f.d.GetExecutor("network") + if !ok { + t.Fatal("executor disappeared") + } + got := after.Addressing(false) + if got.AddressV6 == nil || *got.AddressV6 != "::1" || got.AddressObservations.V6 == nil || got.AddressObservations.V6.Via != wire.AddressViaReflection { + t.Fatalf("reflected IPv6 address not recorded: %+v", got) + } + // Loopback is never looked up, and the reason says so. + if got.ASNV6 != nil || got.AddressObservations.V6.LookupReason != "non_global" { + t.Fatalf("loopback address was looked up: %+v", got.AddressObservations.V6) + } + if got.AddressV4 == nil || *got.AddressV4 != "127.0.0.1" || got.AddressObservations.V4.Via != wire.AddressViaControl { + t.Fatalf("control address missing: %+v", got) + } + // A replacement session starts without the old session's reflections. + replacement := registryOwner(t, "network") + defer replacement.Retire() + if err := registryRegisterWithSetup(t.Context(), f.d, replacement, registryHello("network"), "127.0.0.1"); err != nil { + t.Fatal(err) + } + replacement.MarkRegistered() + f.d.mu.RLock() + fresh := snapshotLocked(f.d.executors["network"], time.Now()) + f.d.mu.RUnlock() + if got := fresh.Addressing(false); got.AddressV6 != nil { + t.Fatalf("reflection crossed a control binding: %+v", got) + } +} diff --git a/internal/dispatcher/result_provenance.go b/internal/dispatcher/result_provenance.go index 5c35f28d..66f63b4d 100644 --- a/internal/dispatcher/result_provenance.go +++ b/internal/dispatcher/result_provenance.go @@ -72,9 +72,17 @@ func admissionVantagePoint(entry *executorEntry, now time.Time) *wire.VantagePoi PublicHost: labelled(host, wire.SourceExecutorReported), SCIONISDAS: admissionISDAS(entry, now), Display: entry.Display(), Clock: admissionClock(entry, now), Platform: admissionPlatform(entry, now), + Addressing: admissionAddressing(entry), } } +// Caller holds the registry lock. The run's owner sees the addresses of a +// private executor, as it sees source_ip. +func admissionAddressing(entry *executorEntry) *wire.ProbeAddressing { + out := entry.Addressing(true) + return &out +} + func labelled(value, source string) wire.LabelledString { if value == "" { return wire.LabelledString{} diff --git a/internal/dispatcher/transport/api/handlers_executor.go b/internal/dispatcher/transport/api/handlers_executor.go index aa43064d..d0cf2ef3 100644 --- a/internal/dispatcher/transport/api/handlers_executor.go +++ b/internal/dispatcher/transport/api/handlers_executor.go @@ -39,6 +39,7 @@ func (h *Handler) GetExecutors(c echo.Context) error { Listeners: listeners, Clock: e.Clock(), IPMetadata: e.IPMetadata(), + ProbeAddressing: e.Addressing(caller.Operator), ID: e.ID, Capabilities: e.Capabilities, Ready: e.Ready, diff --git a/internal/dispatcher/transport/api/ip_metadata_test.go b/internal/dispatcher/transport/api/ip_metadata_test.go index 01e45313..9f5e09e1 100644 --- a/internal/dispatcher/transport/api/ip_metadata_test.go +++ b/internal/dispatcher/transport/api/ip_metadata_test.go @@ -46,8 +46,20 @@ func TestIPMetadataHTTPContractAndSDK(t *testing.T) { if err := json.Unmarshal(rec.Body.Bytes(), &nodes); err != nil { t.Fatal(err) } - if strings.Contains(rec.Body.String(), "8.8.8.8") || strings.Contains(rec.Body.String(), "8.8.4.4") { - t.Fatal("public metadata published lookup addresses") + // The observed control address is listed as address_v4 (here to the local + // operator; see TestPrivateExecutorWithholdsOnlyAddresses for the public + // view). The advertised public_host is a claim and is never listed. + if strings.Contains(rec.Body.String(), "8.8.4.4") || strings.Count(rec.Body.String(), "8.8.8.8") != 1 { + t.Fatalf("public listing published a claimed address: %s", rec.Body.String()) + } + for _, n := range nodes { + if n.ID != id { + continue + } + if n.AddressV4 == nil || *n.AddressV4 != "8.8.8.8" || n.ASNV4 == nil || *n.ASNV4 != 64500 || n.PrefixV4 == nil || + *n.PrefixV4 != n.IPMetadata.Observed.ASN.Value.Prefix || n.AddressObservations.V4.LookupSource == nil { + t.Fatalf("addressing: %+v", n.ProbeAddressing) + } } c := f.client(f.root.URL, false) selected, err := c.SelectExecutor(f.ctx, id, client.ExecutorFilter{ASN: 64500, Country: "CH"}) diff --git a/internal/dispatcher/transport/api/probe_addresses_test.go b/internal/dispatcher/transport/api/probe_addresses_test.go new file mode 100644 index 00000000..4412297c --- /dev/null +++ b/internal/dispatcher/transport/api/probe_addresses_test.go @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 ETH Zurich + +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/netsec-ethz/debuglet/pkg/wire" + pb "github.com/netsec-ethz/debuglet/protocol" +) + +// A public executor's observed address is in the public listing, as a RIPE +// Atlas probe's is; a private one's is withheld from everyone but operators, +// and nothing else about it is. +func TestPrivateExecutorWithholdsOnlyAddresses(t *testing.T) { + f := ccNewFixtureWith(t) + account, token, c := authAccount(t, f, "probe-viewer") + private := apiTestOwner(t, f.d, "private-probe") + if err := apiTestRegister(f.ctx, f.d, private, &pb.HelloResponse{ExecutorId: "private-probe", Currency: "TEST", + VantagePoint: &pb.VantagePointReport{SchemaVersion: 1, AddressOptOut: true}}, "192.0.2.77"); err != nil { + t.Fatal(err) + } + private.MarkRegistered() + list := func() map[string]wire.Executor { + t.Helper() + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/executors", nil).WithContext(f.ctx) + req.Header.Set("Authorization", "Bearer "+token) + f.root.Config.Handler.ServeHTTP(rec, req) + oaCheckResponse(t, oaContract(t), http.MethodGet, "/executors", rec.Code, rec.Body.Bytes()) + var nodes []wire.Executor + if err := json.Unmarshal(rec.Body.Bytes(), &nodes); err != nil { + t.Fatal(err) + } + out := map[string]wire.Executor{} + for _, n := range nodes { + out[n.ID] = n + } + if !strings.Contains(rec.Body.String(), `"is_public":false`) { + t.Fatalf("listing lacks is_public: %s", rec.Body.String()) + } + return out + } + nodes := list() + if n := nodes[ccExecutorID]; n.IsPublic == nil || !*n.IsPublic || n.AddressV4 == nil || *n.AddressV4 != "127.0.0.1" { + t.Fatalf("public executor's observed address not listed: %+v", n.ProbeAddressing) + } + n := nodes["private-probe"] + if n.IsPublic == nil || *n.IsPublic || n.AddressV4 != nil || n.AddressV6 != nil { + t.Fatalf("private executor's address listed: %+v", n.ProbeAddressing) + } + if n.AddressObservations == nil || n.AddressObservations.V4 == nil || n.AddressObservations.V4.Via != wire.AddressViaControl || n.IPMetadata == nil { + t.Fatalf("private executor lost its public facts: %+v", n) + } + if selected, err := c.Nodes(f.ctx); err != nil || len(selected) != 2 { + t.Fatalf("SDK listing: %+v %v", selected, err) + } + authGrantOperator(t, f, account.ID) + if n := list()["private-probe"]; n.AddressV4 == nil || *n.AddressV4 != "192.0.2.77" { + t.Fatalf("operator does not see the private address: %+v", n.ProbeAddressing) + } +} diff --git a/internal/dispatcher/transport/api/vantage_test.go b/internal/dispatcher/transport/api/vantage_test.go index 88f12ff9..e70c3a7c 100644 --- a/internal/dispatcher/transport/api/vantage_test.go +++ b/internal/dispatcher/transport/api/vantage_test.go @@ -46,6 +46,12 @@ func TestVantageMetadataCrossesControlHTTPAndSDK(t *testing.T) { if d := node.Display; *d.DisplayName.Value != "ETH lab" || *d.Country.Value != "CH" || *d.City.Source != wire.SourceOperator || d.Network != (wire.LabelledString{}) { t.Fatalf("display: %+v", d) } + // The control connection's peer is the observed address of its family; + // no other family has been observed. + if node.IsPublic == nil || !*node.IsPublic || node.AddressV4 == nil || *node.AddressV4 != "127.0.0.1" || node.AddressV6 != nil || + node.AddressObservations == nil || node.AddressObservations.V4 == nil || node.AddressObservations.V4.Via != wire.AddressViaControl || node.AddressObservations.V6 != nil { + t.Fatalf("addressing: %+v", node.ProbeAddressing) + } // Before its first heartbeat the executor is listed but offline. if node.SCIONISDAS != (wire.ObservedString{}) || node.Listeners.Value != nil || node.Admission != wire.AdmissionOffline { t.Fatalf("legacy peer invented vantage facts: %+v", node) @@ -84,6 +90,9 @@ func TestVantageMetadataCrossesControlHTTPAndSDK(t *testing.T) { if ia := v.SCIONISDAS; ia.Value == nil || *ia.Value != "1-ff00:0:110" || *ia.Source != wire.SourceExecutorReported || ia.Stale == nil || *ia.Stale { t.Fatalf("admitted isd-as: %+v", ia) } + if a := v.Addressing; a == nil || a.AddressV4 == nil || *a.AddressV4 != "127.0.0.1" || a.IsPublic == nil || !*a.IsPublic { + t.Fatalf("admitted addressing: %+v", a) + } if *v.Display.DisplayName.Value != "ETH lab" || *v.Display.DisplayName.Source != wire.SourceOperator { t.Fatalf("admitted display: %+v", v.Display) } diff --git a/internal/executor/address_observation.go b/internal/executor/address_observation.go new file mode 100644 index 00000000..3618b304 --- /dev/null +++ b/internal/executor/address_observation.go @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 ETH Zurich + +package executor + +import ( + "context" + "crypto/rand" + "net" + "net/netip" + "strconv" + "sync" + "time" + + "github.com/netsec-ethz/debuglet/internal/connectivity" + "github.com/netsec-ethz/debuglet/internal/controlsession" + pb "github.com/netsec-ethz/debuglet/protocol" +) + +// addressObservationInterval spaces the calls that let the dispatcher observe +// this executor's address in each family. The address changes rarely; the +// listing reports when it was last seen. +const addressObservationInterval = 10 * time.Minute + +// addressObservationTimeout bounds one family's name lookup and call, which +// run on the heartbeat loop. +const addressObservationTimeout = 1500 * time.Millisecond + +// addressTarget is the dispatcher endpoint of one family and the name its +// certificate is verified for. +type addressTarget struct { + network, host string + port uint16 + literal netip.Addr // Valid when dispatcher.addr is a literal. +} + +// addressTargets returns the families to observe: those without a configured +// reflector, whose own calls already let the dispatcher observe them. +func (e *Executor) addressTargets() []addressTarget { + host, portText, err := net.SplitHostPort(e.cfg.Dispatcher.Addr) + port, portErr := strconv.ParseUint(portText, 10, 16) + if err != nil || portErr != nil || port == 0 || host == "" { + return nil + } + literal, _ := netip.ParseAddr(host) + targets := []addressTarget{} + for _, family := range []struct { + network string + configured string + v4 bool + }{{"ip4", e.cfg.Connectivity.IPv4Reflector, true}, {"ip6", e.cfg.Connectivity.IPv6Reflector, false}} { + if family.configured != "" || literal.IsValid() && literal.Unmap().Is4() != family.v4 { + continue + } + targets = append(targets, addressTarget{network: family.network, host: host, port: uint16(port), literal: literal.Unmap()}) + } + return targets +} + +// observeAddresses lets the dispatcher see this executor's address in each +// address family, at most every addressObservationInterval. For each family it +// resolves the configured dispatcher name and calls the authenticated address +// reflection over that family. The dispatcher records the peer address of the +// call, never a value the executor sends, and TLS verifies that the resolved +// address is the dispatcher. Outcomes are not reported: a family that does not +// work is simply not observed. Called only by the serialized heartbeat loop. +func (e *Executor) observeAddresses(ctx context.Context, binding controlsession.Binding) { + if !e.cfg.Connectivity.ObserveAddresses || e.Bidi == nil { + return + } + now := time.Now() + e.capabilityMu.Lock() + if now.Before(e.addressNext) { + e.capabilityMu.Unlock() + return + } + e.addressNext = now.Add(addressObservationInterval) + e.capabilityMu.Unlock() + var wg sync.WaitGroup + for _, target := range e.addressTargets() { + wg.Add(1) + go func(target addressTarget) { + defer wg.Done() + callCtx, cancel := context.WithTimeout(ctx, addressObservationTimeout) + defer cancel() + address := target.literal + if !address.IsValid() { + addresses, err := net.DefaultResolver.LookupNetIP(callCtx, target.network, target.host) + if err != nil || len(addresses) == 0 { + return + } + address = addresses[0].Unmap() + } + nonce := make([]byte, connectivity.TokenSize) + if _, err := rand.Read(nonce); err != nil { + return + } + endpoint := netip.AddrPortFrom(address, target.port).String() + _, _ = e.Bidi.ReflectAddressAs(callCtx, binding, endpoint, target.host, &pb.ReflectAddressRequest{ExecutorId: e.cfg.Identity.ExecutorID, Nonce: nonce}) + }(target) + } + wg.Wait() +} diff --git a/internal/executor/address_observation_test.go b/internal/executor/address_observation_test.go new file mode 100644 index 00000000..d33c037d --- /dev/null +++ b/internal/executor/address_observation_test.go @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 ETH Zurich + +package executor + +import ( + "testing" + + "github.com/netsec-ethz/debuglet/internal/controlsession" + "github.com/netsec-ethz/debuglet/internal/executor/config" +) + +func TestAddressTargetsSkipConfiguredReflectorsAndOtherLiteralFamily(t *testing.T) { + for _, tc := range []struct { + name, addr, v4, v6 string + want []string + }{ + {"name observes both families", "dispatcher.example:9090", "", "", []string{"ip4", "ip6"}}, + {"configured reflector already observes its family", "dispatcher.example:9090", "192.0.2.1:9090", "", []string{"ip6"}}, + {"both reflectors configured", "dispatcher.example:9090", "192.0.2.1:9090", "[2001:db8::1]:9090", []string{}}, + {"IPv4 literal has no IPv6 address", "192.0.2.1:9090", "", "", []string{"ip4"}}, + {"IPv6 literal has no IPv4 address", "[2001:db8::1]:9090", "", "", []string{"ip6"}}, + {"malformed address", "dispatcher.example", "", "", []string{}}, + {"zero port", "dispatcher.example:0", "", "", []string{}}, + } { + t.Run(tc.name, func(t *testing.T) { + e := &Executor{cfg: config.ExecutorConfig{Dispatcher: config.DispatcherConfig{Addr: tc.addr}, + Connectivity: config.ConnectivityConfig{IPv4Reflector: tc.v4, IPv6Reflector: tc.v6, ObserveAddresses: true}}} + got := []string{} + for _, target := range e.addressTargets() { + got = append(got, target.network) + if target.port != 9090 { + t.Fatalf("port = %d", target.port) + } + } + if len(got) != len(tc.want) { + t.Fatalf("families = %v, want %v", got, tc.want) + } + for i := range got { + if got[i] != tc.want[i] { + t.Fatalf("families = %v, want %v", got, tc.want) + } + } + }) + } +} + +func TestObserveAddressesIsOptional(t *testing.T) { + e := &Executor{cfg: config.ExecutorConfig{Dispatcher: config.DispatcherConfig{Addr: "dispatcher.example:9090"}}} + e.observeAddresses(t.Context(), controlsession.Binding{}) + if !e.addressNext.IsZero() { + t.Fatal("disabled observation was scheduled") + } + e.cfg.Connectivity.ObserveAddresses = true + // Without a control transport nothing is sent or scheduled. + e.observeAddresses(t.Context(), controlsession.Binding{}) + if !e.addressNext.IsZero() { + t.Fatal("observation without a transport was scheduled") + } +} diff --git a/internal/executor/capabilities.go b/internal/executor/capabilities.go index 5f94e8cc..72669484 100644 --- a/internal/executor/capabilities.go +++ b/internal/executor/capabilities.go @@ -60,7 +60,7 @@ func (e *Executor) capabilityReport(ctx context.Context, initial bool) (*pb.Exec report := &pb.ExecutorCapabilities{SchemaVersion: 1, Attribution: attribution, Icmp: icmp, Tagging: &pb.TaggingMode{Ipv4: tagging.IPv4, Ipv6: tagging.IPv6, Scion: tagging.SCION, TagSpec: tesla.TagSpec}} - vantage := &pb.VantagePointReport{SchemaVersion: 1, LocationOptOut: e.cfg.Metadata.LocationOptOut, Clock: e.clockReport(), Platform: platformReport(hostprobe.ReadPlatform())} + vantage := &pb.VantagePointReport{SchemaVersion: 1, LocationOptOut: e.cfg.Metadata.LocationOptOut, AddressOptOut: e.cfg.Metadata.AddressOptOut, Clock: e.clockReport(), Platform: platformReport(hostprobe.ReadPlatform())} stateDir := "" if e.cfg.Database.Path != "" { stateDir = filepath.Dir(e.cfg.Database.Path) diff --git a/internal/executor/config/config.go b/internal/executor/config/config.go index 4a95dd90..577d1d45 100644 --- a/internal/executor/config/config.go +++ b/internal/executor/config/config.go @@ -39,9 +39,14 @@ type ExecutorConfig struct { Connectivity ConnectivityConfig } -// MetadataConfig controls publication of automatically derived location. +// MetadataConfig controls publication of automatically derived location and +// of the addresses the dispatcher observes. type MetadataConfig struct { LocationOptOut bool `toml:"location_opt_out"` + // AddressOptOut makes the executor private (is_public false): the public + // listing then withholds its observed addresses. Prefix, ASN and + // location stay public, as for a private RIPE Atlas probe. + AddressOptOut bool `toml:"address_opt_out"` } // ClockConfig bounds the kernel's estimated clock error the executor accepts @@ -325,6 +330,9 @@ func DecodeConfig(data []byte) (*ExecutorConfig, configcheck.Document, error) { if cfg.Dispatcher.YamuxAddr == "" { cfg.Dispatcher.YamuxAddr = cfg.Dispatcher.Addr } + if !document.Set("connectivity", "observe_addresses") { + cfg.Connectivity.ObserveAddresses = true + } if err := cfg.Validate(); err != nil { return nil, document, err diff --git a/internal/executor/config/connectivity.go b/internal/executor/config/connectivity.go index 2c6f4940..eec1bb1a 100644 --- a/internal/executor/config/connectivity.go +++ b/internal/executor/config/connectivity.go @@ -17,6 +17,12 @@ type ConnectivityConfig struct { IPv6Reflector string `toml:"ipv6_reflector"` Listeners bool `toml:"listeners"` SCIONPathTarget string `toml:"scion_path_target"` + // ObserveAddresses (default true) lets the dispatcher observe this + // executor's address in each family: every 10 minutes, for each family + // without a configured reflector, the executor resolves dispatcher.addr + // and calls the address reflection over that family. It feeds the + // listing's address_v4 and address_v6 only, never admission. + ObserveAddresses bool `toml:"observe_addresses"` } func (c ConnectivityConfig) Validate(plaintext bool) error { diff --git a/internal/executor/config/ip_metadata_test.go b/internal/executor/config/ip_metadata_test.go index bd63c013..b15f261d 100644 --- a/internal/executor/config/ip_metadata_test.go +++ b/internal/executor/config/ip_metadata_test.go @@ -17,3 +17,14 @@ func TestLocationOptOutConfiguration(t *testing.T) { } } } + +func TestAddressPublicationConfiguration(t *testing.T) { + cfg, _, err := DecodeConfig([]byte(baseSections)) + if err != nil || cfg.Metadata.AddressOptOut || !cfg.Connectivity.ObserveAddresses { + t.Fatalf("defaults are not public with address observation: %+v %v", cfg, err) + } + cfg, _, err = DecodeConfig([]byte(baseSections + "[metadata]\naddress_opt_out=true\n[connectivity]\nobserve_addresses=false\n")) + if err != nil || !cfg.Metadata.AddressOptOut || cfg.Metadata.LocationOptOut || cfg.Connectivity.ObserveAddresses { + t.Fatalf("explicit settings not kept: %+v %v", cfg, err) + } +} diff --git a/internal/executor/connectivity.go b/internal/executor/connectivity.go index 02b6a9ff..266bddce 100644 --- a/internal/executor/connectivity.go +++ b/internal/executor/connectivity.go @@ -31,6 +31,7 @@ func (e *Executor) initialConnectivityReport() *pb.ConnectivityReport { // temporary listeners remain bound until the matching heartbeat returns, then // finish joins their responders and releases their actual pool reservations. func (e *Executor) probeConnectivity(ctx context.Context, binding controlsession.Binding, vantage *pb.VantagePointReport) func() { + e.observeAddresses(ctx, binding) if vantage == nil { return func() {} } diff --git a/internal/executor/executor.go b/internal/executor/executor.go index 1d8b0307..649e435d 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -65,6 +65,7 @@ type Executor struct { capabilityMu sync.Mutex capabilityNext time.Time connectivityNext time.Time + addressNext time.Time // capabilityReason is the attribution reason of the last report sent. capabilityReason string // capabilityTagging is the tagging mode of the last report sent. diff --git a/internal/executor/transport/rpc/reflect.go b/internal/executor/transport/rpc/reflect.go index 5cce8387..5f4e7354 100644 --- a/internal/executor/transport/rpc/reflect.go +++ b/internal/executor/transport/rpc/reflect.go @@ -19,6 +19,15 @@ import ( // literal address of this dispatcher, under the existing TLS identity and // control binding. No request, peer response or DNS result chooses its target. func (b *BidiClient) ReflectAddress(ctx context.Context, binding controlsession.Binding, endpoint string, request *pb.ReflectAddressRequest) (*pb.ReflectAddressResponse, error) { + return b.ReflectAddressAs(ctx, binding, endpoint, "", request) +} + +// ReflectAddressAs is ReflectAddress to a literal endpoint that the executor +// resolved from its configured dispatcher name. TLS verifies the dispatcher's +// certificate for authority, that name, unless tls.server_name overrides it, +// so a resolved address that is not this dispatcher fails the handshake. +// An empty authority keeps the endpoint's own. +func (b *BidiClient) ReflectAddressAs(ctx context.Context, binding controlsession.Binding, endpoint, authority string, request *pb.ReflectAddressRequest) (*pb.ReflectAddressResponse, error) { address, err := netip.ParseAddrPort(endpoint) if err != nil || address.Port() == 0 || address.Addr().IsUnspecified() || address.Addr().IsMulticast() || address.Addr().Zone() != "" { return nil, errors.New("invalid configured reflector") @@ -43,10 +52,14 @@ func (b *BidiClient) ReflectAddress(ctx context.Context, binding controlsession. if address.Addr().Is4() { family = "tcp4" } - connection, err := grpc.NewClient(endpoint, grpc.WithTransportCredentials(creds), grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { + options := []grpc.DialOption{grpc.WithTransportCredentials(creds), grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { var dialer net.Dialer return dialer.DialContext(ctx, family, endpoint) - })) + })} + if authority != "" { + options = append(options, grpc.WithAuthority(authority)) + } + connection, err := grpc.NewClient(endpoint, options...) if err != nil { return nil, err } diff --git a/pkg/wire/models.go b/pkg/wire/models.go index fb80f140..17921c32 100644 --- a/pkg/wire/models.go +++ b/pkg/wire/models.go @@ -58,6 +58,9 @@ type Executor struct { // is operator-only and never part of this public listing. Clock ObservedClock `json:"clock"` IPMetadata *IPMetadata `json:"ip_metadata,omitempty"` + // ProbeAddressing (API 1.16) adds the RIPE Atlas-style is_public, + // address, prefix and ASN fields of each address family. + ProbeAddressing } // State is a debuglet's reported state. Unknown state strings are preserved. diff --git a/pkg/wire/probe.go b/pkg/wire/probe.go new file mode 100644 index 00000000..86305108 --- /dev/null +++ b/pkg/wire/probe.go @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 ETH Zurich + +package wire + +// Ways the dispatcher observed an executor's address. Both are the peer +// address of a connection the executor authenticated with its own identity; +// neither is the executor's claim. +const ( + // AddressViaControl is the executor's control connection. + AddressViaControl = "control" + // AddressViaReflection is a ReflectAddress call over the other address + // family (docs/operations/executor-discovery.md). + AddressViaReflection = "reflection" +) + +// AddressObservation describes how and when the dispatcher last observed the +// address of one family. The address itself is in Executor.AddressV4 or +// AddressV6, where is_public may hide it; this record never carries it. +type AddressObservation struct { + Source string `json:"source"` // Always dispatcher-observed. + Via string `json:"via"` // control or reflection. + // ObservedAt is the Unix second of the last observation: the last + // heartbeat of the control session that carries the address, or the + // receipt of the last reflection call. + ObservedAt int64 `json:"observed_at"` + // LookupSource names the offline ASN database the prefix and ASN come + // from, as in ip_metadata; null when no database is configured. + LookupSource *string `json:"lookup_source"` + // LookupReason is empty when the database knew the address; otherwise + // it explains a null prefix and ASN, with the ip_metadata vocabulary. + LookupReason string `json:"lookup_reason"` +} + +// AddressObservations has one record per address family, null when the +// dispatcher has observed no address of that family in the current control +// session. +type AddressObservations struct { + V4 *AddressObservation `json:"v4"` + V6 *AddressObservation `json:"v6"` +} + +// ProbeAddressing is the RIPE Atlas-style addressing of an executor in the +// public listing, embedded in Executor. A dispatcher of API 1.16 or later +// always sends every field, null when unknown; all are absent from +// dispatchers older than API 1.16, which a client reads as unknown. +// +// AddressV4 and AddressV6 are the last addresses the dispatcher itself +// observed for each family. They are null when none was observed, and in the +// public listing also when IsPublic is false; an operator always sees them. +// Prefixes and ASNs are looked up from those addresses in the offline ASN +// database and stay public for a private executor, as RIPE Atlas does. +type ProbeAddressing struct { + IsPublic *bool `json:"is_public"` + AddressV4 *string `json:"address_v4"` + AddressV6 *string `json:"address_v6"` + PrefixV4 *string `json:"prefix_v4"` + PrefixV6 *string `json:"prefix_v6"` + ASNV4 *uint32 `json:"asn_v4"` + ASNV6 *uint32 `json:"asn_v6"` + AddressObservations *AddressObservations `json:"address_observations"` +} diff --git a/pkg/wire/result.go b/pkg/wire/result.go index 83129f5b..06f66321 100644 --- a/pkg/wire/result.go +++ b/pkg/wire/result.go @@ -77,6 +77,10 @@ type VantagePoint struct { // to the run's owner and operators. Clock LabelledReport[ClockReport] `json:"clock"` Platform LabelledReport[HostPlatform] `json:"platform"` + // Addressing is the RIPE Atlas-style addressing at admission, with the + // addresses of a private executor too. Added within schema 1; absent in + // earlier files, which means null. + Addressing *ProbeAddressing `json:"addressing,omitempty"` } // LabelledReport is an expiring report as known at admission, like diff --git a/protocol/protocol.pb.go b/protocol/protocol.pb.go index 7a8029cf..48704c61 100644 --- a/protocol/protocol.pb.go +++ b/protocol/protocol.pb.go @@ -2718,8 +2718,12 @@ type VantagePointReport struct { ScionPaths *ProbeState `protobuf:"bytes,10,opt,name=scion_paths,json=scionPaths,proto3" json:"scion_paths,omitempty"` // Daemon path availability, not data-plane reachability. Resources *HostResources `protobuf:"bytes,11,opt,name=resources,proto3" json:"resources,omitempty"` // Process and state-filesystem observations; operator metrics only. CounterAttachment string `protobuf:"bytes,12,opt,name=counter_attachment,json=counterAttachment,proto3" json:"counter_attachment,omitempty"` // present, missing or unknown: owned TCX links, not proof of packet policing. - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Hide the dispatcher-observed addresses from the public executor listing + // (is_public false); prefix, ASN and location stay public. Read at + // registration; false for older peers, which are public. + AddressOptOut bool `protobuf:"varint,13,opt,name=address_opt_out,json=addressOptOut,proto3" json:"address_opt_out,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *VantagePointReport) Reset() { @@ -2836,6 +2840,13 @@ func (x *VantagePointReport) GetCounterAttachment() string { return "" } +func (x *VantagePointReport) GetAddressOptOut() bool { + if x != nil { + return x.AddressOptOut + } + return false +} + type HostResourceValue struct { state protoimpl.MessageState `protogen:"open.v1"` Value *uint64 `protobuf:"varint,1,opt,name=value,proto3,oneof" json:"value,omitempty"` @@ -3636,7 +3647,7 @@ const file_protocol_protocol_proto_rawDesc = "" + "\x12disclosure_held_ms\x18\a \x01(\x03H\x02R\x10disclosureHeldMs\x88\x01\x01B\x12\n" + "\x10_installed_epochB\x16\n" + "\x14_last_refresh_age_msB\x15\n" + - "\x13_disclosure_held_ms\"\xdc\x04\n" + + "\x13_disclosure_held_ms\"\x84\x05\n" + "\x12VantagePointReport\x12%\n" + "\x0eschema_version\x18\x01 \x01(\rR\rschemaVersion\x12 \n" + "\fscion_isd_as\x18\x02 \x01(\tR\n" + @@ -3653,7 +3664,8 @@ const file_protocol_protocol_proto_rawDesc = "" + " \x01(\v2\x1d.debuglet.protocol.ProbeStateR\n" + "scionPaths\x12>\n" + "\tresources\x18\v \x01(\v2 .debuglet.protocol.HostResourcesR\tresources\x12-\n" + - "\x12counter_attachment\x18\f \x01(\tR\x11counterAttachment\"Z\n" + + "\x12counter_attachment\x18\f \x01(\tR\x11counterAttachment\x12&\n" + + "\x0faddress_opt_out\x18\r \x01(\bR\raddressOptOut\"Z\n" + "\x11HostResourceValue\x12\x19\n" + "\x05value\x18\x01 \x01(\x04H\x00R\x05value\x88\x01\x01\x12 \n" + "\vunavailable\x18\x02 \x01(\tR\vunavailableB\b\n" + diff --git a/protocol/protocol.proto b/protocol/protocol.proto index 455df5c7..42537953 100644 --- a/protocol/protocol.proto +++ b/protocol/protocol.proto @@ -360,6 +360,10 @@ message VantagePointReport { ProbeState scion_paths = 10; // Daemon path availability, not data-plane reachability. HostResources resources = 11; // Process and state-filesystem observations; operator metrics only. string counter_attachment = 12; // present, missing or unknown: owned TCX links, not proof of packet policing. + // Hide the dispatcher-observed addresses from the public executor listing + // (is_public false); prefix, ASN and location stay public. Read at + // registration; false for older peers, which are public. + bool address_opt_out = 13; } message HostResourceValue {