mirror of
https://github.com/openziti/ziti.git
synced 2026-09-10 08:45:41 +00:00
merge release-v2.0.x: move changelog entry to 2.0.4, keep both testdata readme sections
This commit is contained in:
@@ -14,5 +14,5 @@ jobs:
|
||||
- name: Run code spelling check
|
||||
uses: codespell-project/actions-codespell@v2
|
||||
with:
|
||||
ignore_words_list: allos,ans,dne,noe,referr,ssudo,te,tranfer,ue
|
||||
skip: go.*,zititest/go.*,./controller/storage/zitiql/zitiql_parser.go
|
||||
ignore_words_list: actieve,allos,ans,dne,noe,referr,ssudo,te,tranfer,ue
|
||||
skip: go.*,zititest/go.*,./controller/storage/zitiql/zitiql_parser.go,*.pem,*.cert,*.key
|
||||
|
||||
@@ -21,9 +21,16 @@ jobs:
|
||||
send-notifications:
|
||||
runs-on: ubuntu-24.04
|
||||
name: POST Webhook with Python
|
||||
# a chat notification must never fail the release: this job's status is reported as success even when the
|
||||
# Mattermost webhook is unreachable, so workflows that gate on the checks for a revision aren't blocked by it
|
||||
continue-on-error: true
|
||||
# the webhook is dialed over the ziti overlay with no client-side connect timeout, so cap the job instead
|
||||
timeout-minutes: 5
|
||||
if: github.actor != 'dependabot[bot]'
|
||||
steps:
|
||||
# per-step too, so an unreachable webhook in the first post doesn't skip the second
|
||||
- uses: openziti/ziti-mattermost-action-py@v1
|
||||
continue-on-error: true
|
||||
if: |
|
||||
github.repository_owner == 'openziti'
|
||||
&& ((github.event_name != 'pull_request_review')
|
||||
@@ -35,6 +42,7 @@ jobs:
|
||||
senderUsername: "GitHubZ"
|
||||
|
||||
- uses: openziti/ziti-mattermost-action-py@v1
|
||||
continue-on-error: true
|
||||
if: |
|
||||
github.repository_owner == 'openziti'
|
||||
&& ((github.event_name != 'pull_request_review')
|
||||
|
||||
@@ -14,6 +14,9 @@ on:
|
||||
jobs:
|
||||
mattermost-ziti-nodejs-webhook:
|
||||
continue-on-error: true
|
||||
# the webhook is dialed over the ziti overlay with no client-side connect timeout; when the service is down this
|
||||
# job has sat for hours, stalling anything that waits on the checks for a revision
|
||||
timeout-minutes: 5
|
||||
runs-on: ubuntu-24.04
|
||||
name: POST Webhook with NodeJS
|
||||
if: github.repository_owner == 'openziti' && github.actor != 'dependabot[bot]'
|
||||
|
||||
@@ -1,61 +1,26 @@
|
||||
name: Promote Downstream Releases
|
||||
|
||||
on:
|
||||
# may be triggered manually on a release tag that represents a prerelease to promote it to a release in the downstream package repositories and Docker Hub
|
||||
on:
|
||||
# may be triggered manually to promote a release tag to stable in the downstream package repositories and Docker Hub.
|
||||
# Dispatch from the tag itself, or from any branch and set the 'tag' input to the release tag to promote.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: Release tag to promote, e.g. v2.0.0. Defaults to the ref the run was dispatched from; set this to promote a specific tag when dispatching from a branch.
|
||||
required: false
|
||||
type: string
|
||||
# GitHub release is marked stable, i.e., isPrerelease: false
|
||||
release:
|
||||
types: [released] # this release event activity type excludes prereleases
|
||||
|
||||
# cancel older, redundant runs of same workflow on same branch
|
||||
# cancel older, redundant runs of same workflow for the same tag (or dispatched ref)
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.ref_name }}
|
||||
group: ${{ github.workflow }}-${{ github.event.inputs.tag || github.head_ref || github.ref_name }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
wait_for_release:
|
||||
name: Wait for Release Builds to Succeed
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Debug action
|
||||
uses: hmarr/debug-action@v3
|
||||
|
||||
- name: Wait for all checks on this rev
|
||||
uses: lewagon/wait-on-check-action@v1.5.0
|
||||
with:
|
||||
ref: ${{ github.ref_name }}
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
# seconds between polling the checks api for job statuses
|
||||
wait-interval: 30
|
||||
# confusingly, this means "pause this step until all jobs from all workflows in same run have completed"
|
||||
running-workflow-name: Wait for Release Builds to Succeed
|
||||
# comma-separated list of check names (job.<id>.name) to ignore
|
||||
ignore-checks: SDK Terminator Validation,Fablab HA Smoketest,POST Webhook,Release Quickstart Job,Parse Tag Regex
|
||||
|
||||
- name: Git Checkout
|
||||
if: failure()
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Diagnose Failed "Wait for Release Builds to Succeed"
|
||||
if: failure()
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
set -o xtrace
|
||||
|
||||
COMMIT_SHA=$(git rev-parse ${GITHUB_REF_NAME}^{commit})
|
||||
for STATUS in cancelled failure
|
||||
do
|
||||
gh run list --repo "${GITHUB_REPOSITORY}" --status "${STATUS}" --commit "${COMMIT_SHA}"
|
||||
done
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# the purpose of this job is to enforce that the Git ref promoted is a semver eligible for stable release, i.e., not having a semver pre-release suffix; the extracted version without the leading 'v' is passed to the docker job as the container image tag
|
||||
parse_version:
|
||||
needs: wait_for_release
|
||||
name: Parse Tag Regex
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
@@ -65,11 +30,14 @@ jobs:
|
||||
- name: Validate the Release Tag is a Stable Release Ref
|
||||
id: validate
|
||||
shell: bash
|
||||
env:
|
||||
# the 'tag' input when dispatched from a branch, otherwise the ref the run fired on (the tag)
|
||||
PROMOTE_REF: ${{ github.event.inputs.tag || github.ref_name }}
|
||||
run: |
|
||||
if [[ "${GITHUB_REF_NAME}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "version=${GITHUB_REF_NAME#v}" | tee -a $GITHUB_OUTPUT
|
||||
if [[ "${PROMOTE_REF}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "version=${PROMOTE_REF#v}" | tee -a $GITHUB_OUTPUT
|
||||
else
|
||||
echo "${GITHUB_REF_NAME} is not a semver stable release ref" >&2
|
||||
echo "${PROMOTE_REF} is not a semver stable release ref" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -96,14 +64,15 @@ jobs:
|
||||
| sort -V \
|
||||
| tail -1
|
||||
)
|
||||
CURRENT_VERSION="${GITHUB_REF_NAME}"
|
||||
|
||||
CURRENT_VERSION="${PROMOTE_REF}"
|
||||
|
||||
if [[ "$CURRENT_VERSION" == "$HIGHEST_VERSION" ]]; then
|
||||
echo "highest=true" | tee -a $GITHUB_OUTPUT
|
||||
else
|
||||
echo "highest=false" | tee -a $GITHUB_OUTPUT
|
||||
fi
|
||||
env:
|
||||
PROMOTE_REF: ${{ github.event.inputs.tag || github.ref_name }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
promote_docker:
|
||||
@@ -137,7 +106,9 @@ jobs:
|
||||
name: Promote ${{ matrix.package_name }}-${{ matrix.arch.rpm }}.${{ matrix.packager }}
|
||||
needs: parse_version
|
||||
strategy:
|
||||
fail-fast: true
|
||||
# each copy is independent and idempotent, so let the rest finish and report exactly which one failed instead of
|
||||
# cancelling siblings and leaving an arbitrary subset of the packages promoted
|
||||
fail-fast: false
|
||||
matrix:
|
||||
package_name:
|
||||
- openziti
|
||||
@@ -161,7 +132,7 @@ jobs:
|
||||
ZITI_RPM_PROD_REPO: ${{ vars.ZITI_RPM_PROD_REPO || 'zitipax-openziti-rpm-stable' }}
|
||||
steps:
|
||||
- name: Configure jFrog CLI
|
||||
uses: jfrog/setup-jfrog-cli@v4
|
||||
uses: jfrog/setup-jfrog-cli@v5
|
||||
env:
|
||||
JF_ENV_1: ${{ secrets.ZITI_ARTIFACTORY_CLI_CONFIG_PACKAGE_UPLOAD }}
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Configure jFrog CLI
|
||||
uses: jfrog/setup-jfrog-cli@v4
|
||||
uses: jfrog/setup-jfrog-cli@v5
|
||||
env:
|
||||
JF_ENV_1: ${{ secrets.ZITI_ARTIFACTORY_CLI_CONFIG_PACKAGE_UPLOAD }}
|
||||
|
||||
|
||||
+109
-3
@@ -1,4 +1,4 @@
|
||||
# Release 2.0.2
|
||||
# Release 2.0.4
|
||||
|
||||
## What's New
|
||||
|
||||
@@ -6,10 +6,116 @@
|
||||
|
||||
## Component Updates and Bug Fixes
|
||||
|
||||
* github.com/openziti/ziti/v2: [v2.0.1 -> v2.0.2](https://github.com/openziti/ziti/compare/v2.0.0...v2.0.1)
|
||||
* github.com/openziti/ziti/v2: [v2.0.3 -> v2.0.4](https://github.com/openziti/ziti/compare/v2.0.3...v2.0.4)
|
||||
* [Issue #4264](https://github.com/openziti/ziti/issues/4264) - [Backport-2.0] Router control-channel connect/disconnect race can leave a reconnected router de-registered
|
||||
* [Issue #3891](https://github.com/openziti/ziti/issues/3891) - [Backport-2.0] OIDC auth fails when the controller's server certificate has a wildcard SAN. A wildcard SAN is now expanded to the exact hostnames listed in the new `edge-oidc` `allowedHostnames` option, which become valid OIDC issuers
|
||||
|
||||
# Release 2.0.3
|
||||
|
||||
## What's New
|
||||
|
||||
* Security fixes (see Security Advisories below)
|
||||
* Bug fixes
|
||||
* Controller read throughput under load: this release picks up bbolt v1.5.0, which removes a linear
|
||||
scan over all open read transactions that ran while holding bbolt's single global transaction
|
||||
mutex. Every controller read transaction takes that mutex twice, on open and on close, so the scan
|
||||
cost grew with read concurrency and could put a controller serving a high rate of service-list and
|
||||
policy queries into a lock convoy: many goroutines waiting on one mutex, a machine that looks
|
||||
fully busy while little work completes, and timeouts unexplained by the actual workload.
|
||||
|
||||
## Security Advisories
|
||||
|
||||
This release addresses eight security advisories. See the linked GitHub Security Advisories for full
|
||||
details, impact, and affected versions.
|
||||
|
||||
* [GHSA-q8g9-jc4c-jp6q](https://github.com/openziti/ziti/security/advisories/GHSA-q8g9-jc4c-jp6q) (CVE pending) (High) - The controller buffered the entire body of every
|
||||
inbound request before any authentication check and with no size cap, so an unauthenticated client could
|
||||
exhaust controller memory, and crash it, by sending parallel large-body requests to endpoints such as
|
||||
enrollment.
|
||||
* [GHSA-j952-6x8x-jmj6](https://github.com/openziti/ziti/security/advisories/GHSA-j952-6x8x-jmj6) (CVE pending) (High) - The unauthenticated legacy enrollment path buffered
|
||||
the request body a second time, allocating twice the memory per request and roughly halving the bandwidth
|
||||
needed to drive the controller out of memory. Amplifies GHSA-q8g9-jc4c-jp6q.
|
||||
* [GHSA-hhm9-wf63-g7qj](https://github.com/openziti/ziti/security/advisories/GHSA-hhm9-wf63-g7qj) (CVE pending) (Medium) - When accepting an incoming router-to-router link, a
|
||||
router verified the dialing router's identity against the whole presented certificate chain instead of the
|
||||
leaf certificate whose key the TLS handshake proved. An attacker holding enrolled router credentials could
|
||||
present another router's certificate as filler and be admitted on a link under that router's identity,
|
||||
letting it intercept, inject, drop, or strand the circuits routed over that link.
|
||||
* [GHSA-7868-235p-7497](https://github.com/openziti/ziti/security/advisories/GHSA-7868-235p-7497) (CVE pending) (Medium) - The controller did not validate the API session
|
||||
token when creating a circuit via CreateCircuitV3, taking the dialing identity from a router-supplied
|
||||
header instead. An attacker holding enrolled router credentials could create circuits on behalf of any
|
||||
identity permitted to dial the service through that router, without that identity having authenticated,
|
||||
yielding data-plane access under an impersonated identity. The same gap meant expired and revoked API
|
||||
sessions were not caught at circuit creation.
|
||||
* [GHSA-4h58-w989-xgg4](https://github.com/openziti/ziti/security/advisories/GHSA-4h58-w989-xgg4) (CVE pending) (Medium) - The token-based enrollment endpoint skipped
|
||||
audience and issuer validation when the request carried a `ziti-token-issuer-id` header, so an attacker
|
||||
holding any unexpired JWT signed by a configured external JWT signer, even one minted for a different
|
||||
audience, could enroll a new identity onto the network.
|
||||
* [GHSA-6v5r-p2wr-q492](https://github.com/openziti/ziti/security/advisories/GHSA-6v5r-p2wr-q492) (CVE pending) (Medium) - The current-api-session certificates endpoint
|
||||
performed an unscoped list, so any authenticated user could read the API session certificates (subject
|
||||
DNs, fingerprints, and full PEM chains) of all identities, not just their own.
|
||||
* [GHSA-whjr-3j94-gw3c](https://github.com/openziti/ziti/security/advisories/GHSA-whjr-3j94-gw3c) (CVE pending) (Medium) - A JWKS endpoint URL configured on an external JWT
|
||||
signer was fetched server-side with no timeout, private-range blocking, or allowlist, letting a caller with
|
||||
external-jwt-signer management access make the controller issue requests to arbitrary internal URLs,
|
||||
including cloud metadata endpoints (SSRF).
|
||||
* [GHSA-354c-gpg9-j988](https://github.com/openziti/ziti/security/advisories/GHSA-354c-gpg9-j988) (CVE pending) (Low) - With promptOnWake or promptOnUnlock enabled on an MFA
|
||||
posture check, the edge router dereferenced a nil wake/unlock timestamp while locally evaluating an
|
||||
authorized client's dial or bind, panicking and crashing the router (data-plane denial of service).
|
||||
|
||||
## Contributors
|
||||
|
||||
Thanks to the community members who contributed to this release.
|
||||
|
||||
* [@msbusk](https://github.com/msbusk) diagnosed the circuit leak in
|
||||
[#4184](https://github.com/openziti/ziti/issues/4184) and validated the fix against a
|
||||
production workload.
|
||||
|
||||
## Component Updates and Bug Fixes
|
||||
|
||||
* github.com/openziti/ziti/v2: [v2.0.2 -> v2.0.3](https://github.com/openziti/ziti/compare/v2.0.2...v2.0.3)
|
||||
* [Issue #4269](https://github.com/openziti/ziti/issues/4269) - [Backport-2.0] Router leaks LinkSendBuffer goroutines in `drainDeadlines()` — circuits accumulate until the router OOMs
|
||||
* [Issue #4242](https://github.com/openziti/ziti/issues/4242) - [Backport-2.0] Legacy v1 create-circuit handler crashes the controller on JWT-prefixed tokens
|
||||
* [Issue #4236](https://github.com/openziti/ziti/issues/4236) - [Backport-2.0] Ensure terminator operations are scoped by source router
|
||||
* [Issue #4207](https://github.com/openziti/ziti/issues/4207) - [Backport-2.0] Lock order inversion in ConnectionTracker deadlocks the controller
|
||||
* [Issue #4203](https://github.com/openziti/ziti/issues/4203) - [Backport-2.0] Controller cluster bootstrapping fixes
|
||||
* [Issue #4166](https://github.com/openziti/ziti/issues/4166) - [Backport-2.0] Fabric terminator remove handlers don't verify the terminator belongs to the requesting router
|
||||
* [Issue #4161](https://github.com/openziti/ziti/issues/4161) - [Backport-2.0] Leaderless controller strands terminator operations during cluster membership changes
|
||||
* [Issue #4146](https://github.com/openziti/ziti/issues/4146) - [Backport-2.0] Upgraded controller rejects legacy clients' existing sessions and gives no recovery signal for invalid service tokens
|
||||
* [Issue #4142](https://github.com/openziti/ziti/issues/4142) - [Backport-2.0] Service-policy enforcer deletes valid legacy sessions; type= queries use numeric id against the string-mapped symbol
|
||||
* [Issue #4139](https://github.com/openziti/ziti/issues/4139) - [Backport-2.0] Add l2 service configuration types
|
||||
* [Issue #4126](https://github.com/openziti/ziti/issues/4126) - [Backport-2.0] Legacy create-session signs service JWT with a mismatched session id after dedup
|
||||
* [Issue #4063](https://github.com/openziti/ziti/issues/4063) - External JWT enrollment fails when a configured role attributes claims selector is absent from the JWT
|
||||
* [Issue #4052](https://github.com/openziti/ziti/issues/4052) - [Backport-2.0] The `ziti` CLI now refreshes an expired access token using the cached refresh token
|
||||
|
||||
# Release 2.0.2
|
||||
|
||||
## What's New
|
||||
|
||||
* Security fixes (see Security Advisories below)
|
||||
* Bug fixes
|
||||
|
||||
## Security Advisories
|
||||
|
||||
This release addresses two control-plane certificate and identity validation vulnerabilities. See the linked
|
||||
GitHub Security Advisories for full details, impact, and affected versions.
|
||||
|
||||
* [GHSA-mrpr-756c-xm47](https://github.com/openziti/ziti/security/advisories/GHSA-mrpr-756c-xm47) (CVE pending) (Critical) - Improper peer certificate validation on the controller
|
||||
cluster mesh, router links, and metrics endpoint. TLS peer checks accepted a connection when any presented
|
||||
certificate chained to the trusted CA while taking the peer identity from the leaf certificate, allowing a
|
||||
peer to be admitted under a forged identity without possessing a trusted key. On HA/clustered controllers
|
||||
this allows joining the controller cluster as an arbitrary controller.
|
||||
* [GHSA-cc5m-7mhm-xh9f](https://github.com/openziti/ziti/security/advisories/GHSA-cc5m-7mhm-xh9f) (CVE pending) (Medium) - Control-channel connections carrying a channel-type header
|
||||
bypassed router certificate and identity verification, allowing an attacker that can reach the controller
|
||||
control port to be admitted as an arbitrary router identity and manipulate that router's fabric terminators,
|
||||
faults, and circuit routing. Impact is limited to router data model metadata (service and identity names) and
|
||||
control-plane manipulation; it does not by itself grant access to the services the network protects.
|
||||
|
||||
## Component Updates and Bug Fixes
|
||||
|
||||
* github.com/openziti/ziti/v2: [v2.0.1 -> v2.0.2](https://github.com/openziti/ziti/compare/v2.0.1...v2.0.2)
|
||||
* [Issue #4136](https://github.com/openziti/ziti/issues/4136) - [Backport-2.0] ziti tunnel ignores --dnsSvcIpRange
|
||||
* [Issue #4149](https://github.com/openziti/ziti/issues/4149) - [Backport-2.0] Upgrading a running 1.x controller/router to 2.x fails to create the service user
|
||||
* [Issue #3891](https://github.com/openziti/ziti/issues/3891) - [Backport-2.0] OIDC auth fails when the controller's server certificate has a wildcard SAN. A wildcard SAN is now expanded to the exact hostnames listed in the new `edge-oidc` `allowedHostnames` option, which become valid OIDC issuers
|
||||
* [Issue #4108](https://github.com/openziti/ziti/issues/4108) - Fix controller panic / potential data corruption by copying terminator peer data, instance secret, and eventual event data out of bolt-managed memory
|
||||
|
||||
|
||||
# Release 2.0.1
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package build
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// maxBuildFlags is the greatest number of flag names accepted from buildFlags.
|
||||
maxBuildFlags = 32
|
||||
|
||||
// maxBuildFlagNameLength is the greatest length accepted for a single flag name.
|
||||
maxBuildFlagNameLength = 64
|
||||
)
|
||||
|
||||
// buildFlagNamePattern matches a well formed build flag name.
|
||||
var buildFlagNamePattern = regexp.MustCompile(`^[A-Z0-9_]+$`)
|
||||
|
||||
// buildFlags is a comma separated list of flag names supplied at build time by the linker, empty
|
||||
// in a stock build:
|
||||
//
|
||||
// -X github.com/openziti/ziti/v2/common/build.buildFlags=ALPHA,BRAVO
|
||||
//
|
||||
// The symbol path is a contract with downstream builds. The linker silently ignores -X against a
|
||||
// symbol it cannot resolve, so renaming this variable or moving it to another package produces a
|
||||
// binary with no build flags rather than a build error. A second -X against this symbol replaces
|
||||
// the first rather than adding to it: one build owner composes the whole list.
|
||||
var buildFlags string
|
||||
|
||||
// GetBuildFlags returns the well formed flag names supplied at build time, in the order they were
|
||||
// given. It returns an empty slice for a stock build.
|
||||
func GetBuildFlags() []string {
|
||||
return parseBuildFlags(buildFlags)
|
||||
}
|
||||
|
||||
// parseBuildFlags splits raw on commas and returns the well formed names in first seen order,
|
||||
// dropping blanks, duplicates, and anything outside [A-Z0-9_]. The result is never nil, so it
|
||||
// serializes as an empty JSON array rather than null. The count of names and the length of each
|
||||
// are capped, because the result is served over an unauthenticated API.
|
||||
func parseBuildFlags(raw string) []string {
|
||||
result := []string{}
|
||||
seen := map[string]struct{}{}
|
||||
|
||||
for _, token := range strings.Split(raw, ",") {
|
||||
if len(result) == maxBuildFlags {
|
||||
break
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(token)
|
||||
if len(name) > maxBuildFlagNameLength || !buildFlagNamePattern.MatchString(name) {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, ok := seen[name]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
seen[name] = struct{}{}
|
||||
result = append(result, name)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package build
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseBuildFlags(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "empty string yields an empty, non nil slice",
|
||||
raw: "",
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
name: "single name",
|
||||
raw: "ALPHA",
|
||||
expected: []string{"ALPHA"},
|
||||
},
|
||||
{
|
||||
name: "multiple names keep their order",
|
||||
raw: "ALPHA,BRAVO,CHARLIE",
|
||||
expected: []string{"ALPHA", "BRAVO", "CHARLIE"},
|
||||
},
|
||||
{
|
||||
name: "surrounding whitespace is trimmed",
|
||||
raw: " ALPHA , BRAVO\t,\nCHARLIE ",
|
||||
expected: []string{"ALPHA", "BRAVO", "CHARLIE"},
|
||||
},
|
||||
{
|
||||
name: "blank tokens are dropped",
|
||||
raw: ",ALPHA,, ,BRAVO,",
|
||||
expected: []string{"ALPHA", "BRAVO"},
|
||||
},
|
||||
{
|
||||
name: "duplicates are dropped, first occurrence wins",
|
||||
raw: "ALPHA,BRAVO,ALPHA",
|
||||
expected: []string{"ALPHA", "BRAVO"},
|
||||
},
|
||||
{
|
||||
name: "digits and underscores are accepted",
|
||||
raw: "ALPHA_2,BRAVO_MODE,3",
|
||||
expected: []string{"ALPHA_2", "BRAVO_MODE", "3"},
|
||||
},
|
||||
{
|
||||
name: "malformed tokens are dropped, well formed ones survive",
|
||||
raw: "alpha,BRAVO,Char-lie,DELTA ECHO,FOXTROT!,GOLF",
|
||||
expected: []string{"BRAVO", "GOLF"},
|
||||
},
|
||||
{
|
||||
name: "names longer than the cap are dropped",
|
||||
raw: "ALPHA," + strings.Repeat("B", maxBuildFlagNameLength+1) + ",CHARLIE",
|
||||
expected: []string{"ALPHA", "CHARLIE"},
|
||||
},
|
||||
{
|
||||
name: "names exactly at the length cap are kept",
|
||||
raw: strings.Repeat("B", maxBuildFlagNameLength),
|
||||
expected: []string{strings.Repeat("B", maxBuildFlagNameLength)},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
result := parseBuildFlags(test.raw)
|
||||
|
||||
require.Equal(t, test.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBuildFlagsStopsAtTheCountCap(t *testing.T) {
|
||||
names := make([]string, 0, maxBuildFlags+5)
|
||||
for i := 0; i < maxBuildFlags+5; i++ {
|
||||
names = append(names, "NAME_"+strings.Repeat("X", i))
|
||||
}
|
||||
|
||||
result := parseBuildFlags(strings.Join(names, ","))
|
||||
|
||||
require.Len(t, result, maxBuildFlags, "accepted names must be capped")
|
||||
require.Equal(t, names[:maxBuildFlags], result, "the first names up to the cap are the ones kept")
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
Copyright NetFoundry Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package cert
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// VerifyLeafCertChain verifies that the presented leaf certificate (certs[0]) chains to a trusted
|
||||
// certificate in the given pool, and returns that verified leaf. Only certs[0] is verified: it is the
|
||||
// certificate whose private key the TLS handshake proved the peer holds, and it is the certificate a
|
||||
// caller derives peer identity from. Any remaining certs[1:] are treated only as candidate
|
||||
// intermediates supplied by the peer, never as independent grounds for acceptance - so a peer cannot be
|
||||
// admitted by presenting its own leaf alongside some other certificate that happens to chain.
|
||||
//
|
||||
// roots is the verifying node's full trusted-CA pool (identity.CA()). Every certificate in it is a valid
|
||||
// chain terminus, whether a self-signed root or an intermediate distributed as a trust anchor, matching
|
||||
// how the node's TLS configuration establishes trust. No extended-key-usage restriction is applied:
|
||||
// certificates issued by an external PKI with arbitrary or absent EKUs are accepted as long as the leaf
|
||||
// chains to a trusted CA.
|
||||
//
|
||||
// additionalRoots are trust anchors the caller trusts for this check but which are not in the node's TLS
|
||||
// pool, such as a CA the node itself issues peer certificates from. They are added to a copy of roots, so
|
||||
// the caller's pool - which the identity shares with its live tls.Configs - is never modified.
|
||||
func VerifyLeafCertChain(roots *x509.CertPool, certs []*x509.Certificate, additionalRoots ...*x509.Certificate) (*x509.Certificate, error) {
|
||||
if roots == nil {
|
||||
return nil, errors.New("no ca pool provided")
|
||||
}
|
||||
|
||||
if len(certs) == 0 {
|
||||
return nil, errors.New("no certificates presented")
|
||||
}
|
||||
|
||||
if len(additionalRoots) > 0 {
|
||||
roots = roots.Clone()
|
||||
for _, additionalRoot := range additionalRoots {
|
||||
roots.AddCert(additionalRoot)
|
||||
}
|
||||
}
|
||||
|
||||
intermediates := x509.NewCertPool()
|
||||
for _, intermediate := range certs[1:] {
|
||||
intermediates.AddCert(intermediate)
|
||||
}
|
||||
|
||||
if _, err := certs[0].Verify(x509.VerifyOptions{
|
||||
Roots: roots,
|
||||
Intermediates: intermediates,
|
||||
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("leaf certificate not trusted: %w", err)
|
||||
}
|
||||
|
||||
return certs[0], nil
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
/*
|
||||
Copyright NetFoundry Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package cert
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"math/big"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const vTestTrustDomain = "spiffe://verify-test"
|
||||
|
||||
type vCertAndKey struct {
|
||||
cert *x509.Certificate
|
||||
key *ecdsa.PrivateKey
|
||||
}
|
||||
|
||||
var vSerial int64
|
||||
|
||||
func vNextSerial() *big.Int {
|
||||
vSerial++
|
||||
return big.NewInt(vSerial)
|
||||
}
|
||||
|
||||
func vMkCA(t *testing.T, cn string, parent *vCertAndKey) *vCertAndKey {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: vNextSerial(),
|
||||
Subject: pkix.Name{CommonName: cn},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
IsCA: true,
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
signParent, signKey := tmpl, key
|
||||
if parent != nil {
|
||||
signParent, signKey = parent.cert, parent.key
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, signParent, &key.PublicKey, signKey)
|
||||
require.NoError(t, err)
|
||||
c, err := x509.ParseCertificate(der)
|
||||
require.NoError(t, err)
|
||||
return &vCertAndKey{cert: c, key: key}
|
||||
}
|
||||
|
||||
// vMkLeaf builds an end-entity cert. signer==nil yields a leaf that does not chain to any CA. A nil
|
||||
// ekus produces a cert with no ExtKeyUsage extension (unrestricted).
|
||||
func vMkLeaf(t *testing.T, cn, spiffePath string, ekus []x509.ExtKeyUsage, signer *vCertAndKey) *vCertAndKey {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: vNextSerial(),
|
||||
Subject: pkix.Name{CommonName: cn},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: ekus,
|
||||
}
|
||||
if spiffePath != "" {
|
||||
u, err := url.Parse(vTestTrustDomain + spiffePath)
|
||||
require.NoError(t, err)
|
||||
tmpl.URIs = []*url.URL{u}
|
||||
}
|
||||
signParent, signKey := tmpl, key
|
||||
if signer != nil {
|
||||
signParent, signKey = signer.cert, signer.key
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, signParent, &key.PublicKey, signKey)
|
||||
require.NoError(t, err)
|
||||
c, err := x509.ParseCertificate(der)
|
||||
require.NoError(t, err)
|
||||
return &vCertAndKey{cert: c, key: key}
|
||||
}
|
||||
|
||||
func vPoolOf(certs ...*x509.Certificate) *x509.CertPool {
|
||||
pool := x509.NewCertPool()
|
||||
for _, c := range certs {
|
||||
pool.AddCert(c)
|
||||
}
|
||||
return pool
|
||||
}
|
||||
|
||||
// TestVerifyLeafCertChain_RejectsUnchainedLeaf verifies that a leaf which does not itself chain to a
|
||||
// trusted CA is rejected even when another presented certificate does chain - i.e. verification is bound
|
||||
// to the leaf, not to "some presented certificate verifies".
|
||||
func TestVerifyLeafCertChain_RejectsUnchainedLeaf(t *testing.T) {
|
||||
req := require.New(t)
|
||||
root := vMkCA(t, "root", nil)
|
||||
inter := vMkCA(t, "int", root)
|
||||
roots := vPoolOf(root.cert, inter.cert)
|
||||
|
||||
// A trust-anchored cert (chains to the pool) presented as an extra certificate.
|
||||
extra := vMkLeaf(t, "extra", "", []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, inter)
|
||||
// The leaf itself is self-signed and does NOT chain to the pool.
|
||||
unchained := vMkLeaf(t, "unchained", "/identity/other", []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, nil)
|
||||
|
||||
_, err := VerifyLeafCertChain(roots, []*x509.Certificate{unchained.cert, extra.cert})
|
||||
req.Error(err, "leaf not chaining to the pool must be rejected even alongside a chaining extra cert")
|
||||
}
|
||||
|
||||
func TestVerifyLeafCertChain_AcceptsLegitLeaf(t *testing.T) {
|
||||
req := require.New(t)
|
||||
root := vMkCA(t, "root", nil)
|
||||
inter := vMkCA(t, "int", root)
|
||||
roots := vPoolOf(root.cert, inter.cert)
|
||||
|
||||
legit := vMkLeaf(t, "legit", "/identity/real", []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, inter)
|
||||
leaf, err := VerifyLeafCertChain(roots, []*x509.Certificate{legit.cert})
|
||||
req.NoError(err)
|
||||
req.Equal(legit.cert, leaf, "returns the verified leaf (certs[0]) for identity use")
|
||||
}
|
||||
|
||||
func TestVerifyLeafCertChain_AcceptsPeerSuppliedIntermediate(t *testing.T) {
|
||||
req := require.New(t)
|
||||
root := vMkCA(t, "root", nil)
|
||||
inter := vMkCA(t, "int", root)
|
||||
legit := vMkLeaf(t, "legit", "/identity/real", []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, inter)
|
||||
|
||||
// Pool holds ONLY the root; the intermediate must be supplied on the wire (certs[1:]).
|
||||
rootOnly := vPoolOf(root.cert)
|
||||
_, err := VerifyLeafCertChain(rootOnly, []*x509.Certificate{legit.cert})
|
||||
req.Error(err, "without the intermediate anywhere the leaf cannot be verified")
|
||||
_, err = VerifyLeafCertChain(rootOnly, []*x509.Certificate{legit.cert, inter.cert})
|
||||
req.NoError(err, "peer-supplied intermediate lets a valid peer verify")
|
||||
}
|
||||
|
||||
// TestVerifyLeafCertChain_MultipleRoots covers a trust bundle with more than one self-signed root (as a
|
||||
// quickstart deployment produces, concatenating a controller root and a signer root). A leaf chaining to
|
||||
// either root must verify.
|
||||
func TestVerifyLeafCertChain_MultipleRoots(t *testing.T) {
|
||||
req := require.New(t)
|
||||
ctrlRoot := vMkCA(t, "ctrl-root", nil)
|
||||
signerRoot := vMkCA(t, "signer-root", nil)
|
||||
signerInter := vMkCA(t, "signer-int", signerRoot)
|
||||
roots := vPoolOf(ctrlRoot.cert, signerRoot.cert)
|
||||
|
||||
leaf := vMkLeaf(t, "router", "/identity/r1", []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, signerInter)
|
||||
_, err := VerifyLeafCertChain(roots, []*x509.Certificate{leaf.cert, signerInter.cert})
|
||||
req.NoError(err, "a leaf chaining to one of several trusted roots must verify")
|
||||
}
|
||||
|
||||
// TestVerifyLeafCertChain_IntermediateAsTrustAnchor covers a self-managed PKI that distributes a
|
||||
// (non-self-signed) intermediate as the trust anchor without its root. Every certificate in the pool is
|
||||
// a valid chain terminus, so a leaf chaining directly to that intermediate must verify.
|
||||
func TestVerifyLeafCertChain_IntermediateAsTrustAnchor(t *testing.T) {
|
||||
req := require.New(t)
|
||||
root := vMkCA(t, "root", nil)
|
||||
inter := vMkCA(t, "int", root)
|
||||
leaf := vMkLeaf(t, "leaf", "/identity/real", []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, inter)
|
||||
|
||||
interAnchored := vPoolOf(inter.cert) // only the intermediate distributed, no self-signed root
|
||||
_, err := VerifyLeafCertChain(interAnchored, []*x509.Certificate{leaf.cert})
|
||||
req.NoError(err, "an intermediate distributed as a trust anchor must be accepted as a chain terminus")
|
||||
}
|
||||
|
||||
// TestVerifyLeafCertChain_ArbitraryEKU covers external PKIs whose certificates carry arbitrary or absent
|
||||
// extended key usages. No EKU restriction is applied, so all of them verify as long as the chain is
|
||||
// valid.
|
||||
func TestVerifyLeafCertChain_ArbitraryEKU(t *testing.T) {
|
||||
req := require.New(t)
|
||||
root := vMkCA(t, "root", nil)
|
||||
inter := vMkCA(t, "int", root)
|
||||
roots := vPoolOf(root.cert, inter.cert)
|
||||
|
||||
for _, ekus := range [][]x509.ExtKeyUsage{
|
||||
nil,
|
||||
{x509.ExtKeyUsageClientAuth},
|
||||
{x509.ExtKeyUsageServerAuth},
|
||||
{x509.ExtKeyUsageEmailProtection},
|
||||
} {
|
||||
leaf := vMkLeaf(t, "leaf", "/identity/real", ekus, inter)
|
||||
_, err := VerifyLeafCertChain(roots, []*x509.Certificate{leaf.cert})
|
||||
req.NoError(err, "leaf with EKU %v must be accepted", ekus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyLeafCertChain_EmptyInputs(t *testing.T) {
|
||||
req := require.New(t)
|
||||
root := vMkCA(t, "root", nil)
|
||||
roots := vPoolOf(root.cert)
|
||||
|
||||
_, err := VerifyLeafCertChain(nil, []*x509.Certificate{root.cert})
|
||||
req.Error(err, "nil pool rejected")
|
||||
_, err = VerifyLeafCertChain(roots, nil)
|
||||
req.Error(err, "no certs rejected")
|
||||
}
|
||||
|
||||
// TestVerifyLeafCertChain_AdditionalRoots covers a controller whose edge signing CA sits outside its own
|
||||
// trust bundle: a router presents an enrollment certificate issued by that CA, so the leaf chains only
|
||||
// once the signing bundle is supplied as an additional anchor.
|
||||
func TestVerifyLeafCertChain_AdditionalRoots(t *testing.T) {
|
||||
req := require.New(t)
|
||||
ctrlRoot := vMkCA(t, "ctrl-root", nil)
|
||||
signingRoot := vMkCA(t, "signing-root", nil)
|
||||
signingInter := vMkCA(t, "signing-int", signingRoot)
|
||||
roots := vPoolOf(ctrlRoot.cert)
|
||||
leaf := vMkLeaf(t, "router", "/identity/r1", []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, signingInter)
|
||||
|
||||
_, err := VerifyLeafCertChain(roots, []*x509.Certificate{leaf.cert, signingInter.cert})
|
||||
req.Error(err, "without the signing bundle the leaf chains to nothing trusted")
|
||||
|
||||
_, err = VerifyLeafCertChain(roots, []*x509.Certificate{leaf.cert, signingInter.cert}, signingRoot.cert)
|
||||
req.NoError(err, "the signing root supplied as an additional anchor lets the leaf verify")
|
||||
|
||||
_, err = VerifyLeafCertChain(roots, []*x509.Certificate{leaf.cert}, signingInter.cert)
|
||||
req.NoError(err, "an intermediate from the signing bundle is a terminus like any other anchor")
|
||||
|
||||
ctrlLeaf := vMkLeaf(t, "ctrl-signed", "/identity/r2", []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, ctrlRoot)
|
||||
_, err = VerifyLeafCertChain(roots, []*x509.Certificate{ctrlLeaf.cert}, signingRoot.cert)
|
||||
req.NoError(err, "additional anchors do not displace the caller's own pool")
|
||||
}
|
||||
|
||||
// TestVerifyLeafCertChain_AdditionalRootsDoNotMutateCallerPool guards the caller's pool, which an identity
|
||||
// shares with the live tls.Configs it has handed out: anchors passed for one check must not become
|
||||
// permanently trusted.
|
||||
func TestVerifyLeafCertChain_AdditionalRootsDoNotMutateCallerPool(t *testing.T) {
|
||||
req := require.New(t)
|
||||
ctrlRoot := vMkCA(t, "ctrl-root", nil)
|
||||
signingRoot := vMkCA(t, "signing-root", nil)
|
||||
roots := vPoolOf(ctrlRoot.cert)
|
||||
leaf := vMkLeaf(t, "router", "/identity/r1", []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, signingRoot)
|
||||
|
||||
_, err := VerifyLeafCertChain(roots, []*x509.Certificate{leaf.cert}, signingRoot.cert)
|
||||
req.NoError(err)
|
||||
|
||||
_, err = VerifyLeafCertChain(roots, []*x509.Certificate{leaf.cert})
|
||||
req.Error(err, "the signing root must not have been added to the caller's pool")
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
Copyright NetFoundry Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package concurrency
|
||||
|
||||
import "sync"
|
||||
|
||||
// StripedIdLocker is a fixed-size set of mutexes selected by hashing a string
|
||||
// id. Operations on the same id always serialize; operations on different ids
|
||||
// proceed concurrently unless they happen to hash to the same slot. Ids that
|
||||
// collide on a slot share a lock (false contention), so size the locker
|
||||
// comfortably above the expected concurrency to keep collisions rare.
|
||||
//
|
||||
// It is intended as a lighter-weight alternative to a single coarse mutex when
|
||||
// the protected work is keyed by id and most concurrent callers touch
|
||||
// different ids.
|
||||
type StripedIdLocker struct {
|
||||
locks []sync.Mutex
|
||||
}
|
||||
|
||||
// NewStripedIdLocker returns a StripedIdLocker with the given number of slots.
|
||||
// slots is clamped to a minimum of 1.
|
||||
func NewStripedIdLocker(slots int) *StripedIdLocker {
|
||||
if slots < 1 {
|
||||
slots = 1
|
||||
}
|
||||
return &StripedIdLocker{locks: make([]sync.Mutex, slots)}
|
||||
}
|
||||
|
||||
// LockFor locks the slot for id and returns a function that unlocks it. The
|
||||
// returned function must be called exactly once. Typical use:
|
||||
//
|
||||
// defer locker.LockFor(id)()
|
||||
func (self *StripedIdLocker) LockFor(id string) func() {
|
||||
m := &self.locks[self.indexFor(id)]
|
||||
m.Lock()
|
||||
return m.Unlock
|
||||
}
|
||||
|
||||
// indexFor maps id to a slot using FNV-1a (inlined to avoid allocating a hasher
|
||||
// on this hot path).
|
||||
func (self *StripedIdLocker) indexFor(id string) uint32 {
|
||||
const (
|
||||
offset32 = 2166136261
|
||||
prime32 = 16777619
|
||||
)
|
||||
h := uint32(offset32)
|
||||
for i := 0; i < len(id); i++ {
|
||||
h ^= uint32(id[i])
|
||||
h *= prime32
|
||||
}
|
||||
return h % uint32(len(self.locks))
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
Copyright NetFoundry Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package concurrency
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestStripedIdLocker_SameIdSerializes verifies that two lockers for the same id
|
||||
// are mutually exclusive: the second LockFor blocks until the first unlocks.
|
||||
func TestStripedIdLocker_SameIdSerializes(t *testing.T) {
|
||||
locker := NewStripedIdLocker(16)
|
||||
|
||||
unlock := locker.LockFor("link-1")
|
||||
|
||||
acquired := make(chan struct{})
|
||||
go func() {
|
||||
secondUnlock := locker.LockFor("link-1")
|
||||
close(acquired)
|
||||
secondUnlock()
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-acquired:
|
||||
t.Fatal("second LockFor for the same id acquired while the first was still held")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
// expected: still blocked
|
||||
}
|
||||
|
||||
unlock()
|
||||
|
||||
select {
|
||||
case <-acquired:
|
||||
// expected: unblocked once the first lock was released
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("second LockFor did not acquire after the first was released")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStripedIdLocker_MutualExclusion runs many goroutines incrementing per-id
|
||||
// counters guarded only by the striped locker. With correct per-id
|
||||
// serialization every counter ends at the expected total. Run with -race to
|
||||
// also catch incorrect sharing.
|
||||
func TestStripedIdLocker_MutualExclusion(t *testing.T) {
|
||||
const (
|
||||
ids = 50
|
||||
goroutines = 32
|
||||
incsPerWorker = 200
|
||||
)
|
||||
|
||||
// Use more ids than slots so multiple ids share slots; correctness must not
|
||||
// depend on a 1:1 id-to-slot mapping.
|
||||
locker := NewStripedIdLocker(8)
|
||||
counters := make([]int, ids)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for g := 0; g < goroutines; g++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < incsPerWorker; i++ {
|
||||
id := fmt.Sprintf("id-%d", i%ids)
|
||||
unlock := locker.LockFor(id)
|
||||
counters[i%ids]++
|
||||
unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
expected := goroutines * incsPerWorker / ids
|
||||
for i, c := range counters {
|
||||
if c != expected {
|
||||
t.Fatalf("counter[%d] = %d, expected %d", i, c, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestStripedIdLocker_DifferentIdsConcurrent verifies that ids landing on
|
||||
// different slots do not block each other.
|
||||
func TestStripedIdLocker_DifferentIdsConcurrent(t *testing.T) {
|
||||
locker := NewStripedIdLocker(256)
|
||||
|
||||
// Find two ids that map to different slots.
|
||||
a := "router-a"
|
||||
var b string
|
||||
for i := 0; ; i++ {
|
||||
candidate := fmt.Sprintf("router-%d", i)
|
||||
if locker.indexFor(candidate) != locker.indexFor(a) {
|
||||
b = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
unlockA := locker.LockFor(a)
|
||||
defer unlockA()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
locker.LockFor(b)() // should not block on a's slot
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
// expected
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("LockFor on a different slot blocked while an unrelated slot was held")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
Copyright NetFoundry Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/openziti/channel/v4"
|
||||
)
|
||||
|
||||
// ValidateHeartbeatOptions reports whether options describe a cadence that can keep a channel up.
|
||||
// Callers pass loaded configuration, so the returned error names the configuration keys and is
|
||||
// suitable for returning straight out of config loading.
|
||||
//
|
||||
// Apply this wherever the heartbeat callback closes the channel after CloseUnresponsiveTimeout
|
||||
// without a response. A callback that only records latency has no deadline to sample and does not
|
||||
// need it. A zero CloseUnresponsiveTimeout disables the teardown, and only the check interval is
|
||||
// constrained.
|
||||
func ValidateHeartbeatOptions(options *channel.HeartbeatOptions) error {
|
||||
if options == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ConfigureHeartbeat hands this straight to time.NewTicker on a bare goroutine, which panics on
|
||||
// a non-positive interval and takes the process with it.
|
||||
if options.CheckInterval <= 0 {
|
||||
return fmt.Errorf("heartbeat checkInterval (%v) must be greater than zero", options.CheckInterval)
|
||||
}
|
||||
|
||||
if options.CloseUnresponsiveTimeout <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// The check interval is the sampling rate for the timeout, and the response time it reads can be
|
||||
// a full interval stale, so at or above the timeout it condemns channels that are answering.
|
||||
if options.CheckInterval >= options.CloseUnresponsiveTimeout {
|
||||
return fmt.Errorf("heartbeat checkInterval (%v) must be less than closeUnresponsiveTimeout (%v), "+
|
||||
"otherwise the check reads a stale response time and closes healthy channels",
|
||||
options.CheckInterval, options.CloseUnresponsiveTimeout)
|
||||
}
|
||||
|
||||
if options.SendInterval >= options.CloseUnresponsiveTimeout {
|
||||
return fmt.Errorf("heartbeat sendInterval (%v) must be less than closeUnresponsiveTimeout (%v), "+
|
||||
"otherwise a channel is closed between scheduled heartbeats",
|
||||
options.SendInterval, options.CloseUnresponsiveTimeout)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
Copyright NetFoundry Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/openziti/channel/v4"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestValidateHeartbeatOptions(t *testing.T) {
|
||||
options := func(send, check, closeTimeout time.Duration) *channel.HeartbeatOptions {
|
||||
return &channel.HeartbeatOptions{
|
||||
SendInterval: send,
|
||||
CheckInterval: check,
|
||||
CloseUnresponsiveTimeout: closeTimeout,
|
||||
}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
options *channel.HeartbeatOptions
|
||||
errIs string
|
||||
}{
|
||||
{
|
||||
name: "the library defaults are accepted",
|
||||
options: channel.DefaultHeartbeatOptions(),
|
||||
},
|
||||
{
|
||||
name: "nil is accepted, since an absent config is the caller's default",
|
||||
options: nil,
|
||||
},
|
||||
{
|
||||
name: "a cadence well inside the timeout is accepted",
|
||||
options: options(10*time.Second, time.Second, 30*time.Second),
|
||||
},
|
||||
{
|
||||
name: "a zero check interval is rejected, since the pulse ticker panics on it",
|
||||
options: options(10*time.Second, 0, 30*time.Second),
|
||||
errIs: "checkInterval",
|
||||
},
|
||||
{
|
||||
name: "a negative check interval is rejected",
|
||||
options: options(10*time.Second, -time.Second, 30*time.Second),
|
||||
errIs: "checkInterval",
|
||||
},
|
||||
{
|
||||
name: "a check interval above the timeout is rejected",
|
||||
options: options(10*time.Second, time.Minute, 30*time.Second),
|
||||
errIs: "checkInterval",
|
||||
},
|
||||
{
|
||||
name: "a check interval equal to the timeout is rejected",
|
||||
options: options(10*time.Second, 30*time.Second, 30*time.Second),
|
||||
errIs: "checkInterval",
|
||||
},
|
||||
{
|
||||
name: "a send interval above the timeout is rejected",
|
||||
options: options(time.Minute, time.Second, 30*time.Second),
|
||||
errIs: "sendInterval",
|
||||
},
|
||||
{
|
||||
name: "a send interval equal to the timeout is rejected",
|
||||
options: options(30*time.Second, time.Second, 30*time.Second),
|
||||
errIs: "sendInterval",
|
||||
},
|
||||
{
|
||||
// A zero timeout disables the teardown, so there is no deadline to outpace and only the
|
||||
// ticker's own requirement is left to enforce.
|
||||
name: "a disabled teardown leaves the intervals unconstrained",
|
||||
options: options(time.Hour, time.Minute, 0),
|
||||
},
|
||||
{
|
||||
name: "a disabled teardown still rejects a zero check interval",
|
||||
options: options(10*time.Second, 0, 0),
|
||||
errIs: "checkInterval",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
err := ValidateHeartbeatOptions(test.options)
|
||||
if test.errIs == "" {
|
||||
require.NoError(t, err)
|
||||
return
|
||||
}
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), test.errIs)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -207,7 +207,9 @@ func DecodeCreateCircuitV2Response(m *channel.Message) (*CreateCircuitV2Response
|
||||
// CreateCircuitV3Request is sent from a router to the controller to create a circuit
|
||||
// without a service session token. The router has already authorized the dial locally
|
||||
// via RDM and provides the identity and service IDs directly, along with a pre-assigned
|
||||
// circuit ID.
|
||||
// circuit ID. ApiSessionToken is required and must be the token of the dialing identity:
|
||||
// the controller validates it and treats its claims, rather than IdentityId, as the
|
||||
// authoritative identity.
|
||||
type CreateCircuitV3Request struct {
|
||||
IdentityId string
|
||||
ServiceId string
|
||||
|
||||
@@ -653,3 +653,77 @@ func TestDialCtrlChannel_ReconnectCycle(t *testing.T) {
|
||||
}, 5*time.Second, 10*time.Millisecond, "loop iteration %d, should match channel iteration %d", i, dialChannel.iteration.Load())
|
||||
}
|
||||
}
|
||||
|
||||
// TestReject_BindErrorNotEstablishedOrRxStarted validates the boundary that router-connect rejection
|
||||
// relies on: when the ctrl-channel bind handler returns an error (as CtrlAccepter.Bind does when
|
||||
// network.ConnectRouter rejects a busy slot), channel.NewChannel returns that error, the MultiListener
|
||||
// closes the underlay without registering the channel, and the receive loop never starts. This
|
||||
// no-rx / no-register outcome is the contract the fake-CtrlChannel unit tests cannot observe.
|
||||
func TestReject_BindErrorNotEstablishedOrRxStarted(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
listenerAddr := "tcp:127.0.0.1:40010"
|
||||
id := &identity.TokenId{Token: "test-controller"}
|
||||
|
||||
var bindAttempts atomic.Int32
|
||||
var established atomic.Int32
|
||||
var rxFired atomic.Bool
|
||||
|
||||
multiListener := channel.NewMultiListener(
|
||||
func(underlay channel.Underlay, closeCallback func()) (channel.MultiChannel, error) {
|
||||
bindAttempts.Add(1)
|
||||
listenerChannel := NewListenerCtrlChannel()
|
||||
multiConfig := &channel.MultiChannelConfig{
|
||||
LogicalName: "ctrl/" + underlay.ConnectionId(),
|
||||
Options: channel.DefaultOptions(),
|
||||
UnderlayHandler: listenerChannel,
|
||||
Underlay: underlay,
|
||||
BindHandler: channel.BindHandlerF(func(binding channel.Binding) error {
|
||||
// Would flip if the rx loop ever started for this (rejected) channel.
|
||||
binding.AddReceiveHandlerF(echoContentType, func(*channel.Message, channel.Channel) {
|
||||
rxFired.Store(true)
|
||||
})
|
||||
// Reject, as network.ConnectRouter does for a busy slot.
|
||||
return fmt.Errorf("simulated router connect rejected (busy slot)")
|
||||
}),
|
||||
}
|
||||
|
||||
multiCh, err := channel.NewMultiChannel(multiConfig)
|
||||
if err != nil {
|
||||
// Rejected: NewMultiChannel closed the underlay and never started rx; do not register.
|
||||
return nil, err
|
||||
}
|
||||
established.Add(1)
|
||||
return multiCh, nil
|
||||
},
|
||||
func(underlay channel.Underlay) error {
|
||||
return fmt.Errorf("ungrouped connections not supported")
|
||||
},
|
||||
)
|
||||
|
||||
bindAddr, err := transport.ParseAddress(listenerAddr)
|
||||
req.NoError(err)
|
||||
listenerConfig := channel.ListenerConfig{ConnectOptions: channel.DefaultOptions().ConnectOptions}
|
||||
listener, err := channel.NewClassicListenerF(id, bindAddr, listenerConfig, multiListener.AcceptUnderlay)
|
||||
req.NoError(err)
|
||||
defer func() { _ = listener.Close() }()
|
||||
|
||||
headers := channel.Headers{}
|
||||
headers.PutStringHeader(channel.TypeHeader, ChannelTypeDefault)
|
||||
headers.PutBoolHeader(channel.IsGroupedHeader, true)
|
||||
headers.PutBoolHeader(channel.IsFirstGroupConnection, true)
|
||||
|
||||
dialer := channel.NewClassicDialer(channel.DialerConfig{Identity: id, Endpoint: bindAddr})
|
||||
underlay, err := dialer.CreateWithHeaders(5*time.Second, headers)
|
||||
req.NoError(err)
|
||||
defer func() { _ = underlay.Close() }()
|
||||
|
||||
// The dial reaches the listener's bind (the hello is acked before the factory runs).
|
||||
req.Eventually(func() bool { return bindAttempts.Load() >= 1 }, 5*time.Second, 10*time.Millisecond,
|
||||
"listener bind should have run for the dialed connection")
|
||||
|
||||
// The rejected connection is never established, and its receive loop never starts. Give any spurious
|
||||
// rx a moment to (not) happen.
|
||||
req.Never(func() bool { return established.Load() != 0 || rxFired.Load() }, 500*time.Millisecond, 50*time.Millisecond,
|
||||
"a rejected bind must not establish a channel or start its receive loop")
|
||||
}
|
||||
|
||||
@@ -209,6 +209,26 @@ func (c *ServiceAccessClaims) HasAudience(targetAud string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// InvalidTokenError indicates a token was rejected because of the token itself (bad signature or
|
||||
// parse, expiry, unexpected claims/audience/type, a mismatched api session, or revocation) rather
|
||||
// than an infrastructure failure (e.g. a datastore read) encountered while validating it. Callers
|
||||
// use it to distinguish a client that should discard and re-create its session from a transient
|
||||
// controller failure that must not invalidate otherwise-valid client state.
|
||||
type InvalidTokenError struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *InvalidTokenError) Error() string {
|
||||
if e.Err == nil {
|
||||
return "invalid token"
|
||||
}
|
||||
return e.Err.Error()
|
||||
}
|
||||
|
||||
func (e *InvalidTokenError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
type AccessClaims struct {
|
||||
oidc.AccessTokenClaims
|
||||
CustomClaims
|
||||
|
||||
+163
-153
@@ -599,6 +599,7 @@ type SyncSnapshotCommand struct {
|
||||
|
||||
SnapshotId string `protobuf:"bytes,1,opt,name=snapshotId,proto3" json:"snapshotId,omitempty"`
|
||||
Snapshot []byte `protobuf:"bytes,2,opt,name=snapshot,proto3" json:"snapshot,omitempty"`
|
||||
ClusterId string `protobuf:"bytes,3,opt,name=clusterId,proto3" json:"clusterId,omitempty"`
|
||||
}
|
||||
|
||||
func (x *SyncSnapshotCommand) Reset() {
|
||||
@@ -647,6 +648,13 @@ func (x *SyncSnapshotCommand) GetSnapshot() []byte {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *SyncSnapshotCommand) GetClusterId() string {
|
||||
if x != nil {
|
||||
return x.ClusterId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type InitClusterIdCommand struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
@@ -1413,164 +1421,166 @@ var file_cmd_proto_rawDesc = []byte{
|
||||
0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x54, 0x79, 0x70, 0x65,
|
||||
0x12, 0x2c, 0x0a, 0x03, 0x63, 0x74, 0x78, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e,
|
||||
0x7a, 0x69, 0x74, 0x69, 0x2e, 0x63, 0x6d, 0x64, 0x2e, 0x70, 0x62, 0x2e, 0x43, 0x68, 0x61, 0x6e,
|
||||
0x67, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x52, 0x03, 0x63, 0x74, 0x78, 0x22, 0x51,
|
||||
0x67, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x52, 0x03, 0x63, 0x74, 0x78, 0x22, 0x6f,
|
||||
0x0a, 0x13, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x43, 0x6f,
|
||||
0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x12, 0x1e, 0x0a, 0x0a, 0x73, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f,
|
||||
0x74, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x73, 0x6e, 0x61, 0x70, 0x73,
|
||||
0x68, 0x6f, 0x74, 0x49, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x73, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f,
|
||||
0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x08, 0x73, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f,
|
||||
0x74, 0x22, 0x54, 0x0a, 0x14, 0x49, 0x6e, 0x69, 0x74, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72,
|
||||
0x49, 0x64, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x12, 0x1c, 0x0a, 0x09, 0x63, 0x6c, 0x75,
|
||||
0x73, 0x74, 0x65, 0x72, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x6c,
|
||||
0x75, 0x73, 0x74, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1e, 0x0a, 0x0a, 0x74, 0x69, 0x6d, 0x65, 0x6c,
|
||||
0x69, 0x6e, 0x65, 0x49, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x74, 0x69, 0x6d,
|
||||
0x65, 0x6c, 0x69, 0x6e, 0x65, 0x49, 0x64, 0x22, 0x6b, 0x0a, 0x1d, 0x44, 0x65, 0x6c, 0x65, 0x74,
|
||||
0x65, 0x54, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x61, 0x74, 0x6f, 0x72, 0x73, 0x42, 0x61, 0x74, 0x63,
|
||||
0x68, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x12, 0x1c, 0x0a, 0x09, 0x65, 0x6e, 0x74, 0x69,
|
||||
0x74, 0x79, 0x49, 0x64, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x09, 0x65, 0x6e, 0x74,
|
||||
0x69, 0x74, 0x79, 0x49, 0x64, 0x73, 0x12, 0x2c, 0x0a, 0x03, 0x63, 0x74, 0x78, 0x18, 0x02, 0x20,
|
||||
0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x7a, 0x69, 0x74, 0x69, 0x2e, 0x63, 0x6d, 0x64, 0x2e, 0x70,
|
||||
0x62, 0x2e, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x52,
|
||||
0x03, 0x63, 0x74, 0x78, 0x22, 0x91, 0x01, 0x0a, 0x08, 0x54, 0x61, 0x67, 0x56, 0x61, 0x6c, 0x75,
|
||||
0x65, 0x12, 0x1e, 0x0a, 0x09, 0x62, 0x6f, 0x6f, 0x6c, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x01,
|
||||
0x20, 0x01, 0x28, 0x08, 0x48, 0x00, 0x52, 0x09, 0x62, 0x6f, 0x6f, 0x6c, 0x56, 0x61, 0x6c, 0x75,
|
||||
0x65, 0x12, 0x22, 0x0a, 0x0b, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x56, 0x61, 0x6c, 0x75, 0x65,
|
||||
0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x0b, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67,
|
||||
0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x1a, 0x0a, 0x07, 0x66, 0x70, 0x56, 0x61, 0x6c, 0x75, 0x65,
|
||||
0x18, 0x03, 0x20, 0x01, 0x28, 0x01, 0x48, 0x00, 0x52, 0x07, 0x66, 0x70, 0x56, 0x61, 0x6c, 0x75,
|
||||
0x65, 0x12, 0x1c, 0x0a, 0x08, 0x6e, 0x69, 0x6c, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x04, 0x20,
|
||||
0x01, 0x28, 0x08, 0x48, 0x00, 0x52, 0x08, 0x6e, 0x69, 0x6c, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x42,
|
||||
0x07, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x83, 0x02, 0x0a, 0x07, 0x53, 0x65, 0x72,
|
||||
0x76, 0x69, 0x63, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
|
||||
0x52, 0x02, 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01,
|
||||
0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x2e, 0x0a, 0x12, 0x74, 0x65, 0x72, 0x6d,
|
||||
0x69, 0x6e, 0x61, 0x74, 0x6f, 0x72, 0x53, 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x18, 0x03,
|
||||
0x20, 0x01, 0x28, 0x09, 0x52, 0x12, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x61, 0x74, 0x6f, 0x72,
|
||||
0x53, 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x12, 0x32, 0x0a, 0x04, 0x74, 0x61, 0x67, 0x73,
|
||||
0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x7a, 0x69, 0x74, 0x69, 0x2e, 0x63, 0x6d,
|
||||
0x64, 0x2e, 0x70, 0x62, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x54, 0x61, 0x67,
|
||||
0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x04, 0x74, 0x61, 0x67, 0x73, 0x12, 0x20, 0x0a, 0x0b,
|
||||
0x6d, 0x61, 0x78, 0x49, 0x64, 0x6c, 0x65, 0x54, 0x69, 0x6d, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28,
|
||||
0x03, 0x52, 0x0b, 0x6d, 0x61, 0x78, 0x49, 0x64, 0x6c, 0x65, 0x54, 0x69, 0x6d, 0x65, 0x1a, 0x4e,
|
||||
0x0a, 0x09, 0x54, 0x61, 0x67, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b,
|
||||
0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2b, 0x0a,
|
||||
0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x7a,
|
||||
0x69, 0x74, 0x69, 0x2e, 0x63, 0x6d, 0x64, 0x2e, 0x70, 0x62, 0x2e, 0x54, 0x61, 0x67, 0x56, 0x61,
|
||||
0x6c, 0x75, 0x65, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x30,
|
||||
0x0a, 0x16, 0x43, 0x74, 0x72, 0x6c, 0x43, 0x68, 0x61, 0x6e, 0x4c, 0x69, 0x73, 0x74, 0x65, 0x6e,
|
||||
0x65, 0x72, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x12, 0x16, 0x0a, 0x06, 0x67, 0x72, 0x6f, 0x75,
|
||||
0x70, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x06, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73,
|
||||
0x22, 0xa0, 0x04, 0x0a, 0x06, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x12, 0x0e, 0x0a, 0x02, 0x69,
|
||||
0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e,
|
||||
0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12,
|
||||
0x20, 0x0a, 0x0b, 0x66, 0x69, 0x6e, 0x67, 0x65, 0x72, 0x70, 0x72, 0x69, 0x6e, 0x74, 0x18, 0x03,
|
||||
0x20, 0x01, 0x28, 0x0c, 0x52, 0x0b, 0x66, 0x69, 0x6e, 0x67, 0x65, 0x72, 0x70, 0x72, 0x69, 0x6e,
|
||||
0x74, 0x12, 0x12, 0x0a, 0x04, 0x63, 0x6f, 0x73, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0d, 0x52,
|
||||
0x04, 0x63, 0x6f, 0x73, 0x74, 0x12, 0x20, 0x0a, 0x0b, 0x6e, 0x6f, 0x54, 0x72, 0x61, 0x76, 0x65,
|
||||
0x72, 0x73, 0x61, 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b, 0x6e, 0x6f, 0x54, 0x72,
|
||||
0x61, 0x76, 0x65, 0x72, 0x73, 0x61, 0x6c, 0x12, 0x1a, 0x0a, 0x08, 0x64, 0x69, 0x73, 0x61, 0x62,
|
||||
0x6c, 0x65, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x64, 0x69, 0x73, 0x61, 0x62,
|
||||
0x6c, 0x65, 0x64, 0x12, 0x31, 0x0a, 0x04, 0x74, 0x61, 0x67, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28,
|
||||
0x0b, 0x32, 0x1d, 0x2e, 0x7a, 0x69, 0x74, 0x69, 0x2e, 0x63, 0x6d, 0x64, 0x2e, 0x70, 0x62, 0x2e,
|
||||
0x52, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x2e, 0x54, 0x61, 0x67, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79,
|
||||
0x52, 0x04, 0x74, 0x61, 0x67, 0x73, 0x12, 0x36, 0x0a, 0x0a, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x66,
|
||||
0x61, 0x63, 0x65, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x7a, 0x69, 0x74,
|
||||
0x69, 0x2e, 0x63, 0x6d, 0x64, 0x2e, 0x70, 0x62, 0x2e, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x66, 0x61,
|
||||
0x63, 0x65, 0x52, 0x0a, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x66, 0x61, 0x63, 0x65, 0x73, 0x12, 0x58,
|
||||
0x0a, 0x11, 0x63, 0x74, 0x72, 0x6c, 0x43, 0x68, 0x61, 0x6e, 0x4c, 0x69, 0x73, 0x74, 0x65, 0x6e,
|
||||
0x65, 0x72, 0x73, 0x18, 0x09, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2a, 0x2e, 0x7a, 0x69, 0x74, 0x69,
|
||||
0x2e, 0x63, 0x6d, 0x64, 0x2e, 0x70, 0x62, 0x2e, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x2e, 0x43,
|
||||
0x74, 0x72, 0x6c, 0x43, 0x68, 0x61, 0x6e, 0x4c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x65, 0x72, 0x73,
|
||||
0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x11, 0x63, 0x74, 0x72, 0x6c, 0x43, 0x68, 0x61, 0x6e, 0x4c,
|
||||
0x69, 0x73, 0x74, 0x65, 0x6e, 0x65, 0x72, 0x73, 0x1a, 0x4e, 0x0a, 0x09, 0x54, 0x61, 0x67, 0x73,
|
||||
0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01,
|
||||
0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2b, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65,
|
||||
0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x7a, 0x69, 0x74, 0x69, 0x2e, 0x63, 0x6d,
|
||||
0x64, 0x2e, 0x70, 0x62, 0x2e, 0x54, 0x61, 0x67, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x05, 0x76,
|
||||
0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x1a, 0x69, 0x0a, 0x16, 0x43, 0x74, 0x72, 0x6c,
|
||||
0x43, 0x68, 0x61, 0x6e, 0x4c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74,
|
||||
0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
|
||||
0x03, 0x6b, 0x65, 0x79, 0x12, 0x39, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20,
|
||||
0x01, 0x28, 0x0b, 0x32, 0x23, 0x2e, 0x7a, 0x69, 0x74, 0x69, 0x2e, 0x63, 0x6d, 0x64, 0x2e, 0x70,
|
||||
0x62, 0x2e, 0x43, 0x74, 0x72, 0x6c, 0x43, 0x68, 0x61, 0x6e, 0x4c, 0x69, 0x73, 0x74, 0x65, 0x6e,
|
||||
0x65, 0x72, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a,
|
||||
0x02, 0x38, 0x01, 0x22, 0x8b, 0x05, 0x0a, 0x0a, 0x54, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x61, 0x74,
|
||||
0x6f, 0x72, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02,
|
||||
0x69, 0x64, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x49, 0x64, 0x18,
|
||||
0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x49, 0x64,
|
||||
0x12, 0x1a, 0x0a, 0x08, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x49, 0x64, 0x18, 0x03, 0x20, 0x01,
|
||||
0x28, 0x09, 0x52, 0x08, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07,
|
||||
0x62, 0x69, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x62,
|
||||
0x69, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x12, 0x18, 0x0a, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73,
|
||||
0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73,
|
||||
0x12, 0x1e, 0x0a, 0x0a, 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x49, 0x64, 0x18, 0x06,
|
||||
0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x49, 0x64,
|
||||
0x12, 0x26, 0x0a, 0x0e, 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x53, 0x65, 0x63, 0x72,
|
||||
0x65, 0x74, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0e, 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e,
|
||||
0x63, 0x65, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x63, 0x6f, 0x73, 0x74,
|
||||
0x18, 0x08, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x04, 0x63, 0x6f, 0x73, 0x74, 0x12, 0x1e, 0x0a, 0x0a,
|
||||
0x70, 0x72, 0x65, 0x63, 0x65, 0x64, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0d,
|
||||
0x52, 0x0a, 0x70, 0x72, 0x65, 0x63, 0x65, 0x64, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x41, 0x0a, 0x08,
|
||||
0x70, 0x65, 0x65, 0x72, 0x44, 0x61, 0x74, 0x61, 0x18, 0x0a, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x25,
|
||||
0x2e, 0x7a, 0x69, 0x74, 0x69, 0x2e, 0x63, 0x6d, 0x64, 0x2e, 0x70, 0x62, 0x2e, 0x54, 0x65, 0x72,
|
||||
0x6d, 0x69, 0x6e, 0x61, 0x74, 0x6f, 0x72, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x44, 0x61, 0x74, 0x61,
|
||||
0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, 0x70, 0x65, 0x65, 0x72, 0x44, 0x61, 0x74, 0x61, 0x12,
|
||||
0x35, 0x0a, 0x04, 0x74, 0x61, 0x67, 0x73, 0x18, 0x0b, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x21, 0x2e,
|
||||
0x7a, 0x69, 0x74, 0x69, 0x2e, 0x63, 0x6d, 0x64, 0x2e, 0x70, 0x62, 0x2e, 0x54, 0x65, 0x72, 0x6d,
|
||||
0x69, 0x6e, 0x61, 0x74, 0x6f, 0x72, 0x2e, 0x54, 0x61, 0x67, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79,
|
||||
0x52, 0x04, 0x74, 0x61, 0x67, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x68, 0x6f, 0x73, 0x74, 0x49, 0x64,
|
||||
0x18, 0x0c, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x68, 0x6f, 0x73, 0x74, 0x49, 0x64, 0x12, 0x1a,
|
||||
0x0a, 0x08, 0x69, 0x73, 0x53, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x08,
|
||||
0x52, 0x08, 0x69, 0x73, 0x53, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x12, 0x28, 0x0a, 0x0f, 0x73, 0x61,
|
||||
0x76, 0x65, 0x64, 0x50, 0x72, 0x65, 0x63, 0x65, 0x64, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x0e, 0x20,
|
||||
0x01, 0x28, 0x0d, 0x52, 0x0f, 0x73, 0x61, 0x76, 0x65, 0x64, 0x50, 0x72, 0x65, 0x63, 0x65, 0x64,
|
||||
0x65, 0x6e, 0x63, 0x65, 0x12, 0x1e, 0x0a, 0x0a, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x43, 0x74,
|
||||
0x72, 0x6c, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65,
|
||||
0x43, 0x74, 0x72, 0x6c, 0x1a, 0x3b, 0x0a, 0x0d, 0x50, 0x65, 0x65, 0x72, 0x44, 0x61, 0x74, 0x61,
|
||||
0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01,
|
||||
0x28, 0x0d, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65,
|
||||
0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38,
|
||||
0x01, 0x1a, 0x4e, 0x0a, 0x09, 0x54, 0x61, 0x67, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10,
|
||||
0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79,
|
||||
0x12, 0x2b, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32,
|
||||
0x15, 0x2e, 0x7a, 0x69, 0x74, 0x69, 0x2e, 0x63, 0x6d, 0x64, 0x2e, 0x70, 0x62, 0x2e, 0x54, 0x61,
|
||||
0x67, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38,
|
||||
0x01, 0x22, 0xa5, 0x01, 0x0a, 0x09, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x66, 0x61, 0x63, 0x65, 0x12,
|
||||
0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e,
|
||||
0x61, 0x6d, 0x65, 0x12, 0x28, 0x0a, 0x0f, 0x68, 0x61, 0x72, 0x64, 0x77, 0x61, 0x72, 0x65, 0x41,
|
||||
0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x68, 0x61,
|
||||
0x72, 0x64, 0x77, 0x61, 0x72, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x10, 0x0a,
|
||||
0x03, 0x6d, 0x74, 0x75, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x03, 0x6d, 0x74, 0x75, 0x12,
|
||||
0x14, 0x0a, 0x05, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05,
|
||||
0x69, 0x6e, 0x64, 0x65, 0x78, 0x12, 0x14, 0x0a, 0x05, 0x66, 0x6c, 0x61, 0x67, 0x73, 0x18, 0x05,
|
||||
0x20, 0x01, 0x28, 0x04, 0x52, 0x05, 0x66, 0x6c, 0x61, 0x67, 0x73, 0x12, 0x1c, 0x0a, 0x09, 0x61,
|
||||
0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x65, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x09, 0x52, 0x09,
|
||||
0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x65, 0x73, 0x2a, 0xc3, 0x01, 0x0a, 0x0b, 0x43, 0x6f,
|
||||
0x6e, 0x74, 0x65, 0x6e, 0x74, 0x54, 0x79, 0x70, 0x65, 0x12, 0x13, 0x0a, 0x0f, 0x43, 0x6f, 0x6e,
|
||||
0x74, 0x65, 0x6e, 0x74, 0x54, 0x79, 0x70, 0x65, 0x5a, 0x65, 0x72, 0x6f, 0x10, 0x00, 0x12, 0x14,
|
||||
0x0a, 0x0f, 0x4e, 0x65, 0x77, 0x4c, 0x6f, 0x67, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x54, 0x79, 0x70,
|
||||
0x65, 0x10, 0x82, 0x10, 0x12, 0x16, 0x0a, 0x11, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, 0x65, 0x73,
|
||||
0x70, 0x6f, 0x6e, 0x73, 0x65, 0x54, 0x79, 0x70, 0x65, 0x10, 0x83, 0x10, 0x12, 0x18, 0x0a, 0x13,
|
||||
0x53, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x54,
|
||||
0x79, 0x70, 0x65, 0x10, 0x84, 0x10, 0x12, 0x17, 0x0a, 0x12, 0x41, 0x64, 0x64, 0x50, 0x65, 0x65,
|
||||
0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x54, 0x79, 0x70, 0x65, 0x10, 0x85, 0x10, 0x12,
|
||||
0x1a, 0x0a, 0x15, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x71,
|
||||
0x75, 0x65, 0x73, 0x74, 0x54, 0x79, 0x70, 0x65, 0x10, 0x86, 0x10, 0x12, 0x22, 0x0a, 0x1d, 0x54,
|
||||
0x72, 0x61, 0x6e, 0x73, 0x66, 0x65, 0x72, 0x4c, 0x65, 0x61, 0x64, 0x65, 0x72, 0x73, 0x68, 0x69,
|
||||
0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x54, 0x79, 0x70, 0x65, 0x10, 0x87, 0x10, 0x2a,
|
||||
0x9e, 0x01, 0x0a, 0x0b, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x54, 0x79, 0x70, 0x65, 0x12,
|
||||
0x08, 0x0a, 0x04, 0x5a, 0x65, 0x72, 0x6f, 0x10, 0x00, 0x12, 0x14, 0x0a, 0x10, 0x43, 0x72, 0x65,
|
||||
0x61, 0x74, 0x65, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x54, 0x79, 0x70, 0x65, 0x10, 0x01, 0x12,
|
||||
0x14, 0x0a, 0x10, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x54,
|
||||
0x79, 0x70, 0x65, 0x10, 0x02, 0x12, 0x14, 0x0a, 0x10, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x45,
|
||||
0x6e, 0x74, 0x69, 0x74, 0x79, 0x54, 0x79, 0x70, 0x65, 0x10, 0x03, 0x12, 0x1e, 0x0a, 0x1a, 0x44,
|
||||
0x65, 0x6c, 0x65, 0x74, 0x65, 0x54, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x61, 0x74, 0x6f, 0x72, 0x73,
|
||||
0x42, 0x61, 0x74, 0x63, 0x68, 0x54, 0x79, 0x70, 0x65, 0x10, 0x04, 0x12, 0x10, 0x0a, 0x0c, 0x53,
|
||||
0x79, 0x6e, 0x63, 0x53, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x10, 0x0a, 0x12, 0x11, 0x0a,
|
||||
0x0d, 0x49, 0x6e, 0x69, 0x74, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x49, 0x64, 0x10, 0x0b,
|
||||
0x42, 0x26, 0x5a, 0x24, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6f,
|
||||
0x70, 0x65, 0x6e, 0x7a, 0x69, 0x74, 0x69, 0x2f, 0x66, 0x61, 0x62, 0x72, 0x69, 0x63, 0x2f, 0x70,
|
||||
0x62, 0x2f, 0x63, 0x6d, 0x64, 0x5f, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||
0x74, 0x12, 0x1c, 0x0a, 0x09, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x49, 0x64, 0x18, 0x03,
|
||||
0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x49, 0x64, 0x22,
|
||||
0x54, 0x0a, 0x14, 0x49, 0x6e, 0x69, 0x74, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x49, 0x64,
|
||||
0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x12, 0x1c, 0x0a, 0x09, 0x63, 0x6c, 0x75, 0x73, 0x74,
|
||||
0x65, 0x72, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x6c, 0x75, 0x73,
|
||||
0x74, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1e, 0x0a, 0x0a, 0x74, 0x69, 0x6d, 0x65, 0x6c, 0x69, 0x6e,
|
||||
0x65, 0x49, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x74, 0x69, 0x6d, 0x65, 0x6c,
|
||||
0x69, 0x6e, 0x65, 0x49, 0x64, 0x22, 0x6b, 0x0a, 0x1d, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x54,
|
||||
0x65, 0x72, 0x6d, 0x69, 0x6e, 0x61, 0x74, 0x6f, 0x72, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43,
|
||||
0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x12, 0x1c, 0x0a, 0x09, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79,
|
||||
0x49, 0x64, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x09, 0x65, 0x6e, 0x74, 0x69, 0x74,
|
||||
0x79, 0x49, 0x64, 0x73, 0x12, 0x2c, 0x0a, 0x03, 0x63, 0x74, 0x78, 0x18, 0x02, 0x20, 0x01, 0x28,
|
||||
0x0b, 0x32, 0x1a, 0x2e, 0x7a, 0x69, 0x74, 0x69, 0x2e, 0x63, 0x6d, 0x64, 0x2e, 0x70, 0x62, 0x2e,
|
||||
0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x52, 0x03, 0x63,
|
||||
0x74, 0x78, 0x22, 0x91, 0x01, 0x0a, 0x08, 0x54, 0x61, 0x67, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12,
|
||||
0x1e, 0x0a, 0x09, 0x62, 0x6f, 0x6f, 0x6c, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x01, 0x20, 0x01,
|
||||
0x28, 0x08, 0x48, 0x00, 0x52, 0x09, 0x62, 0x6f, 0x6f, 0x6c, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12,
|
||||
0x22, 0x0a, 0x0b, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02,
|
||||
0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x0b, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x56, 0x61,
|
||||
0x6c, 0x75, 0x65, 0x12, 0x1a, 0x0a, 0x07, 0x66, 0x70, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x03,
|
||||
0x20, 0x01, 0x28, 0x01, 0x48, 0x00, 0x52, 0x07, 0x66, 0x70, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12,
|
||||
0x1c, 0x0a, 0x08, 0x6e, 0x69, 0x6c, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28,
|
||||
0x08, 0x48, 0x00, 0x52, 0x08, 0x6e, 0x69, 0x6c, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x42, 0x07, 0x0a,
|
||||
0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x83, 0x02, 0x0a, 0x07, 0x53, 0x65, 0x72, 0x76, 0x69,
|
||||
0x63, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02,
|
||||
0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09,
|
||||
0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x2e, 0x0a, 0x12, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e,
|
||||
0x61, 0x74, 0x6f, 0x72, 0x53, 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x18, 0x03, 0x20, 0x01,
|
||||
0x28, 0x09, 0x52, 0x12, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x61, 0x74, 0x6f, 0x72, 0x53, 0x74,
|
||||
0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x12, 0x32, 0x0a, 0x04, 0x74, 0x61, 0x67, 0x73, 0x18, 0x04,
|
||||
0x20, 0x03, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x7a, 0x69, 0x74, 0x69, 0x2e, 0x63, 0x6d, 0x64, 0x2e,
|
||||
0x70, 0x62, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x54, 0x61, 0x67, 0x73, 0x45,
|
||||
0x6e, 0x74, 0x72, 0x79, 0x52, 0x04, 0x74, 0x61, 0x67, 0x73, 0x12, 0x20, 0x0a, 0x0b, 0x6d, 0x61,
|
||||
0x78, 0x49, 0x64, 0x6c, 0x65, 0x54, 0x69, 0x6d, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52,
|
||||
0x0b, 0x6d, 0x61, 0x78, 0x49, 0x64, 0x6c, 0x65, 0x54, 0x69, 0x6d, 0x65, 0x1a, 0x4e, 0x0a, 0x09,
|
||||
0x54, 0x61, 0x67, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79,
|
||||
0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2b, 0x0a, 0x05, 0x76,
|
||||
0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x7a, 0x69, 0x74,
|
||||
0x69, 0x2e, 0x63, 0x6d, 0x64, 0x2e, 0x70, 0x62, 0x2e, 0x54, 0x61, 0x67, 0x56, 0x61, 0x6c, 0x75,
|
||||
0x65, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x30, 0x0a, 0x16,
|
||||
0x43, 0x74, 0x72, 0x6c, 0x43, 0x68, 0x61, 0x6e, 0x4c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x65, 0x72,
|
||||
0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x12, 0x16, 0x0a, 0x06, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73,
|
||||
0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x06, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x22, 0xa0,
|
||||
0x04, 0x0a, 0x06, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18,
|
||||
0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d,
|
||||
0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x20, 0x0a,
|
||||
0x0b, 0x66, 0x69, 0x6e, 0x67, 0x65, 0x72, 0x70, 0x72, 0x69, 0x6e, 0x74, 0x18, 0x03, 0x20, 0x01,
|
||||
0x28, 0x0c, 0x52, 0x0b, 0x66, 0x69, 0x6e, 0x67, 0x65, 0x72, 0x70, 0x72, 0x69, 0x6e, 0x74, 0x12,
|
||||
0x12, 0x0a, 0x04, 0x63, 0x6f, 0x73, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x04, 0x63,
|
||||
0x6f, 0x73, 0x74, 0x12, 0x20, 0x0a, 0x0b, 0x6e, 0x6f, 0x54, 0x72, 0x61, 0x76, 0x65, 0x72, 0x73,
|
||||
0x61, 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b, 0x6e, 0x6f, 0x54, 0x72, 0x61, 0x76,
|
||||
0x65, 0x72, 0x73, 0x61, 0x6c, 0x12, 0x1a, 0x0a, 0x08, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65,
|
||||
0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65,
|
||||
0x64, 0x12, 0x31, 0x0a, 0x04, 0x74, 0x61, 0x67, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x0b, 0x32,
|
||||
0x1d, 0x2e, 0x7a, 0x69, 0x74, 0x69, 0x2e, 0x63, 0x6d, 0x64, 0x2e, 0x70, 0x62, 0x2e, 0x52, 0x6f,
|
||||
0x75, 0x74, 0x65, 0x72, 0x2e, 0x54, 0x61, 0x67, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x04,
|
||||
0x74, 0x61, 0x67, 0x73, 0x12, 0x36, 0x0a, 0x0a, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x66, 0x61, 0x63,
|
||||
0x65, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x7a, 0x69, 0x74, 0x69, 0x2e,
|
||||
0x63, 0x6d, 0x64, 0x2e, 0x70, 0x62, 0x2e, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x66, 0x61, 0x63, 0x65,
|
||||
0x52, 0x0a, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x66, 0x61, 0x63, 0x65, 0x73, 0x12, 0x58, 0x0a, 0x11,
|
||||
0x63, 0x74, 0x72, 0x6c, 0x43, 0x68, 0x61, 0x6e, 0x4c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x65, 0x72,
|
||||
0x73, 0x18, 0x09, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2a, 0x2e, 0x7a, 0x69, 0x74, 0x69, 0x2e, 0x63,
|
||||
0x6d, 0x64, 0x2e, 0x70, 0x62, 0x2e, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x2e, 0x43, 0x74, 0x72,
|
||||
0x6c, 0x43, 0x68, 0x61, 0x6e, 0x4c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x65, 0x72, 0x73, 0x45, 0x6e,
|
||||
0x74, 0x72, 0x79, 0x52, 0x11, 0x63, 0x74, 0x72, 0x6c, 0x43, 0x68, 0x61, 0x6e, 0x4c, 0x69, 0x73,
|
||||
0x74, 0x65, 0x6e, 0x65, 0x72, 0x73, 0x1a, 0x4e, 0x0a, 0x09, 0x54, 0x61, 0x67, 0x73, 0x45, 0x6e,
|
||||
0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
|
||||
0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2b, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02,
|
||||
0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x7a, 0x69, 0x74, 0x69, 0x2e, 0x63, 0x6d, 0x64, 0x2e,
|
||||
0x70, 0x62, 0x2e, 0x54, 0x61, 0x67, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x05, 0x76, 0x61, 0x6c,
|
||||
0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x1a, 0x69, 0x0a, 0x16, 0x43, 0x74, 0x72, 0x6c, 0x43, 0x68,
|
||||
0x61, 0x6e, 0x4c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79,
|
||||
0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b,
|
||||
0x65, 0x79, 0x12, 0x39, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28,
|
||||
0x0b, 0x32, 0x23, 0x2e, 0x7a, 0x69, 0x74, 0x69, 0x2e, 0x63, 0x6d, 0x64, 0x2e, 0x70, 0x62, 0x2e,
|
||||
0x43, 0x74, 0x72, 0x6c, 0x43, 0x68, 0x61, 0x6e, 0x4c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x65, 0x72,
|
||||
0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38,
|
||||
0x01, 0x22, 0x8b, 0x05, 0x0a, 0x0a, 0x54, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x61, 0x74, 0x6f, 0x72,
|
||||
0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64,
|
||||
0x12, 0x1c, 0x0a, 0x09, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x49, 0x64, 0x18, 0x02, 0x20,
|
||||
0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x49, 0x64, 0x12, 0x1a,
|
||||
0x0a, 0x08, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x49, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09,
|
||||
0x52, 0x08, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x62, 0x69,
|
||||
0x6e, 0x64, 0x69, 0x6e, 0x67, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x62, 0x69, 0x6e,
|
||||
0x64, 0x69, 0x6e, 0x67, 0x12, 0x18, 0x0a, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18,
|
||||
0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x1e,
|
||||
0x0a, 0x0a, 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x49, 0x64, 0x18, 0x06, 0x20, 0x01,
|
||||
0x28, 0x09, 0x52, 0x0a, 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x49, 0x64, 0x12, 0x26,
|
||||
0x0a, 0x0e, 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74,
|
||||
0x18, 0x07, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0e, 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65,
|
||||
0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x63, 0x6f, 0x73, 0x74, 0x18, 0x08,
|
||||
0x20, 0x01, 0x28, 0x0d, 0x52, 0x04, 0x63, 0x6f, 0x73, 0x74, 0x12, 0x1e, 0x0a, 0x0a, 0x70, 0x72,
|
||||
0x65, 0x63, 0x65, 0x64, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0a,
|
||||
0x70, 0x72, 0x65, 0x63, 0x65, 0x64, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x41, 0x0a, 0x08, 0x70, 0x65,
|
||||
0x65, 0x72, 0x44, 0x61, 0x74, 0x61, 0x18, 0x0a, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x7a,
|
||||
0x69, 0x74, 0x69, 0x2e, 0x63, 0x6d, 0x64, 0x2e, 0x70, 0x62, 0x2e, 0x54, 0x65, 0x72, 0x6d, 0x69,
|
||||
0x6e, 0x61, 0x74, 0x6f, 0x72, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x44, 0x61, 0x74, 0x61, 0x45, 0x6e,
|
||||
0x74, 0x72, 0x79, 0x52, 0x08, 0x70, 0x65, 0x65, 0x72, 0x44, 0x61, 0x74, 0x61, 0x12, 0x35, 0x0a,
|
||||
0x04, 0x74, 0x61, 0x67, 0x73, 0x18, 0x0b, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x21, 0x2e, 0x7a, 0x69,
|
||||
0x74, 0x69, 0x2e, 0x63, 0x6d, 0x64, 0x2e, 0x70, 0x62, 0x2e, 0x54, 0x65, 0x72, 0x6d, 0x69, 0x6e,
|
||||
0x61, 0x74, 0x6f, 0x72, 0x2e, 0x54, 0x61, 0x67, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x04,
|
||||
0x74, 0x61, 0x67, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x68, 0x6f, 0x73, 0x74, 0x49, 0x64, 0x18, 0x0c,
|
||||
0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x68, 0x6f, 0x73, 0x74, 0x49, 0x64, 0x12, 0x1a, 0x0a, 0x08,
|
||||
0x69, 0x73, 0x53, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08,
|
||||
0x69, 0x73, 0x53, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x12, 0x28, 0x0a, 0x0f, 0x73, 0x61, 0x76, 0x65,
|
||||
0x64, 0x50, 0x72, 0x65, 0x63, 0x65, 0x64, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x0e, 0x20, 0x01, 0x28,
|
||||
0x0d, 0x52, 0x0f, 0x73, 0x61, 0x76, 0x65, 0x64, 0x50, 0x72, 0x65, 0x63, 0x65, 0x64, 0x65, 0x6e,
|
||||
0x63, 0x65, 0x12, 0x1e, 0x0a, 0x0a, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x43, 0x74, 0x72, 0x6c,
|
||||
0x18, 0x0f, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x43, 0x74,
|
||||
0x72, 0x6c, 0x1a, 0x3b, 0x0a, 0x0d, 0x50, 0x65, 0x65, 0x72, 0x44, 0x61, 0x74, 0x61, 0x45, 0x6e,
|
||||
0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d,
|
||||
0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02,
|
||||
0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x1a,
|
||||
0x4e, 0x0a, 0x09, 0x54, 0x61, 0x67, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03,
|
||||
0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2b,
|
||||
0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e,
|
||||
0x7a, 0x69, 0x74, 0x69, 0x2e, 0x63, 0x6d, 0x64, 0x2e, 0x70, 0x62, 0x2e, 0x54, 0x61, 0x67, 0x56,
|
||||
0x61, 0x6c, 0x75, 0x65, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22,
|
||||
0xa5, 0x01, 0x0a, 0x09, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x66, 0x61, 0x63, 0x65, 0x12, 0x12, 0x0a,
|
||||
0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d,
|
||||
0x65, 0x12, 0x28, 0x0a, 0x0f, 0x68, 0x61, 0x72, 0x64, 0x77, 0x61, 0x72, 0x65, 0x41, 0x64, 0x64,
|
||||
0x72, 0x65, 0x73, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x68, 0x61, 0x72, 0x64,
|
||||
0x77, 0x61, 0x72, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x10, 0x0a, 0x03, 0x6d,
|
||||
0x74, 0x75, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x03, 0x6d, 0x74, 0x75, 0x12, 0x14, 0x0a,
|
||||
0x05, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x69, 0x6e,
|
||||
0x64, 0x65, 0x78, 0x12, 0x14, 0x0a, 0x05, 0x66, 0x6c, 0x61, 0x67, 0x73, 0x18, 0x05, 0x20, 0x01,
|
||||
0x28, 0x04, 0x52, 0x05, 0x66, 0x6c, 0x61, 0x67, 0x73, 0x12, 0x1c, 0x0a, 0x09, 0x61, 0x64, 0x64,
|
||||
0x72, 0x65, 0x73, 0x73, 0x65, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x09, 0x52, 0x09, 0x61, 0x64,
|
||||
0x64, 0x72, 0x65, 0x73, 0x73, 0x65, 0x73, 0x2a, 0xc3, 0x01, 0x0a, 0x0b, 0x43, 0x6f, 0x6e, 0x74,
|
||||
0x65, 0x6e, 0x74, 0x54, 0x79, 0x70, 0x65, 0x12, 0x13, 0x0a, 0x0f, 0x43, 0x6f, 0x6e, 0x74, 0x65,
|
||||
0x6e, 0x74, 0x54, 0x79, 0x70, 0x65, 0x5a, 0x65, 0x72, 0x6f, 0x10, 0x00, 0x12, 0x14, 0x0a, 0x0f,
|
||||
0x4e, 0x65, 0x77, 0x4c, 0x6f, 0x67, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x54, 0x79, 0x70, 0x65, 0x10,
|
||||
0x82, 0x10, 0x12, 0x16, 0x0a, 0x11, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f,
|
||||
0x6e, 0x73, 0x65, 0x54, 0x79, 0x70, 0x65, 0x10, 0x83, 0x10, 0x12, 0x18, 0x0a, 0x13, 0x53, 0x75,
|
||||
0x63, 0x63, 0x65, 0x73, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x54, 0x79, 0x70,
|
||||
0x65, 0x10, 0x84, 0x10, 0x12, 0x17, 0x0a, 0x12, 0x41, 0x64, 0x64, 0x50, 0x65, 0x65, 0x72, 0x52,
|
||||
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x54, 0x79, 0x70, 0x65, 0x10, 0x85, 0x10, 0x12, 0x1a, 0x0a,
|
||||
0x15, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65,
|
||||
0x73, 0x74, 0x54, 0x79, 0x70, 0x65, 0x10, 0x86, 0x10, 0x12, 0x22, 0x0a, 0x1d, 0x54, 0x72, 0x61,
|
||||
0x6e, 0x73, 0x66, 0x65, 0x72, 0x4c, 0x65, 0x61, 0x64, 0x65, 0x72, 0x73, 0x68, 0x69, 0x70, 0x52,
|
||||
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x54, 0x79, 0x70, 0x65, 0x10, 0x87, 0x10, 0x2a, 0x9e, 0x01,
|
||||
0x0a, 0x0b, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x54, 0x79, 0x70, 0x65, 0x12, 0x08, 0x0a,
|
||||
0x04, 0x5a, 0x65, 0x72, 0x6f, 0x10, 0x00, 0x12, 0x14, 0x0a, 0x10, 0x43, 0x72, 0x65, 0x61, 0x74,
|
||||
0x65, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x54, 0x79, 0x70, 0x65, 0x10, 0x01, 0x12, 0x14, 0x0a,
|
||||
0x10, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x54, 0x79, 0x70,
|
||||
0x65, 0x10, 0x02, 0x12, 0x14, 0x0a, 0x10, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x45, 0x6e, 0x74,
|
||||
0x69, 0x74, 0x79, 0x54, 0x79, 0x70, 0x65, 0x10, 0x03, 0x12, 0x1e, 0x0a, 0x1a, 0x44, 0x65, 0x6c,
|
||||
0x65, 0x74, 0x65, 0x54, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x61, 0x74, 0x6f, 0x72, 0x73, 0x42, 0x61,
|
||||
0x74, 0x63, 0x68, 0x54, 0x79, 0x70, 0x65, 0x10, 0x04, 0x12, 0x10, 0x0a, 0x0c, 0x53, 0x79, 0x6e,
|
||||
0x63, 0x53, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x10, 0x0a, 0x12, 0x11, 0x0a, 0x0d, 0x49,
|
||||
0x6e, 0x69, 0x74, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x49, 0x64, 0x10, 0x0b, 0x42, 0x26,
|
||||
0x5a, 0x24, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6f, 0x70, 0x65,
|
||||
0x6e, 0x7a, 0x69, 0x74, 0x69, 0x2f, 0x66, 0x61, 0x62, 0x72, 0x69, 0x63, 0x2f, 0x70, 0x62, 0x2f,
|
||||
0x63, 0x6d, 0x64, 0x5f, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||
}
|
||||
|
||||
var (
|
||||
|
||||
@@ -75,6 +75,7 @@ message DeleteEntityCommand {
|
||||
message SyncSnapshotCommand {
|
||||
string snapshotId = 1;
|
||||
bytes snapshot = 2;
|
||||
string clusterId = 3;
|
||||
}
|
||||
|
||||
message InitClusterIdCommand {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v3.21.12
|
||||
// protoc v4.23.4
|
||||
// source: edge_ctrl.proto
|
||||
|
||||
package edge_ctrl_pb
|
||||
@@ -627,8 +627,11 @@ func (DataState_Action) EnumDescriptor() ([]byte, []int) {
|
||||
type DataState_PublicKey_Usage int32
|
||||
|
||||
const (
|
||||
DataState_PublicKey_JWTValidation DataState_PublicKey_Usage = 0
|
||||
DataState_PublicKey_ClientX509CertValidation DataState_PublicKey_Usage = 1
|
||||
DataState_PublicKey_JWTValidation DataState_PublicKey_Usage = 0
|
||||
// Deprecated: Marked as deprecated in edge_ctrl.proto.
|
||||
DataState_PublicKey_ClientX509CertValidation DataState_PublicKey_Usage = 1 //superseded by FirstPartyX509CertValidation and ThirdPartyX509CertValidation
|
||||
DataState_PublicKey_FirstPartyX509CertValidation DataState_PublicKey_Usage = 2 //network-internal CAs: controller certs, edge signing CA roots
|
||||
DataState_PublicKey_ThirdPartyX509CertValidation DataState_PublicKey_Usage = 3 //externally registered CAs
|
||||
)
|
||||
|
||||
// Enum value maps for DataState_PublicKey_Usage.
|
||||
@@ -636,10 +639,14 @@ var (
|
||||
DataState_PublicKey_Usage_name = map[int32]string{
|
||||
0: "JWTValidation",
|
||||
1: "ClientX509CertValidation",
|
||||
2: "FirstPartyX509CertValidation",
|
||||
3: "ThirdPartyX509CertValidation",
|
||||
}
|
||||
DataState_PublicKey_Usage_value = map[string]int32{
|
||||
"JWTValidation": 0,
|
||||
"ClientX509CertValidation": 1,
|
||||
"JWTValidation": 0,
|
||||
"ClientX509CertValidation": 1,
|
||||
"FirstPartyX509CertValidation": 2,
|
||||
"ThirdPartyX509CertValidation": 3,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -4683,6 +4690,7 @@ type DataState_PublicKey struct {
|
||||
Kid string `protobuf:"bytes,2,opt,name=kid,proto3" json:"kid,omitempty"` //key id/fingerprint
|
||||
Usages []DataState_PublicKey_Usage `protobuf:"varint,3,rep,packed,name=usages,proto3,enum=ziti.edge_ctrl.pb.DataState_PublicKey_Usage" json:"usages,omitempty"` // what the public key in data is used for
|
||||
Format DataState_PublicKey_Format `protobuf:"varint,4,opt,name=format,proto3,enum=ziti.edge_ctrl.pb.DataState_PublicKey_Format" json:"format,omitempty"` //the format of the public key in data and chain
|
||||
Intermediates [][]byte `protobuf:"bytes,5,rep,name=intermediates,proto3" json:"intermediates,omitempty"` //intermediate CA certs chaining to the anchor in data, same format
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -4745,6 +4753,13 @@ func (x *DataState_PublicKey) GetFormat() DataState_PublicKey_Format {
|
||||
return DataState_PublicKey_X509CertDer
|
||||
}
|
||||
|
||||
func (x *DataState_PublicKey) GetIntermediates() [][]byte {
|
||||
if x != nil {
|
||||
return x.Intermediates
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type DataState_PostureCheck struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
|
||||
@@ -5451,7 +5466,7 @@ const file_edge_ctrl_proto_rawDesc = "" +
|
||||
"\x04data\x18\x01 \x03(\v2\".ziti.edge_ctrl.pb.Cache.DataEntryR\x04data\x1a7\n" +
|
||||
"\tDataEntry\x12\x10\n" +
|
||||
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
||||
"\x05value\x18\x02 \x01(\fR\x05value:\x028\x01\"\xb5$\n" +
|
||||
"\x05value\x18\x02 \x01(\fR\x05value:\x028\x01\"\xa4%\n" +
|
||||
"\tDataState\x12:\n" +
|
||||
"\x06events\x18\x01 \x03(\v2\".ziti.edge_ctrl.pb.DataState.EventR\x06events\x12\x1a\n" +
|
||||
"\bendIndex\x18\x02 \x01(\x04R\bendIndex\x12\x1e\n" +
|
||||
@@ -5540,15 +5555,18 @@ const file_edge_ctrl_proto_rawDesc = "" +
|
||||
"configType\x18\x11 \x01(\v2'.ziti.edge_ctrl.pb.DataState.ConfigTypeH\x00R\n" +
|
||||
"configType\x12=\n" +
|
||||
"\x06config\x18\x12 \x01(\v2#.ziti.edge_ctrl.pb.DataState.ConfigH\x00R\x06configB\a\n" +
|
||||
"\x05Model\x1a\xa6\x02\n" +
|
||||
"\x05Model\x1a\x95\x03\n" +
|
||||
"\tPublicKey\x12\x12\n" +
|
||||
"\x04data\x18\x01 \x01(\fR\x04data\x12\x10\n" +
|
||||
"\x03kid\x18\x02 \x01(\tR\x03kid\x12D\n" +
|
||||
"\x06usages\x18\x03 \x03(\x0e2,.ziti.edge_ctrl.pb.DataState.PublicKey.UsageR\x06usages\x12E\n" +
|
||||
"\x06format\x18\x04 \x01(\x0e2-.ziti.edge_ctrl.pb.DataState.PublicKey.FormatR\x06format\"8\n" +
|
||||
"\x06format\x18\x04 \x01(\x0e2-.ziti.edge_ctrl.pb.DataState.PublicKey.FormatR\x06format\x12$\n" +
|
||||
"\rintermediates\x18\x05 \x03(\fR\rintermediates\"\x80\x01\n" +
|
||||
"\x05Usage\x12\x11\n" +
|
||||
"\rJWTValidation\x10\x00\x12\x1c\n" +
|
||||
"\x18ClientX509CertValidation\x10\x01\",\n" +
|
||||
"\rJWTValidation\x10\x00\x12 \n" +
|
||||
"\x18ClientX509CertValidation\x10\x01\x1a\x02\b\x01\x12 \n" +
|
||||
"\x1cFirstPartyX509CertValidation\x10\x02\x12 \n" +
|
||||
"\x1cThirdPartyX509CertValidation\x10\x03\",\n" +
|
||||
"\x06Format\x12\x0f\n" +
|
||||
"\vX509CertDer\x10\x00\x12\x11\n" +
|
||||
"\rPKIXPublicKey\x10\x01\x1a\xa3\t\n" +
|
||||
|
||||
@@ -258,10 +258,13 @@ message DataState {
|
||||
string kid = 2; //key id/fingerprint
|
||||
repeated Usage usages = 3; // what the public key in data is used for
|
||||
Format format = 4; //the format of the public key in data and chain
|
||||
repeated bytes intermediates = 5; //intermediate CA certs chaining to the anchor in data, same format
|
||||
|
||||
enum Usage {
|
||||
JWTValidation = 0;
|
||||
ClientX509CertValidation = 1;
|
||||
ClientX509CertValidation = 1 [deprecated = true]; //superseded by FirstPartyX509CertValidation and ThirdPartyX509CertValidation
|
||||
FirstPartyX509CertValidation = 2; //network-internal CAs: controller certs, edge signing CA roots
|
||||
ThirdPartyX509CertValidation = 3; //externally registered CAs
|
||||
}
|
||||
|
||||
enum Format {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/openziti/ziti/v2/common/pb/edge_ctrl_pb"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
// Test_PublicKeyIntermediatesSurviveValidateRoundTrip mirrors the router-data-model validate
|
||||
// flow: a router's live model and a bare model rebuilt from a proto-round-tripped controller
|
||||
// snapshot must agree on a public key that carries intermediates.
|
||||
func Test_PublicKeyIntermediatesSurviveValidateRoundTrip(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
key := &edge_ctrl_pb.DataState_PublicKey{
|
||||
Kid: "kid1",
|
||||
Data: []byte("anchor-der"),
|
||||
Format: edge_ctrl_pb.DataState_PublicKey_X509CertDer,
|
||||
Usages: []edge_ctrl_pb.DataState_PublicKey_Usage{
|
||||
edge_ctrl_pb.DataState_PublicKey_ClientX509CertValidation,
|
||||
edge_ctrl_pb.DataState_PublicKey_FirstPartyX509CertValidation,
|
||||
},
|
||||
Intermediates: [][]byte{[]byte("intermediate-der")},
|
||||
}
|
||||
evt := &edge_ctrl_pb.DataState_Event{
|
||||
Action: edge_ctrl_pb.DataState_Create,
|
||||
Model: &edge_ctrl_pb.DataState_Event_PublicKey{PublicKey: key},
|
||||
IsSynthetic: true,
|
||||
}
|
||||
|
||||
current := NewBareRouterDataModel()
|
||||
current.WhileLocked(func(u uint64) {
|
||||
current.Handle(1, evt)
|
||||
current.SetCurrentIndex(1)
|
||||
})
|
||||
|
||||
state := &edge_ctrl_pb.DataState{Events: []*edge_ctrl_pb.DataState_Event{evt}, EndIndex: 1}
|
||||
request := &edge_ctrl_pb.RouterDataModelValidateRequest{State: state}
|
||||
wire, err := proto.Marshal(request)
|
||||
req.NoError(err)
|
||||
parsed := &edge_ctrl_pb.RouterDataModelValidateRequest{}
|
||||
req.NoError(proto.Unmarshal(wire, parsed))
|
||||
|
||||
model := NewBareRouterDataModel()
|
||||
model.WhileLocked(func(u uint64) {
|
||||
for _, e := range parsed.State.Events {
|
||||
model.Handle(parsed.State.EndIndex, e)
|
||||
}
|
||||
model.SetCurrentIndex(parsed.State.EndIndex)
|
||||
})
|
||||
|
||||
var diffs []string
|
||||
current.Validate(model, func(entityType string, id string, diffType DiffType, detail string) {
|
||||
diffs = append(diffs, entityType+" "+id+" "+detail)
|
||||
})
|
||||
req.Empty(diffs)
|
||||
}
|
||||
@@ -142,6 +142,12 @@ type TokenIssuerCache interface {
|
||||
|
||||
// GetIssuerByKid returns the TokenIssuer that owns the given key ID
|
||||
GetIssuerByKid(kid string) TokenIssuer
|
||||
|
||||
// GetControllerIssuerByKid returns the controller TokenIssuer that owns the given key ID,
|
||||
// or nil if no controller issuer claims that kid. A controller issuer's key ID is the
|
||||
// fingerprint of its TLS certificate, so this resolves controller-issued tokens by kid
|
||||
// without consulting external signers.
|
||||
GetControllerIssuerByKid(kid string) TokenIssuer
|
||||
}
|
||||
|
||||
// SecurityToken is the result of verifying the primary security token presented on a request.
|
||||
@@ -505,12 +511,20 @@ func (s *SecurityTokenCtx) processHeaders() error {
|
||||
continue
|
||||
}
|
||||
|
||||
// Bind controller-issued tokens first by kid. A controller issuer's kid is the
|
||||
// fingerprint of its TLS certificate, so it does not collide with external signers.
|
||||
// This must precede the issuer-string lookup so an external signer configured with a
|
||||
// controller's OIDC issuer URL cannot capture controller access tokens.
|
||||
kid := bearerToken.Kid()
|
||||
|
||||
if kid != "" {
|
||||
bearerToken.TokenIssuer = s.tokenIssuerCache.GetIssuerByKid(kid)
|
||||
bearerToken.TokenIssuer = s.tokenIssuerCache.GetControllerIssuerByKid(kid)
|
||||
}
|
||||
|
||||
// Otherwise bind external signers by their exact issuer claim. Binding by kid is not
|
||||
// used as a fallback: external signers can share a kid (shared signing-key pools), so
|
||||
// kid resolution is ambiguous, and binding a token whose issuer matches no configured
|
||||
// signer to an unrelated signer that happens to share the kid would be incorrect.
|
||||
if bearerToken.TokenIssuer == nil {
|
||||
issuer := bearerToken.Issuer()
|
||||
if issuer != "" {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
Copyright NetFoundry Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package api
|
||||
|
||||
// MaxRequestBodySize is the maximum number of HTTP request body bytes the controller's
|
||||
// web APIs accept per request. Request bodies are buffered into memory before
|
||||
// authentication, so this cap bounds the memory an unauthenticated client can consume.
|
||||
// Requests with larger bodies are rejected with HTTP 413 Request Entity Too Large.
|
||||
const MaxRequestBodySize = 1024 * 1024
|
||||
@@ -49,6 +49,16 @@ func NewCouldNotReadBody(err error) *errorz.ApiError {
|
||||
}
|
||||
}
|
||||
|
||||
// NewRequestEntityTooLarge returns the 413 ApiError raised when a request body exceeds the
|
||||
// maximum size the web APIs accept.
|
||||
func NewRequestEntityTooLarge() *errorz.ApiError {
|
||||
return &errorz.ApiError{
|
||||
AppCode: RequestEntityTooLargeCode,
|
||||
Message: RequestEntityTooLargeMessage,
|
||||
Status: RequestEntityTooLargeStatus,
|
||||
}
|
||||
}
|
||||
|
||||
func NewInvalidAuth() *errorz.ApiError {
|
||||
return &errorz.ApiError{
|
||||
AppCode: InvalidAuthCode,
|
||||
|
||||
@@ -31,6 +31,10 @@ const (
|
||||
CouldNotReadBodyMessage string = "The body of the request could not be read"
|
||||
CouldNotReadBodyStatus int = http.StatusInternalServerError
|
||||
|
||||
RequestEntityTooLargeCode string = "REQUEST_ENTITY_TOO_LARGE"
|
||||
RequestEntityTooLargeMessage string = "The request body exceeds the maximum accepted size"
|
||||
RequestEntityTooLargeStatus int = http.StatusRequestEntityTooLarge
|
||||
|
||||
InvalidUuidCode string = "INVALID_UUID"
|
||||
InvalidUuidMessage string = "The supplied UUID is invalid"
|
||||
InvalidUuidStatus int = http.StatusBadRequest
|
||||
|
||||
@@ -18,14 +18,15 @@ package command
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"sync"
|
||||
|
||||
"github.com/michaelquigley/pfxlog"
|
||||
"github.com/openziti/channel/v4"
|
||||
"github.com/openziti/foundation/v2/debugz"
|
||||
"github.com/openziti/foundation/v2/rate"
|
||||
"github.com/openziti/ziti/v2/controller/storage/boltz"
|
||||
"github.com/openziti/ziti/v2/common/pb/ctrl_pb"
|
||||
"github.com/openziti/ziti/v2/controller/change"
|
||||
"github.com/openziti/ziti/v2/controller/storage/boltz"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
@@ -42,6 +43,14 @@ type Command interface {
|
||||
Encode() ([]byte, error)
|
||||
}
|
||||
|
||||
// CriticalCommand marks commands that establish base state (e.g. a snapshot restore). A failed apply
|
||||
// of one must halt the node rather than log-and-advance the raft index, which would leave the node
|
||||
// caught up on index but missing data. Ordinary commands are logged and skipped on failure.
|
||||
type CriticalCommand interface {
|
||||
Command
|
||||
IsCriticalCommand()
|
||||
}
|
||||
|
||||
// Validatable instances can be validated. Command instances which implement Validable will be validated
|
||||
// before Command.Apply is called
|
||||
type Validatable interface {
|
||||
@@ -59,12 +68,27 @@ type Dispatcher interface {
|
||||
GetRateLimiter() rate.RateLimiter
|
||||
Bootstrap() error
|
||||
CtrlAddresses() (uint64, []string, []*ctrl_pb.CtrlDetail)
|
||||
|
||||
// GetDecoders returns the command decoder registry used to decode commands dispatched
|
||||
// through this dispatcher. Each dispatcher owns its own registry so multiple controllers
|
||||
// in one process don't decode each other's commands.
|
||||
GetDecoders() Decoders
|
||||
}
|
||||
|
||||
// LocalDispatcher should be used when running a non-clustered system
|
||||
type LocalDispatcher struct {
|
||||
EncodeDecodeCommands bool
|
||||
Limiter rate.RateLimiter
|
||||
|
||||
decodersInit sync.Once
|
||||
decoders Decoders
|
||||
}
|
||||
|
||||
func (self *LocalDispatcher) GetDecoders() Decoders {
|
||||
self.decodersInit.Do(func() {
|
||||
self.decoders = NewDecoders()
|
||||
})
|
||||
return self.decoders
|
||||
}
|
||||
|
||||
func (self *LocalDispatcher) Bootstrap() error {
|
||||
@@ -117,7 +141,7 @@ func (self *LocalDispatcher) Dispatch(command Command) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cmd, err := GetDefaultDecoders().Decode(bytes)
|
||||
cmd, err := self.GetDecoders().Decode(bytes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"github.com/openziti/ziti/v2/controller/storage/boltz"
|
||||
"github.com/openziti/ziti/v2/common/pb/cmd_pb"
|
||||
"github.com/openziti/ziti/v2/controller/change"
|
||||
"github.com/openziti/ziti/v2/controller/fields"
|
||||
"github.com/openziti/ziti/v2/controller/models"
|
||||
"github.com/openziti/ziti/v2/controller/storage/boltz"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
@@ -144,9 +144,12 @@ func (self *DeleteEntityCommand) GetChangeContext() *change.Context {
|
||||
return self.Context
|
||||
}
|
||||
|
||||
var _ CriticalCommand = (*SyncSnapshotCommand)(nil)
|
||||
|
||||
type SyncSnapshotCommand struct {
|
||||
TimelineId string
|
||||
Snapshot []byte
|
||||
ClusterId string
|
||||
SnapshotSink func(cmd *SyncSnapshotCommand, index uint64) error
|
||||
}
|
||||
|
||||
@@ -155,10 +158,14 @@ func (self *SyncSnapshotCommand) Apply(ctx boltz.MutateContext) error {
|
||||
return self.SnapshotSink(self, changeCtx.RaftIndex)
|
||||
}
|
||||
|
||||
// IsCriticalCommand marks SyncSnapshotCommand as base state: a failed apply halts rather than advances.
|
||||
func (self *SyncSnapshotCommand) IsCriticalCommand() {}
|
||||
|
||||
func (self *SyncSnapshotCommand) Encode() ([]byte, error) {
|
||||
return cmd_pb.EncodeProtobuf(&cmd_pb.SyncSnapshotCommand{
|
||||
SnapshotId: self.TimelineId,
|
||||
Snapshot: self.Snapshot,
|
||||
ClusterId: self.ClusterId,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -438,6 +438,17 @@ func WasRateLimited(err error) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// WasLeaderless returns true if the given error indicates that a command could not be dispatched because the
|
||||
// cluster currently has no leader. This is a transient condition during membership changes; callers should
|
||||
// treat it like rate limiting and signal the requester to retry rather than fail permanently.
|
||||
func WasLeaderless(err error) bool {
|
||||
var apiErr *errorz.ApiError
|
||||
if errors.As(err, &apiErr) {
|
||||
return apiErr.AppCode == apierror.ClusterHasNoLeaderCode
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// AdaptiveRateLimitTrackerConfig contains configuration values used to create a new AdaptiveRateLimitTracker
|
||||
type AdaptiveRateLimitTrackerConfig struct {
|
||||
AdaptiveRateLimiterConfig
|
||||
|
||||
@@ -37,6 +37,7 @@ import (
|
||||
"github.com/openziti/ziti/v2/common"
|
||||
"github.com/openziti/ziti/v2/controller/command"
|
||||
"github.com/pkg/errors"
|
||||
"golang.org/x/net/idna"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -72,6 +73,17 @@ const (
|
||||
|
||||
DefaultIdentityOnlineStatusUnknownTimeout = 5 * time.Minute
|
||||
DefaultIdentityOnlineStatusSource = IdentityStatusSourceHybrid
|
||||
|
||||
// DefaultJwksFetchBlockPrivateAddresses leaves private and loopback addresses reachable by
|
||||
// default so that deployments using an internal IdP keep working. Metadata and link-local
|
||||
// addresses are blocked regardless of this setting.
|
||||
DefaultJwksFetchBlockPrivateAddresses = false
|
||||
|
||||
// DefaultJwksFetchTimeout bounds the total time spent fetching a JWKS endpoint.
|
||||
DefaultJwksFetchTimeout = 5 * time.Second
|
||||
|
||||
// DefaultJwksFetchMaxRedirects bounds how many redirects a JWKS fetch will follow.
|
||||
DefaultJwksFetchMaxRedirects = 5
|
||||
)
|
||||
|
||||
type Enrollment struct {
|
||||
@@ -136,6 +148,67 @@ func (o *Oidc) MaxTokenDuration() time.Duration {
|
||||
return common.MaxTokenDuration(o.RefreshTokenDuration, o.AccessTokenDuration, o.IdTokenDuration)
|
||||
}
|
||||
|
||||
// ExternalJwtSigners holds settings that govern how the controller interacts with
|
||||
// external JWT signers.
|
||||
type ExternalJwtSigners struct {
|
||||
JwksFetch JwksFetch
|
||||
}
|
||||
|
||||
// JwksFetch controls the server-side fetch of an external JWT signer's jwksEndpoint.
|
||||
// The endpoint URL is supplied by an operator, so the fetch is constrained to keep it
|
||||
// from being pointed at addresses the controller can reach but a caller should not.
|
||||
//
|
||||
// A hop is fetched only if it passes two independent gates. Neither gate can authorize what
|
||||
// the other refuses, and both are applied to the initial request and to every redirect.
|
||||
//
|
||||
// The host gate is applied to the URL's hostname:
|
||||
//
|
||||
// 1. DeniedHostnames - blocked
|
||||
// 2. AllowedHostnames, when non-empty and the host does not match - blocked
|
||||
// 3. otherwise - passes
|
||||
//
|
||||
// The address gate is applied to the resolved address being connected to, first-match-wins,
|
||||
// deny before allow:
|
||||
//
|
||||
// 1. built-in blocked addresses (cloud metadata, link-local, link-local multicast,
|
||||
// unspecified) - always blocked, AllowedIPs cannot override
|
||||
// 2. DeniedIPs - blocked, AllowedIPs cannot override
|
||||
// 3. AllowedIPs - allowed; a carve-out of tier 4 only
|
||||
// 4. BlockPrivateAddresses and the address is private or loopback - blocked
|
||||
// 5. everything else - allowed
|
||||
type JwksFetch struct {
|
||||
// BlockPrivateAddresses blocks private and loopback addresses (address gate tier 4).
|
||||
// Defaults to false so deployments with an internal IdP keep working; tier 1 applies
|
||||
// regardless.
|
||||
BlockPrivateAddresses bool
|
||||
|
||||
// DeniedIPs are CIDRs that are always blocked (address gate tier 2), above
|
||||
// AllowedIPs.
|
||||
DeniedIPs []*net.IPNet
|
||||
|
||||
// AllowedIPs are CIDRs that carve an exception out of BlockPrivateAddresses
|
||||
// (address gate tier 3). They do not override tier 1 or DeniedIPs.
|
||||
AllowedIPs []*net.IPNet
|
||||
|
||||
// DeniedHostnames are normalized hostname patterns that are blocked (hostname gate tier 1). Host
|
||||
// matching only ever narrows what may be fetched: it cannot authorize an address the
|
||||
// address gate blocks, and a caller can still reach the same target under another name,
|
||||
// so the address gate remains the boundary.
|
||||
DeniedHostnames []string
|
||||
|
||||
// AllowedHostnames are normalized hostname patterns that, when non-empty, are the only hosts that
|
||||
// may be fetched (hostname gate tier 2). Entries are an exact hostname (idp.example.com) or a
|
||||
// wildcard suffix (*.example.com), which matches any subdomain but not the suffix itself.
|
||||
AllowedHostnames []string
|
||||
|
||||
// Timeout bounds the total time spent on a single JWKS fetch, including redirects.
|
||||
Timeout time.Duration
|
||||
|
||||
// MaxRedirects bounds how many redirects a JWKS fetch will follow. Every hop is
|
||||
// address-checked. Zero disables redirects.
|
||||
MaxRedirects int
|
||||
}
|
||||
|
||||
type EdgeConfig struct {
|
||||
Enabled bool
|
||||
Api Api
|
||||
@@ -149,6 +222,7 @@ type EdgeConfig struct {
|
||||
caCerts []*x509.Certificate
|
||||
caCertPool *x509.CertPool
|
||||
DisablePostureChecks bool
|
||||
ExternalJwtSigners ExternalJwtSigners
|
||||
}
|
||||
|
||||
type HttpTimeouts struct {
|
||||
@@ -182,10 +256,24 @@ type IdentityStatusConfig struct {
|
||||
UnknownTimeout time.Duration
|
||||
}
|
||||
|
||||
// DefaultJwksFetch returns the default JWKS fetch settings. The defaults are deliberately
|
||||
// compatible with existing deployments: only the non-disableable built-in blocked addresses
|
||||
// are refused.
|
||||
func DefaultJwksFetch() JwksFetch {
|
||||
return JwksFetch{
|
||||
BlockPrivateAddresses: DefaultJwksFetchBlockPrivateAddresses,
|
||||
Timeout: DefaultJwksFetchTimeout,
|
||||
MaxRedirects: DefaultJwksFetchMaxRedirects,
|
||||
}
|
||||
}
|
||||
|
||||
func NewEdgeConfig() *EdgeConfig {
|
||||
return &EdgeConfig{
|
||||
Enabled: false,
|
||||
caPems: bytes.NewBuffer(nil),
|
||||
ExternalJwtSigners: ExternalJwtSigners{
|
||||
JwksFetch: DefaultJwksFetch(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -703,6 +791,250 @@ func (c *EdgeConfig) loadIdentityStatusConfig(cfgmap map[interface{}]interface{}
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadExternalJwtSignersSection loads [edge.externalJwtSigners]. Every value is optional;
|
||||
// absent values keep the defaults from DefaultJwksFetch.
|
||||
func (c *EdgeConfig) loadExternalJwtSignersSection(edgeConfigMap map[any]any) error {
|
||||
c.ExternalJwtSigners.JwksFetch = DefaultJwksFetch()
|
||||
|
||||
value, found := edgeConfigMap["externalJwtSigners"]
|
||||
|
||||
if !found || value == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
extJwtSignersMap, ok := value.(map[any]any)
|
||||
|
||||
if !ok {
|
||||
return errors.Errorf("invalid type %T for [edge.externalJwtSigners], must be a map", value)
|
||||
}
|
||||
|
||||
value, found = extJwtSignersMap["jwksFetch"]
|
||||
|
||||
if !found || value == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
jwksFetchMap, ok := value.(map[any]any)
|
||||
|
||||
if !ok {
|
||||
return errors.Errorf("invalid type %T for [edge.externalJwtSigners.jwksFetch], must be a map", value)
|
||||
}
|
||||
|
||||
jwksFetch := &c.ExternalJwtSigners.JwksFetch
|
||||
|
||||
if val, found := jwksFetchMap["blockPrivateAddresses"]; found && val != nil {
|
||||
switch typedVal := val.(type) {
|
||||
case bool:
|
||||
jwksFetch.BlockPrivateAddresses = typedVal
|
||||
case string:
|
||||
boolVal, err := strconv.ParseBool(typedVal)
|
||||
if err != nil {
|
||||
return errors.Errorf("invalid value %q for [edge.externalJwtSigners.jwksFetch.blockPrivateAddresses], must be a boolean", typedVal)
|
||||
}
|
||||
jwksFetch.BlockPrivateAddresses = boolVal
|
||||
default:
|
||||
return errors.Errorf("invalid type %T for [edge.externalJwtSigners.jwksFetch.blockPrivateAddresses], must be a boolean", val)
|
||||
}
|
||||
}
|
||||
|
||||
if val, found := jwksFetchMap["deniedIPs"]; found && val != nil {
|
||||
addresses, err := parseCidrList(val, "edge.externalJwtSigners.jwksFetch.deniedIPs")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
jwksFetch.DeniedIPs = addresses
|
||||
}
|
||||
|
||||
if val, found := jwksFetchMap["allowedIPs"]; found && val != nil {
|
||||
addresses, err := parseCidrList(val, "edge.externalJwtSigners.jwksFetch.allowedIPs")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
jwksFetch.AllowedIPs = addresses
|
||||
}
|
||||
|
||||
if val, found := jwksFetchMap["deniedHostnames"]; found && val != nil {
|
||||
hosts, err := parseHostnameList(val, "edge.externalJwtSigners.jwksFetch.deniedHostnames")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
jwksFetch.DeniedHostnames = hosts
|
||||
}
|
||||
|
||||
if val, found := jwksFetchMap["allowedHostnames"]; found && val != nil {
|
||||
hosts, err := parseHostnameList(val, "edge.externalJwtSigners.jwksFetch.allowedHostnames")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
jwksFetch.AllowedHostnames = hosts
|
||||
}
|
||||
|
||||
if val, found := jwksFetchMap["timeout"]; found && val != nil {
|
||||
strVal, ok := val.(string)
|
||||
|
||||
if !ok {
|
||||
return errors.Errorf("invalid type %T for [edge.externalJwtSigners.jwksFetch.timeout], must be a string duration", val)
|
||||
}
|
||||
|
||||
durationVal, err := time.ParseDuration(strVal)
|
||||
|
||||
if err != nil {
|
||||
return errors.Errorf("error parsing [edge.externalJwtSigners.jwksFetch.timeout], invalid duration string %s, cannot parse as duration (e.g. 5s): %v", strVal, err)
|
||||
}
|
||||
|
||||
if durationVal <= 0 {
|
||||
return errors.Errorf("invalid value %s for [edge.externalJwtSigners.jwksFetch.timeout], must be greater than zero", strVal)
|
||||
}
|
||||
|
||||
jwksFetch.Timeout = durationVal
|
||||
}
|
||||
|
||||
if val, found := jwksFetchMap["maxRedirects"]; found && val != nil {
|
||||
intVal, ok := val.(int)
|
||||
|
||||
if !ok {
|
||||
return errors.Errorf("invalid type %T for [edge.externalJwtSigners.jwksFetch.maxRedirects], must be an integer", val)
|
||||
}
|
||||
|
||||
if intVal < 0 {
|
||||
return errors.Errorf("invalid value %v for [edge.externalJwtSigners.jwksFetch.maxRedirects], must not be negative", intVal)
|
||||
}
|
||||
|
||||
jwksFetch.MaxRedirects = intVal
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseCidrList parses a list of CIDRs, accepting a bare IP address as a single address CIDR
|
||||
// (/32 for IPv4, /128 for IPv6). Hostnames are rejected: the address check happens at dial
|
||||
// time against the resolved IP, so a hostname entry could never be matched reliably.
|
||||
func parseCidrList(value any, field string) ([]*net.IPNet, error) {
|
||||
values, ok := value.([]any)
|
||||
|
||||
if !ok {
|
||||
return nil, errors.Errorf("invalid type %T for [%s], must be a list of CIDRs", value, field)
|
||||
}
|
||||
|
||||
var result []*net.IPNet
|
||||
|
||||
for _, entry := range values {
|
||||
strVal, ok := entry.(string)
|
||||
|
||||
if !ok {
|
||||
return nil, errors.Errorf("invalid type %T for an entry in [%s], must be a string CIDR", entry, field)
|
||||
}
|
||||
|
||||
ipNet, err := parseCidrOrIp(strings.TrimSpace(strVal))
|
||||
|
||||
if err != nil {
|
||||
return nil, errors.Errorf("invalid value %q in [%s]: %v", strVal, field, err)
|
||||
}
|
||||
|
||||
result = append(result, ipNet)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// parseHostnameList parses a list of hostname patterns, returning them normalized for comparison
|
||||
// against a URL's hostname.
|
||||
func parseHostnameList(value any, field string) ([]string, error) {
|
||||
values, ok := value.([]any)
|
||||
|
||||
if !ok {
|
||||
return nil, errors.Errorf("invalid type %T for [%s], must be a list of hostnames", value, field)
|
||||
}
|
||||
|
||||
var result []string
|
||||
|
||||
for _, entry := range values {
|
||||
strVal, ok := entry.(string)
|
||||
|
||||
if !ok {
|
||||
return nil, errors.Errorf("invalid type %T for an entry in [%s], must be a string hostname", entry, field)
|
||||
}
|
||||
|
||||
pattern, err := parseHostnamePattern(strings.TrimSpace(strVal))
|
||||
|
||||
if err != nil {
|
||||
return nil, errors.Errorf("invalid value %q in [%s]: %v", strVal, field, err)
|
||||
}
|
||||
|
||||
result = append(result, pattern)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// parseHostnamePattern validates a host entry and returns it normalized. An entry is either an
|
||||
// exact host (idp.example.com) or a wildcard suffix (*.example.com), which matches any
|
||||
// subdomain of that suffix but not the suffix itself. IP addresses are rejected: matching an
|
||||
// address by name comparison would not be an address check.
|
||||
func parseHostnamePattern(value string) (string, error) {
|
||||
if value == "" {
|
||||
return "", errors.New("must not be empty")
|
||||
}
|
||||
|
||||
host := strings.TrimPrefix(value, "*.")
|
||||
isWildcard := host != value
|
||||
|
||||
if net.ParseIP(strings.Trim(host, "[]")) != nil {
|
||||
return "", errors.New("must be a hostname, use deniedIPs or allowedIPs for IP addresses")
|
||||
}
|
||||
|
||||
if host == "" {
|
||||
return "", errors.New("must include a hostname after the leading \"*.\"")
|
||||
}
|
||||
|
||||
if strings.ContainsAny(host, "*/:@ \t") {
|
||||
return "", errors.New("must be a bare host name, without a scheme, port or path, and a wildcard is only supported as a leading \"*.\"")
|
||||
}
|
||||
|
||||
normalized := NormalizeHostname(host)
|
||||
|
||||
if normalized == "" {
|
||||
return "", errors.New("must be a valid host name")
|
||||
}
|
||||
|
||||
if isWildcard {
|
||||
return "*." + normalized, nil
|
||||
}
|
||||
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
// NormalizeHostname returns a host in the form used for comparison: lower-cased, without a
|
||||
// trailing dot, and converted to punycode when it contains non-ASCII labels. Config entries
|
||||
// and request hosts are both normalized this way so that they compare consistently.
|
||||
func NormalizeHostname(host string) string {
|
||||
host = strings.TrimSuffix(strings.TrimSpace(host), ".")
|
||||
|
||||
if ascii, err := idna.Lookup.ToASCII(host); err == nil {
|
||||
host = ascii
|
||||
}
|
||||
|
||||
return strings.ToLower(host)
|
||||
}
|
||||
|
||||
// parseCidrOrIp parses a CIDR or a bare IP address into a *net.IPNet. A bare IP address
|
||||
// becomes a single address CIDR.
|
||||
func parseCidrOrIp(value string) (*net.IPNet, error) {
|
||||
if _, ipNet, err := net.ParseCIDR(value); err == nil {
|
||||
return ipNet, nil
|
||||
}
|
||||
|
||||
if ip := net.ParseIP(value); ip != nil {
|
||||
if ip4 := ip.To4(); ip4 != nil {
|
||||
return &net.IPNet{IP: ip4, Mask: net.CIDRMask(32, 32)}, nil
|
||||
}
|
||||
|
||||
return &net.IPNet{IP: ip.To16(), Mask: net.CIDRMask(128, 128)}, nil
|
||||
}
|
||||
|
||||
return nil, errors.New("must be a CIDR (e.g. 10.0.0.0/8) or an IP address, hostnames are not supported")
|
||||
}
|
||||
|
||||
func LoadEdgeConfigFromMap(configMap map[interface{}]interface{}) (*EdgeConfig, error) {
|
||||
edgeConfig := NewEdgeConfig()
|
||||
|
||||
@@ -748,6 +1080,10 @@ func LoadEdgeConfigFromMap(configMap map[interface{}]interface{}) (*EdgeConfig,
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err = edgeConfig.loadExternalJwtSignersSection(edgeConfigMap); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if v, ok := edgeConfigMap["disablePostureChecks"]; ok {
|
||||
if boolVal, ok := v.(bool); ok {
|
||||
edgeConfig.DisablePostureChecks = boolVal
|
||||
|
||||
@@ -362,6 +362,261 @@ func Test_CalculateCaPems(t *testing.T) {
|
||||
|
||||
}
|
||||
|
||||
func Test_loadExternalJwtSignersSection(t *testing.T) {
|
||||
t.Run("an absent section yields defaults", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
c := NewEdgeConfig()
|
||||
|
||||
req.NoError(c.loadExternalJwtSignersSection(map[any]any{}))
|
||||
|
||||
req.False(c.ExternalJwtSigners.JwksFetch.BlockPrivateAddresses)
|
||||
req.Empty(c.ExternalJwtSigners.JwksFetch.DeniedIPs)
|
||||
req.Empty(c.ExternalJwtSigners.JwksFetch.AllowedIPs)
|
||||
req.Empty(c.ExternalJwtSigners.JwksFetch.DeniedHostnames)
|
||||
req.Empty(c.ExternalJwtSigners.JwksFetch.AllowedHostnames)
|
||||
req.Equal(DefaultJwksFetchTimeout, c.ExternalJwtSigners.JwksFetch.Timeout)
|
||||
req.Equal(DefaultJwksFetchMaxRedirects, c.ExternalJwtSigners.JwksFetch.MaxRedirects)
|
||||
})
|
||||
|
||||
t.Run("an absent jwksFetch sub-section yields defaults", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
c := NewEdgeConfig()
|
||||
|
||||
req.NoError(c.loadExternalJwtSignersSection(map[any]any{
|
||||
"externalJwtSigners": map[any]any{},
|
||||
}))
|
||||
|
||||
req.False(c.ExternalJwtSigners.JwksFetch.BlockPrivateAddresses)
|
||||
req.Equal(DefaultJwksFetchTimeout, c.ExternalJwtSigners.JwksFetch.Timeout)
|
||||
req.Equal(DefaultJwksFetchMaxRedirects, c.ExternalJwtSigners.JwksFetch.MaxRedirects)
|
||||
})
|
||||
|
||||
t.Run("a fully specified section is parsed", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
c := NewEdgeConfig()
|
||||
|
||||
req.NoError(c.loadExternalJwtSignersSection(map[any]any{
|
||||
"externalJwtSigners": map[any]any{
|
||||
"jwksFetch": map[any]any{
|
||||
"blockPrivateAddresses": true,
|
||||
"deniedIPs": []any{"10.0.0.0/8", "203.0.113.5"},
|
||||
"allowedIPs": []any{"192.168.10.0/24", "fd00:1234::1"},
|
||||
"timeout": "12s",
|
||||
"maxRedirects": 2,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
jwksFetch := c.ExternalJwtSigners.JwksFetch
|
||||
|
||||
req.True(jwksFetch.BlockPrivateAddresses)
|
||||
req.Equal(12*time.Second, jwksFetch.Timeout)
|
||||
req.Equal(2, jwksFetch.MaxRedirects)
|
||||
|
||||
req.Len(jwksFetch.DeniedIPs, 2)
|
||||
req.Equal("10.0.0.0/8", jwksFetch.DeniedIPs[0].String())
|
||||
req.Equal("203.0.113.5/32", jwksFetch.DeniedIPs[1].String(), "a bare IPv4 address should be treated as a /32")
|
||||
|
||||
req.Len(jwksFetch.AllowedIPs, 2)
|
||||
req.Equal("192.168.10.0/24", jwksFetch.AllowedIPs[0].String())
|
||||
req.Equal("fd00:1234::1/128", jwksFetch.AllowedIPs[1].String(), "a bare IPv6 address should be treated as a /128")
|
||||
})
|
||||
|
||||
t.Run("host lists are parsed and normalized", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
c := NewEdgeConfig()
|
||||
|
||||
req.NoError(c.loadExternalJwtSignersSection(map[any]any{
|
||||
"externalJwtSigners": map[any]any{
|
||||
"jwksFetch": map[any]any{
|
||||
"deniedHostnames": []any{"Blocked.Example.COM", "internal.example.com."},
|
||||
"allowedHostnames": []any{"idp.example.com", "*.idp.example.org"},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
jwksFetch := c.ExternalJwtSigners.JwksFetch
|
||||
|
||||
req.Equal([]string{"blocked.example.com", "internal.example.com"}, jwksFetch.DeniedHostnames,
|
||||
"host entries should be lower-cased with any trailing dot removed")
|
||||
req.Equal([]string{"idp.example.com", "*.idp.example.org"}, jwksFetch.AllowedHostnames)
|
||||
})
|
||||
|
||||
t.Run("a host list entry that is an IP address is an error", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
c := NewEdgeConfig()
|
||||
|
||||
err := c.loadExternalJwtSignersSection(map[any]any{
|
||||
"externalJwtSigners": map[any]any{
|
||||
"jwksFetch": map[any]any{
|
||||
"deniedHostnames": []any{"10.0.0.5"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
req.Error(err, "an IP address in a host list would be a name comparison, not an address check")
|
||||
req.Contains(err.Error(), "deniedIPs")
|
||||
})
|
||||
|
||||
t.Run("a host list entry with a scheme, port or path is an error", func(t *testing.T) {
|
||||
entries := []string{"https://idp.example.com", "idp.example.com:443", "idp.example.com/jwks", "idp.*.example.com", "*.", ""}
|
||||
|
||||
for _, entry := range entries {
|
||||
t.Run(entry, func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
c := NewEdgeConfig()
|
||||
|
||||
err := c.loadExternalJwtSignersSection(map[any]any{
|
||||
"externalJwtSigners": map[any]any{
|
||||
"jwksFetch": map[any]any{
|
||||
"allowedHostnames": []any{entry},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
req.Error(err)
|
||||
req.Contains(err.Error(), "allowedHostnames")
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("blockPrivateAddresses accepts a string boolean", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
c := NewEdgeConfig()
|
||||
|
||||
req.NoError(c.loadExternalJwtSignersSection(map[any]any{
|
||||
"externalJwtSigners": map[any]any{
|
||||
"jwksFetch": map[any]any{
|
||||
"blockPrivateAddresses": "true",
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
req.True(c.ExternalJwtSigners.JwksFetch.BlockPrivateAddresses)
|
||||
})
|
||||
|
||||
t.Run("maxRedirects of zero is allowed and disables redirects", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
c := NewEdgeConfig()
|
||||
|
||||
req.NoError(c.loadExternalJwtSignersSection(map[any]any{
|
||||
"externalJwtSigners": map[any]any{
|
||||
"jwksFetch": map[any]any{
|
||||
"maxRedirects": 0,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
req.Equal(0, c.ExternalJwtSigners.JwksFetch.MaxRedirects)
|
||||
})
|
||||
|
||||
t.Run("an invalid CIDR is an error", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
c := NewEdgeConfig()
|
||||
|
||||
err := c.loadExternalJwtSignersSection(map[any]any{
|
||||
"externalJwtSigners": map[any]any{
|
||||
"jwksFetch": map[any]any{
|
||||
"deniedIPs": []any{"not-an-address"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
req.Error(err)
|
||||
req.Contains(err.Error(), "deniedIPs")
|
||||
})
|
||||
|
||||
t.Run("a hostname entry is an error", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
c := NewEdgeConfig()
|
||||
|
||||
err := c.loadExternalJwtSignersSection(map[any]any{
|
||||
"externalJwtSigners": map[any]any{
|
||||
"jwksFetch": map[any]any{
|
||||
"allowedIPs": []any{"idp.example.com"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
req.Error(err, "hostnames are not valid entries, they cannot be enforced at dial time")
|
||||
req.Contains(err.Error(), "allowedIPs")
|
||||
})
|
||||
|
||||
t.Run("a non-list address value is an error", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
c := NewEdgeConfig()
|
||||
|
||||
err := c.loadExternalJwtSignersSection(map[any]any{
|
||||
"externalJwtSigners": map[any]any{
|
||||
"jwksFetch": map[any]any{
|
||||
"deniedIPs": "10.0.0.0/8",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
req.Error(err)
|
||||
})
|
||||
|
||||
t.Run("an invalid duration is an error", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
c := NewEdgeConfig()
|
||||
|
||||
err := c.loadExternalJwtSignersSection(map[any]any{
|
||||
"externalJwtSigners": map[any]any{
|
||||
"jwksFetch": map[any]any{
|
||||
"timeout": "not-a-duration",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
req.Error(err)
|
||||
})
|
||||
|
||||
t.Run("a non-positive timeout is an error", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
c := NewEdgeConfig()
|
||||
|
||||
err := c.loadExternalJwtSignersSection(map[any]any{
|
||||
"externalJwtSigners": map[any]any{
|
||||
"jwksFetch": map[any]any{
|
||||
"timeout": "0s",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
req.Error(err, "an unbounded fetch is exactly what this section exists to prevent")
|
||||
})
|
||||
|
||||
t.Run("a negative maxRedirects is an error", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
c := NewEdgeConfig()
|
||||
|
||||
err := c.loadExternalJwtSignersSection(map[any]any{
|
||||
"externalJwtSigners": map[any]any{
|
||||
"jwksFetch": map[any]any{
|
||||
"maxRedirects": -1,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
req.Error(err)
|
||||
})
|
||||
}
|
||||
|
||||
func newSelfSignedCert(commonName string, isCas bool) (*x509.Certificate, crypto.PrivateKey) {
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
|
||||
@@ -46,14 +46,22 @@ const (
|
||||
)
|
||||
|
||||
type NetworkConfig struct {
|
||||
CreateCircuitRetries uint32
|
||||
CycleSeconds uint32
|
||||
InitialLinkLatency time.Duration
|
||||
IntervalAgeThreshold time.Duration
|
||||
MetricsReportInterval time.Duration
|
||||
MinRouterCost uint16
|
||||
PendingLinkTimeout time.Duration
|
||||
RouteTimeout time.Duration
|
||||
CreateCircuitRetries uint32
|
||||
CycleSeconds uint32
|
||||
InitialLinkLatency time.Duration
|
||||
IntervalAgeThreshold time.Duration
|
||||
MetricsReportInterval time.Duration
|
||||
MinRouterCost uint16
|
||||
PendingLinkTimeout time.Duration
|
||||
RouteTimeout time.Duration
|
||||
// RouterConnectChurnLimit is how long an established router control channel is protected from being
|
||||
// displaced by a new connection for the same router. A new connection arriving inside the window is
|
||||
// refused; after it, the established connection is displaced and the router redials into the freed
|
||||
// slot. Zero always allows takeover.
|
||||
//
|
||||
// This is churn policy, not the guarantee that a router has one connection: that is enforced under the
|
||||
// per-router lock in Network.ConnectRouter. Its purpose is to stop a flapping router from repeatedly
|
||||
// tearing down a working channel, since displacement is not free.
|
||||
RouterConnectChurnLimit time.Duration
|
||||
RouterComm struct {
|
||||
QueueSize uint32
|
||||
|
||||
@@ -33,6 +33,7 @@ import (
|
||||
"github.com/openziti/channel/v4"
|
||||
"github.com/openziti/channel/v4/protobufs"
|
||||
"github.com/openziti/foundation/v2/concurrenz"
|
||||
nfpem "github.com/openziti/foundation/v2/pem"
|
||||
"github.com/openziti/foundation/v2/versions"
|
||||
"github.com/openziti/identity"
|
||||
"github.com/openziti/metrics"
|
||||
@@ -68,6 +69,7 @@ import (
|
||||
"github.com/pkg/errors"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/teris-io/shortid"
|
||||
"go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
type Controller struct {
|
||||
@@ -311,8 +313,15 @@ func NewController(cfg *config.Config, versionProvider versions.VersionProvider)
|
||||
|
||||
c.initWeb() // need to init web before bootstrapping, so we can provide our endpoints to peers
|
||||
|
||||
if c.raftController != nil && !c.raftController.IsBootstrapped() {
|
||||
if err = c.TryInitializeRaftFromBoltDb(); err != nil {
|
||||
if c.raftController != nil {
|
||||
_, dbConfigured := c.config.Src["db"]
|
||||
if c.raftController.IsBootstrapped() {
|
||||
// On a clustered config 'db' only seeds a new cluster on first bootstrap; once
|
||||
// initialized it is dead config, so warn rather than let a stale setting sit unnoticed.
|
||||
if dbConfigured {
|
||||
log.Warn("'db' is set but this clustered controller is already initialized; the 'db' setting is ignored and should be removed from the configuration")
|
||||
}
|
||||
} else if err = c.TryInitializeRaftFromBoltDb(); err != nil {
|
||||
log.WithError(err).Panic("error bootstrapping raft")
|
||||
}
|
||||
}
|
||||
@@ -537,9 +546,6 @@ func (c *Controller) Run() error {
|
||||
pfxlog.Logger().Infof("staring control channel listener on %s", c.config.Ctrl.Listener.String())
|
||||
ctrlListener := channel.NewClassicListener(c.config.Id, c.config.Ctrl.Listener, ctrlChannelListenerConfig)
|
||||
c.ctrlListener = ctrlListener
|
||||
if err := c.ctrlListener.Listen(c.ctrlConnectHandler); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
ctrlAccepter := handler_ctrl.NewCtrlAccepter(c.network, c.xctrls, c.config.Ctrl.Options.Options, c.config.Ctrl.Options.RouterHeartbeatOptions, c.config.Trace.Handler)
|
||||
|
||||
@@ -549,6 +555,19 @@ func (c *Controller) Run() error {
|
||||
ctrlAcceptors[mesh.ChannelTypeMesh] = c.raftController.GetMesh()
|
||||
}
|
||||
|
||||
// Channel types routed to a dedicated acceptor above validate their own peers; the connect handler
|
||||
// must skip exactly those and validate everything else (which the dispatcher routes to the default
|
||||
// router control acceptor, including unrecognized types). Set this before accepting connections.
|
||||
separatelyValidatedTypes := map[string]struct{}{}
|
||||
for chType := range ctrlAcceptors {
|
||||
separatelyValidatedTypes[chType] = struct{}{}
|
||||
}
|
||||
c.ctrlConnectHandler.SetSeparatelyValidatedChannelTypes(separatelyValidatedTypes)
|
||||
|
||||
if err := c.ctrlListener.Listen(c.ctrlConnectHandler); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
underlayDispatcher := channel.NewUnderlayDispatcher(channel.UnderlayDispatcherConfig{
|
||||
Listener: ctrlListener,
|
||||
ConnectTimeout: c.config.Ctrl.Options.ConnectTimeout,
|
||||
@@ -699,11 +718,21 @@ func (c *Controller) registerXts() {
|
||||
}
|
||||
|
||||
func (c *Controller) registerComponents() error {
|
||||
c.ctrlConnectHandler = handler_ctrl.NewConnectHandler(c.config.Id, c.network)
|
||||
c.ctrlConnectHandler = handler_ctrl.NewConnectHandler(c.config.Id, c.network, c.signingCertRoots())
|
||||
c.eventDispatcher.AddClusterEventHandler(event.ClusterEventHandlerF(c.routerDispatchCallback))
|
||||
return nil
|
||||
}
|
||||
|
||||
// signingCertRoots returns the trust anchors from the edge enrollment signing CA bundle, which issues the
|
||||
// certificates routers present on the control channel. It is empty when no signing CA bundle is
|
||||
// configured, in which case a router is trusted only through the controller's own CA bundle.
|
||||
func (c *Controller) signingCertRoots() []*x509.Certificate {
|
||||
if c.config.Edge == nil {
|
||||
return nil
|
||||
}
|
||||
return nfpem.PemBytesToCertificates(c.config.Edge.Enrollment.SigningCertCaPem)
|
||||
}
|
||||
|
||||
func (c *Controller) RegisterXctrl(x xctrl.Xctrl) error {
|
||||
if err := c.config.Configure(x); err != nil {
|
||||
return err
|
||||
@@ -842,6 +871,32 @@ func (c *Controller) InitializeRaftFromBoltDb(sourceDbPath string) error {
|
||||
return c.RaftRestoreFromBoltDb(sourceDbPath)
|
||||
}
|
||||
|
||||
// validateMigrationSourceDb rejects a migration source that is not an initialized controller db.
|
||||
// db.Open creates the root bucket on any file, so existence is not enough; it checks for a default
|
||||
// admin identity, which an initialized controller always has. The check is read-only.
|
||||
func validateMigrationSourceDb(sourceDb boltz.Db) error {
|
||||
hasDefaultAdmin := false
|
||||
err := sourceDb.View(func(tx *bbolt.Tx) error {
|
||||
identities := boltz.Path(tx, db.RootBucket, db.EntityTypeIdentities)
|
||||
if identities == nil {
|
||||
return nil
|
||||
}
|
||||
return identities.ForEachTypedBucket(func(_ string, identity *boltz.TypedBucket) error {
|
||||
if identity.GetBoolWithDefault(db.FieldIdentityIsDefaultAdmin, false) {
|
||||
hasDefaultAdmin = true
|
||||
}
|
||||
return nil
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "unable to read identities from source db")
|
||||
}
|
||||
if !hasDefaultAdmin {
|
||||
return errors.New("source db has no default admin identity; it is empty or was never a fully initialized controller")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Controller) RaftRestoreFromBoltDb(sourceDbPath string) error {
|
||||
log := pfxlog.Logger()
|
||||
|
||||
@@ -866,6 +921,10 @@ func (c *Controller) RaftRestoreFromBoltDb(sourceDbPath string) error {
|
||||
}
|
||||
}()
|
||||
|
||||
if err = validateMigrationSourceDb(sourceDb); err != nil {
|
||||
return errors.Wrapf(err, "migration source db [%v] is not a valid initialized controller database", sourceDbPath)
|
||||
}
|
||||
|
||||
timelineId, err := sourceDb.GetTimelineId(boltz.TimelineModeForceReset, shortid.Generate)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -895,6 +954,13 @@ func (c *Controller) RaftRestoreFromBoltDb(sourceDbPath string) error {
|
||||
return fmt.Errorf("unable to bootstrap cluster (%w)", err)
|
||||
}
|
||||
|
||||
// Carry the cluster id Bootstrap established so RestoreSnapshot can write it back after the
|
||||
// restore (the migration source has none). Blank here means a bug in Bootstrap, so fail.
|
||||
cmd.ClusterId = c.raftController.GetClusterId()
|
||||
if cmd.ClusterId == "" {
|
||||
return errors.New("cluster id is blank after bootstrap; refusing to restore without a durable cluster id")
|
||||
}
|
||||
|
||||
return c.raftController.Dispatch(cmd)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
Copyright NetFoundry Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/openziti/ziti/v2/controller/db"
|
||||
"github.com/openziti/ziti/v2/controller/storage/boltz"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestValidateMigrationSourceDb(t *testing.T) {
|
||||
t.Run("rejects a db with no identities", func(t *testing.T) {
|
||||
// db.Open creates the root bucket but no identities, mimicking an empty or stray file.
|
||||
sourceDb, err := db.Open(filepath.Join(t.TempDir(), "empty.db"))
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = sourceDb.Close() }()
|
||||
|
||||
require.Error(t, validateMigrationSourceDb(sourceDb))
|
||||
})
|
||||
|
||||
t.Run("rejects a db whose identities include no default admin", func(t *testing.T) {
|
||||
sourceDb, err := db.Open(filepath.Join(t.TempDir(), "no-admin.db"))
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = sourceDb.Close() }()
|
||||
|
||||
require.NoError(t, addIdentity(sourceDb, "regular-identity", false))
|
||||
require.Error(t, validateMigrationSourceDb(sourceDb))
|
||||
})
|
||||
|
||||
t.Run("accepts a db that has a default admin identity", func(t *testing.T) {
|
||||
sourceDb, err := db.Open(filepath.Join(t.TempDir(), "populated.db"))
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = sourceDb.Close() }()
|
||||
|
||||
require.NoError(t, addIdentity(sourceDb, "regular-identity", false))
|
||||
require.NoError(t, addIdentity(sourceDb, "admin-identity", true))
|
||||
require.NoError(t, validateMigrationSourceDb(sourceDb))
|
||||
})
|
||||
}
|
||||
|
||||
// addIdentity writes an identity bucket with the isDefaultAdmin field set, using boltz so the
|
||||
// stored value is read back the same way the controller reads it.
|
||||
func addIdentity(sourceDb boltz.Db, id string, isDefaultAdmin bool) error {
|
||||
return sourceDb.Update(nil, func(ctx boltz.MutateContext) error {
|
||||
idBucket := boltz.GetOrCreatePath(ctx.Tx(), db.RootBucket, db.EntityTypeIdentities, id)
|
||||
idBucket.SetBool(db.FieldIdentityIsDefaultAdmin, isDefaultAdmin, nil)
|
||||
return idBucket.GetError()
|
||||
})
|
||||
}
|
||||
+15
-7
@@ -17,8 +17,7 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/michaelquigley/pfxlog"
|
||||
"github.com/openziti/ziti/v2/controller/storage/boltz"
|
||||
"go.etcd.io/bbolt"
|
||||
)
|
||||
@@ -74,20 +73,29 @@ func LoadClusterId(db boltz.Db) (string, error) {
|
||||
return result, err
|
||||
}
|
||||
|
||||
func InitClusterId(db boltz.Db, ctx boltz.MutateContext, clusterId string) error {
|
||||
return db.Update(ctx, func(ctx boltz.MutateContext) error {
|
||||
// InitClusterId sets the cluster id if unset and returns the effective id (an existing id wins). It
|
||||
// is set-once: a differing id is kept with a warning rather than an error, so redundant or racing
|
||||
// writes (e.g. backfill across leadership changes) cannot fail.
|
||||
func InitClusterId(db boltz.Db, ctx boltz.MutateContext, clusterId string) (string, error) {
|
||||
effective := clusterId
|
||||
err := db.Update(ctx, func(ctx boltz.MutateContext) error {
|
||||
raftBucket := boltz.GetOrCreatePath(ctx.Tx(), RootBucket, MetadataBucket)
|
||||
if raftBucket.HasError() {
|
||||
return raftBucket.Err
|
||||
}
|
||||
currentId := raftBucket.GetStringWithDefault(FieldClusterId, "")
|
||||
if currentId != "" {
|
||||
if currentId == clusterId {
|
||||
return nil
|
||||
effective = currentId
|
||||
if currentId != clusterId {
|
||||
pfxlog.Logger().
|
||||
WithField("existingClusterId", currentId).
|
||||
WithField("ignoredClusterId", clusterId).
|
||||
Warn("cluster id already set; keeping existing value and ignoring the new one")
|
||||
}
|
||||
return fmt.Errorf("cluster id already initialized to %s", currentId)
|
||||
return nil
|
||||
}
|
||||
raftBucket.SetString(FieldClusterId, clusterId, nil)
|
||||
return raftBucket.Err
|
||||
})
|
||||
return effective, err
|
||||
}
|
||||
|
||||
@@ -45,6 +45,8 @@ func (m *Migrations) initialize(step *boltz.MigrationStep) int {
|
||||
m.addSystemAuthPolicies(step)
|
||||
m.createConfigType(step, interfacesConfigTypeV1)
|
||||
m.createConfigType(step, proxyConfigTypeV1)
|
||||
m.createConfigType(step, l2HostV1ConfigType)
|
||||
m.createConfigType(step, l2InterceptV1ConfigType)
|
||||
|
||||
return CurrentDbVersion
|
||||
}
|
||||
@@ -321,6 +323,65 @@ var tunnelDefinitions = map[string]interface{}{
|
||||
},
|
||||
}
|
||||
|
||||
var listenOptions = map[string]interface{}{
|
||||
"listenOptions": map[string]interface{}{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": map[string]interface{}{
|
||||
"connectTimeoutSeconds": map[string]interface{}{
|
||||
"$ref": "#/definitions/timeoutSeconds",
|
||||
"description": "Timeout when making outbound connections. Defaults to 5. If both connectTimeoutSeconds and connectTimeout are specified, connectTimeout will be used.",
|
||||
"deprecated": true,
|
||||
},
|
||||
"connectTimeout": map[string]interface{}{
|
||||
"$ref": "#/definitions/duration",
|
||||
"description": "Timeout when making outbound connections. Defaults to '5s'. If both connectTimeoutSeconds and connectTimeout are specified, connectTimeout will be used.",
|
||||
},
|
||||
"maxConnections": map[string]interface{}{
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "defaults to 3",
|
||||
},
|
||||
"identity": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "Associate the hosting terminator with the specified identity. '$tunneler_id.name' resolves to the name of the hosting tunneler's identity. '$tunneler_id.tag[tagName]' resolves to the value of the 'tagName' tag on the hosting tunneler's identity.",
|
||||
},
|
||||
"bindUsingEdgeIdentity": map[string]interface{}{
|
||||
"type": "boolean",
|
||||
"description": "Associate the hosting terminator with the name of the hosting tunneler's identity. Setting this to 'true' is equivalent to setting 'identiy=$tunneler_id.name'",
|
||||
},
|
||||
"cost": map[string]interface{}{
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 65535,
|
||||
"description": "defaults to 0",
|
||||
},
|
||||
"precedence": map[string]interface{}{
|
||||
"type": "string",
|
||||
"enum": []interface{}{"default", "required", "failed"},
|
||||
"description": "defaults to 'default'",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var dialOptions = map[string]interface{}{
|
||||
"dialOptions": map[string]interface{}{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": map[string]interface{}{
|
||||
"identity": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "Dial a terminator with the specified identity. '$dst_protocol', '$dst_ip', '$dst_port are resolved to the corresponding value of the destination address.",
|
||||
},
|
||||
"connectTimeoutSeconds": map[string]interface{}{
|
||||
"$ref": "#/definitions/timeoutSeconds",
|
||||
"description": "defaults to 5 seconds if no dialOptions are defined. defaults to 15 if dialOptions are defined but connectTimeoutSeconds is not specified.",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// hostV1 schema with ["$id"] and ["definitions"] excluded
|
||||
var hostV1SchemaSansDefs = map[string]interface{}{
|
||||
"type": "object",
|
||||
@@ -388,50 +449,12 @@ var hostV1SchemaSansDefs = map[string]interface{}{
|
||||
},
|
||||
"description": "hosting tunnelers establish local routes for the specified source addresses so binding will succeed",
|
||||
},
|
||||
"listenOptions": map[string]interface{}{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": map[string]interface{}{
|
||||
"connectTimeoutSeconds": map[string]interface{}{
|
||||
"$ref": "#/definitions/timeoutSeconds",
|
||||
"description": "Timeout when making outbound connections. Defaults to 5. If both connectTimoutSeconds and connectTimeout are specified, connectTimeout will be used.",
|
||||
"deprecated": true,
|
||||
},
|
||||
"connectTimeout": map[string]interface{}{
|
||||
"$ref": "#/definitions/duration",
|
||||
"description": "Timeout when making outbound connections. Defaults to '5s'. If both connectTimoutSeconds and connectTimeout are specified, connectTimeout will be used.",
|
||||
},
|
||||
"maxConnections": map[string]interface{}{
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "defaults to 3",
|
||||
},
|
||||
"identity": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "Associate the hosting terminator with the specified identity. '$tunneler_id.name' resolves to the name of the hosting tunneler's identity. '$tunneler_id.tag[tagName]' resolves to the value of the 'tagName' tag on the hosting tunneler's identity.",
|
||||
},
|
||||
"bindUsingEdgeIdentity": map[string]interface{}{
|
||||
"type": "boolean",
|
||||
"description": "Associate the hosting terminator with the name of the hosting tunneler's identity. Setting this to 'true' is equivalent to setting 'identiy=$tunneler_id.name'",
|
||||
},
|
||||
"cost": map[string]interface{}{
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 65535,
|
||||
"description": "defaults to 0",
|
||||
},
|
||||
"precedence": map[string]interface{}{
|
||||
"type": "string",
|
||||
"enum": []interface{}{"default", "required", "failed"},
|
||||
"description": "defaults to 'default'",
|
||||
},
|
||||
},
|
||||
},
|
||||
"proxy": map[string]interface{}{
|
||||
"$ref": "#/definitions/proxyConfiguration",
|
||||
"description": "If defined, outgoing connections will be send through this proxy server",
|
||||
},
|
||||
},
|
||||
listenOptions,
|
||||
),
|
||||
"additionalProperties": false,
|
||||
"allOf": []interface{}{
|
||||
@@ -532,7 +555,7 @@ var interceptV1ConfigType = &ConfigType{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"definitions": tunnelDefinitions,
|
||||
"properties": map[string]interface{}{
|
||||
"properties": combine(dialOptions, map[string]interface{}{
|
||||
"protocols": map[string]interface{}{
|
||||
"allOf": []interface{}{
|
||||
map[string]interface{}{"$ref": "#/definitions/inhabitedSet"},
|
||||
@@ -551,20 +574,6 @@ var interceptV1ConfigType = &ConfigType{
|
||||
map[string]interface{}{"items": map[string]interface{}{"$ref": "#/definitions/portRange"}},
|
||||
},
|
||||
},
|
||||
"dialOptions": map[string]interface{}{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": map[string]interface{}{
|
||||
"identity": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "Dial a terminator with the specified identity. '$dst_protocol', '$dst_ip', '$dst_port are resolved to the corresponding value of the destination address.",
|
||||
},
|
||||
"connectTimeoutSeconds": map[string]interface{}{
|
||||
"$ref": "#/definitions/timeoutSeconds",
|
||||
"description": "defaults to 5 seconds if no dialOptions are defined. defaults to 15 if dialOptions are defined but connectTimeoutSeconds is not specified.",
|
||||
},
|
||||
},
|
||||
},
|
||||
"sourceIp": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "The source IP (and optional :port) to spoof when the connection is egressed from the hosting tunneler. '$tunneler_id.name' resolves to the name of the client tunneler's identity. '$tunneler_id.tag[tagName]' resolves to the value of the 'tagName' tag on the client tunneler's identity. '$src_ip' and '$src_port' resolve to the source IP / port of the originating client. '$dst_port' resolves to the port that the client is trying to connect.",
|
||||
@@ -576,7 +585,7 @@ var interceptV1ConfigType = &ConfigType{
|
||||
},
|
||||
"description": "white list of source ips/cidrs that can be intercepted. all ips can be intercepted if this is not set.",
|
||||
},
|
||||
},
|
||||
}),
|
||||
"required": []interface{}{
|
||||
"protocols",
|
||||
"addresses",
|
||||
@@ -585,6 +594,54 @@ var interceptV1ConfigType = &ConfigType{
|
||||
},
|
||||
}
|
||||
|
||||
var l2HostV1ConfigType = &ConfigType{
|
||||
BaseExtEntity: boltz.BaseExtEntity{Id: "l2.host.v1"},
|
||||
Name: "l2.host.v1",
|
||||
Schema: map[string]interface{}{
|
||||
"$id": "https://ziti-edge.netfoundry.io/schemas/l2.host.v1.schema.json",
|
||||
"definitions": combine(healthCheckSchema["definitions"].(map[string]interface{}), tunnelDefinitions),
|
||||
"type": "object",
|
||||
"properties": combine(listenOptions, map[string]interface{}{
|
||||
"bridgeIfs": map[string]interface{}{
|
||||
"allOf": []interface{}{
|
||||
map[string]interface{}{"$ref": "#/definitions/inhabitedSet"},
|
||||
map[string]interface{}{"items": map[string]interface{}{"type": "string"}},
|
||||
},
|
||||
"description": "Bridge the provided network interfaces with the tunneler's tap interface.",
|
||||
},
|
||||
}),
|
||||
"additionalProperties": false,
|
||||
},
|
||||
}
|
||||
|
||||
var l2InterceptV1ConfigType = &ConfigType{
|
||||
BaseExtEntity: boltz.BaseExtEntity{Id: "l2.intercept.v1"},
|
||||
Name: "l2.intercept.v1",
|
||||
Schema: map[string]interface{}{
|
||||
"$id": "https://ziti-edge.netfoundry.io/schemas/l2.intercept.v1.schema.json",
|
||||
"definitions": combine(tunnelDefinitions, map[string]interface{}{
|
||||
"ethType": map[string]interface{}{
|
||||
"type": "string",
|
||||
"pattern": "^0[xX][0-9a-fA-F]{4}$",
|
||||
},
|
||||
}),
|
||||
"type": "object",
|
||||
"properties": combine(dialOptions, map[string]interface{}{
|
||||
"ethTypes": map[string]interface{}{
|
||||
"allOf": []interface{}{
|
||||
map[string]interface{}{"$ref": "#/definitions/inhabitedSet"},
|
||||
map[string]interface{}{"items": map[string]interface{}{"$ref": "#/definitions/ethType"}},
|
||||
},
|
||||
"description": "list of EtherTypes to forward. frames with an EtherType that is not in this list will be dropped.",
|
||||
},
|
||||
}),
|
||||
"required": []interface{}{
|
||||
"ethTypes",
|
||||
},
|
||||
"additionalProperties": false,
|
||||
},
|
||||
}
|
||||
|
||||
var InterfacesV1TypeId = "interfaces.v1"
|
||||
|
||||
var interfacesConfigTypeV1 = &ConfigType{
|
||||
|
||||
@@ -41,7 +41,21 @@ func RunMigrations(db boltz.Db, stores *Stores, signingCert *x509.Certificate) e
|
||||
}
|
||||
|
||||
mm := boltz.NewMigratorManager(db)
|
||||
return mm.Migrate("edge", CurrentDbVersion, migrations.migrate)
|
||||
if err := mm.Migrate("edge", CurrentDbVersion, migrations.migrate); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// l2 config types are needed in 2.0.x, but we don't want to increment the db version to add them,
|
||||
// since doing so risks colliding with the db version range used by other branches. Since bumping
|
||||
// the db version is what would normally trigger the migrator to invoke migrate() again, we instead
|
||||
// ensure these config types exist on every startup, independent of the stored db version.
|
||||
return db.Update(nil, func(ctx boltz.MutateContext) error {
|
||||
step := &boltz.MigrationStep{Component: "edge", Ctx: ctx}
|
||||
for _, cfgType := range []*ConfigType{l2HostV1ConfigType, l2InterceptV1ConfigType} {
|
||||
migrations.createConfigType(step, cfgType)
|
||||
}
|
||||
return step.GetError()
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Migrations) migrate(step *boltz.MigrationStep) int {
|
||||
|
||||
@@ -24,6 +24,35 @@ func Test_ServicePolicyStore(t *testing.T) {
|
||||
t.Run("test service policy evaluation", ctx.testServicePolicyRoleEvaluation)
|
||||
t.Run("test update/delete referenced entities", ctx.testServicePolicyUpdateDeleteRefs)
|
||||
t.Run("test filter service policies by type name", ctx.testServicePolicyFilterByTypeName)
|
||||
t.Run("test service policy enforcer type query", ctx.testServicePolicyEnforcerTypeQuery)
|
||||
}
|
||||
|
||||
// testServicePolicyEnforcerTypeQuery guards the predicate the ServicePolicyEnforcer uses to decide
|
||||
// whether a session's identity still has a covering policy. The policy type symbol is string-mapped
|
||||
// ("Dial"/"Bind"), so the predicate must filter on the string form; filtering on the numeric id
|
||||
// (type = 1) matches nothing against the string symbol, which previously made the enforcer delete
|
||||
// valid legacy sessions on startup.
|
||||
func (ctx *TestContext) testServicePolicyEnforcerTypeQuery(_ *testing.T) {
|
||||
ctx.CleanupAll()
|
||||
|
||||
dialer := ctx.RequireNewIdentity(eid.New(), false)
|
||||
service := newEdgeService(eid.New())
|
||||
boltztest.RequireCreate(ctx, service)
|
||||
ctx.requireNewServicePolicy(PolicyTypeDial, ss(entityRef(dialer.Id)), ss(entityRef(service.Id)))
|
||||
|
||||
ctx.NoError(ctx.GetDb().View(func(tx *bbolt.Tx) error {
|
||||
matched, _, err := ctx.stores.Identity.QueryIds(tx, fmt.Sprintf(
|
||||
`id = "%v" and not isEmpty(from servicePolicies where type = "%v" and anyOf(services) = "%v")`,
|
||||
dialer.Id, PolicyTypeDial.String(), service.Id))
|
||||
ctx.NoError(err)
|
||||
ctx.Contains(matched, dialer.Id, "string policy-type predicate must match the covering Dial policy")
|
||||
|
||||
numeric, _, _ := ctx.stores.Identity.QueryIds(tx, fmt.Sprintf(
|
||||
`id = "%v" and not isEmpty(from servicePolicies where type = %v and anyOf(services) = "%v")`,
|
||||
dialer.Id, PolicyTypeDial.Id(), service.Id))
|
||||
ctx.NotContains(numeric, dialer.Id, "numeric policy-type predicate must not match the string-mapped symbol")
|
||||
return nil
|
||||
}))
|
||||
}
|
||||
|
||||
func newServicePolicy(name string) *ServicePolicy {
|
||||
|
||||
Vendored
+29
-10
@@ -50,13 +50,13 @@ import (
|
||||
"github.com/openziti/identity"
|
||||
"github.com/openziti/metrics"
|
||||
"github.com/openziti/sdk-golang/ziti"
|
||||
"github.com/openziti/ziti/v2/controller/storage/boltz"
|
||||
"github.com/openziti/xweb/v3"
|
||||
"github.com/openziti/ziti/v2/common"
|
||||
"github.com/openziti/ziti/v2/common/cert"
|
||||
"github.com/openziti/ziti/v2/common/eid"
|
||||
"github.com/openziti/ziti/v2/common/pb/edge_ctrl_pb"
|
||||
"github.com/openziti/ziti/v2/controller/api"
|
||||
"github.com/openziti/ziti/v2/controller/apierror"
|
||||
"github.com/openziti/ziti/v2/controller/command"
|
||||
"github.com/openziti/ziti/v2/controller/config"
|
||||
"github.com/openziti/ziti/v2/controller/db"
|
||||
@@ -64,6 +64,7 @@ import (
|
||||
"github.com/openziti/ziti/v2/controller/events"
|
||||
"github.com/openziti/ziti/v2/controller/jwtsigner"
|
||||
"github.com/openziti/ziti/v2/controller/model"
|
||||
"github.com/openziti/ziti/v2/controller/storage/boltz"
|
||||
|
||||
"github.com/openziti/ziti/v2/controller/network"
|
||||
"github.com/openziti/ziti/v2/controller/permissions"
|
||||
@@ -254,19 +255,19 @@ func (ae *AppEnv) ValidateServiceAccessToken(token string, apiSessionId *string)
|
||||
parsedToken, err := jwt.ParseWithClaims(token, serviceAccessClaims, ae.JwtSignerKeyFunc)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, &common.InvalidTokenError{Err: err}
|
||||
}
|
||||
|
||||
if !parsedToken.Valid {
|
||||
return nil, errors.New("service access token is invalid")
|
||||
return nil, &common.InvalidTokenError{Err: errors.New("service access token is invalid")}
|
||||
}
|
||||
|
||||
if !serviceAccessClaims.HasAudience(common.ClaimAudienceOpenZiti) && !serviceAccessClaims.HasAudience(common.ClaimLegacyNative) {
|
||||
return nil, fmt.Errorf("invalid audience, expected an instance of %s or %s, got %v", common.ClaimAudienceOpenZiti, common.ClaimLegacyNative, serviceAccessClaims.Audience)
|
||||
return nil, &common.InvalidTokenError{Err: fmt.Errorf("invalid audience, expected an instance of %s or %s, got %v", common.ClaimAudienceOpenZiti, common.ClaimLegacyNative, serviceAccessClaims.Audience)}
|
||||
}
|
||||
|
||||
if serviceAccessClaims.TokenType != common.TokenTypeServiceAccess {
|
||||
return nil, fmt.Errorf("invalid token type, expected %s, got %s", common.TokenTypeServiceAccess, serviceAccessClaims.Type)
|
||||
return nil, &common.InvalidTokenError{Err: fmt.Errorf("invalid token type, expected %s, got %s", common.TokenTypeServiceAccess, serviceAccessClaims.Type)}
|
||||
}
|
||||
|
||||
if apiSessionId != nil {
|
||||
@@ -275,10 +276,12 @@ func (ae *AppEnv) ValidateServiceAccessToken(token string, apiSessionId *string)
|
||||
}
|
||||
|
||||
if serviceAccessClaims.ApiSessionId != *apiSessionId {
|
||||
return nil, fmt.Errorf("invalid api session id, expected %s, got %s", *apiSessionId, serviceAccessClaims.ApiSessionId)
|
||||
return nil, &common.InvalidTokenError{Err: fmt.Errorf("invalid api session id, expected %s, got %s", *apiSessionId, serviceAccessClaims.ApiSessionId)}
|
||||
}
|
||||
}
|
||||
|
||||
// Revocation.Read failures below are infrastructure errors and are returned raw (not wrapped as
|
||||
// InvalidTokenError), so a transient datastore failure does not make callers discard valid sessions.
|
||||
tokenRevocation, err := ae.GetManagers().Revocation.Read(serviceAccessClaims.ID)
|
||||
|
||||
if err != nil && !boltz.IsErrNotFoundErr(err) {
|
||||
@@ -286,7 +289,7 @@ func (ae *AppEnv) ValidateServiceAccessToken(token string, apiSessionId *string)
|
||||
}
|
||||
|
||||
if tokenRevocation != nil {
|
||||
return nil, errors.New("service access token has been revoked by id")
|
||||
return nil, &common.InvalidTokenError{Err: errors.New("service access token has been revoked by id")}
|
||||
}
|
||||
|
||||
revocation, err := ae.GetManagers().Revocation.Read(serviceAccessClaims.IdentityId)
|
||||
@@ -296,7 +299,7 @@ func (ae *AppEnv) ValidateServiceAccessToken(token string, apiSessionId *string)
|
||||
}
|
||||
|
||||
if revocation != nil && revocation.CreatedAt.After(serviceAccessClaims.IssuedAt.Time) {
|
||||
return nil, errors.New("service access token has been revoked by identity")
|
||||
return nil, &common.InvalidTokenError{Err: errors.New("service access token has been revoked by identity")}
|
||||
}
|
||||
|
||||
return serviceAccessClaims, nil
|
||||
@@ -879,11 +882,27 @@ func (ae *AppEnv) GetControllerPublicKey(kid string) crypto.PublicKey {
|
||||
return signers[kid]
|
||||
}
|
||||
|
||||
// CreateRequestContext creates a new request context for handling HTTP requests.
|
||||
// CreateRequestContext creates a new request context for handling HTTP requests. The request body
|
||||
// is buffered into memory before any authentication check, so bodies larger than
|
||||
// api.MaxRequestBodySize are rejected with a 413 ApiError instead of being buffered.
|
||||
func (ae *AppEnv) CreateRequestContext(rw http.ResponseWriter, r *http.Request) (*response.RequestContext, error) {
|
||||
rid := eid.New()
|
||||
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
if r.ContentLength > api.MaxRequestBodySize {
|
||||
return nil, apierror.NewRequestEntityTooLarge()
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(rw, r.Body, api.MaxRequestBodySize)
|
||||
body, err := io.ReadAll(r.Body)
|
||||
|
||||
if err != nil {
|
||||
var maxBytesErr *http.MaxBytesError
|
||||
if errors.As(err, &maxBytesErr) {
|
||||
return nil, apierror.NewRequestEntityTooLarge()
|
||||
}
|
||||
return nil, apierror.NewCouldNotReadBody(err)
|
||||
}
|
||||
|
||||
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||
|
||||
securityTokenCtx, err := common.NewSecurityTokenCtx(r, ae.TokenIssuerCache)
|
||||
|
||||
Vendored
+5
@@ -6,12 +6,17 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/openziti/ziti/v2/common/eid"
|
||||
"github.com/openziti/ziti/v2/controller/api"
|
||||
"github.com/openziti/ziti/v2/controller/response"
|
||||
)
|
||||
|
||||
// NewRequestContext creates a bare request context for responses rendered outside the normal
|
||||
// request pipeline. The body read is capped at api.MaxRequestBodySize; a body that exceeds the
|
||||
// cap is truncated rather than rejected, since this context only renders error responses.
|
||||
func NewRequestContext(rw http.ResponseWriter, r *http.Request) *response.RequestContext {
|
||||
rid := eid.New()
|
||||
|
||||
r.Body = http.MaxBytesReader(rw, r.Body, api.MaxRequestBodySize)
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||
|
||||
|
||||
@@ -17,8 +17,6 @@
|
||||
package handler_ctrl
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/michaelquigley/pfxlog"
|
||||
"github.com/openziti/channel/v4"
|
||||
@@ -111,16 +109,12 @@ func (self *CtrlAccepter) Bind(binding channel.Binding) error {
|
||||
ch := binding.GetChannel()
|
||||
|
||||
log := pfxlog.Logger().WithField("routerId", ch.Id())
|
||||
// Use a new copy of the router instance each time we connect. That way we can tell on disconnect
|
||||
// if we're working with the right connection, in case connects and disconnects happen quickly.
|
||||
// It also means that the channel and connected time fields don't change and we don't have to protect them
|
||||
r, err := self.network.GetReloadedRouter(ch.Id())
|
||||
// A fresh instance per connection, carrying this channel: that is what lets connect and disconnect tell
|
||||
// two connections for one router apart, and keeps them from writing over each other's state.
|
||||
r, err := self.network.NewCtrlChanRouter(ch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if r == nil {
|
||||
return errors.Errorf("no router with id [%v] found, closing connection", ch.Id())
|
||||
}
|
||||
|
||||
var ctrlChanListeners map[string][]string
|
||||
|
||||
@@ -190,8 +184,6 @@ func (self *CtrlAccepter) Bind(binding channel.Binding) error {
|
||||
return errors.New("channel provided no headers, not accepting router connection as version info not provided")
|
||||
}
|
||||
|
||||
r.Control = ch.(channel.MultiChannel).GetUnderlayHandler().(ctrlchan.CtrlChannel)
|
||||
r.ConnectTime = time.Now()
|
||||
if err := binding.Bind(newBindHandler(self.heartbeatOptions, r, self.network, self.xctrls)); err != nil {
|
||||
return errors.Wrap(err, "error binding router")
|
||||
}
|
||||
@@ -200,9 +192,16 @@ func (self *CtrlAccepter) Bind(binding channel.Binding) error {
|
||||
binding.AddPeekHandler(self.traceHandler)
|
||||
}
|
||||
|
||||
log.Info("accepted new router connection")
|
||||
if err = self.network.ConnectRouter(r); err != nil {
|
||||
if network.IsConnectRejected(err) {
|
||||
log.Info("router connect rejected; another connection is already current, router will redial")
|
||||
}
|
||||
// Returning the error fails the bind, so NewChannel closes this channel's underlay without
|
||||
// starting rx or registering it. That preserves the rx-gate for a rejected connection.
|
||||
return err
|
||||
}
|
||||
|
||||
self.network.ConnectRouter(r)
|
||||
log.Info("accepted new router connection")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -17,10 +17,12 @@
|
||||
package handler_ctrl
|
||||
|
||||
import (
|
||||
"github.com/michaelquigley/pfxlog"
|
||||
"github.com/openziti/channel/v4"
|
||||
"github.com/openziti/ziti/v2/controller/change"
|
||||
"github.com/openziti/ziti/v2/controller/model"
|
||||
"github.com/openziti/ziti/v2/controller/network"
|
||||
"github.com/openziti/ziti/v2/controller/storage/boltz"
|
||||
)
|
||||
|
||||
type baseHandler struct {
|
||||
@@ -31,3 +33,55 @@ type baseHandler struct {
|
||||
func (self *baseHandler) newChangeContext(ch channel.Channel, method string) *change.Context {
|
||||
return change.NewControlChannelChange(self.router.Id, self.router.Name, method, ch)
|
||||
}
|
||||
|
||||
// ownsTerminator reports whether terminator belongs to the router on the other end of this handler's
|
||||
// control channel. Terminator operations arriving on the fabric control channel are scoped to the
|
||||
// requesting router, mirroring the edge control channel's verifyTerminator.
|
||||
func (self *baseHandler) ownsTerminator(terminator *model.Terminator) bool {
|
||||
return terminator.Router == self.router.Id
|
||||
}
|
||||
|
||||
// lookupTerminatorOwner returns the id of the router that owns terminator id and whether the
|
||||
// terminator currently exists. A not-found terminator returns ("", false, nil); other read errors
|
||||
// are returned so the caller can default to keeping the id rather than acting on incomplete state.
|
||||
func (self *baseHandler) lookupTerminatorOwner(id string) (routerId string, present bool, err error) {
|
||||
terminator, err := self.network.Terminator.Read(id)
|
||||
if err != nil {
|
||||
if boltz.IsErrNotFoundErr(err) {
|
||||
return "", false, nil
|
||||
}
|
||||
return "", false, err
|
||||
}
|
||||
return terminator.Router, true, nil
|
||||
}
|
||||
|
||||
// selectOwnedTerminators returns the terminator ids from a remove request that this router may
|
||||
// remove, dropping (and logging) any whose terminator is owned by a different router, so a router
|
||||
// can only remove terminators it owns. Absent ids are kept so a delete racing a not-yet-applied
|
||||
// create is still ordered after it.
|
||||
func (self *baseHandler) selectOwnedTerminators(ids []string) []string {
|
||||
kept, rejected := filterOwnedTerminators(ids, self.router.Id, self.lookupTerminatorOwner)
|
||||
if rejected > 0 {
|
||||
pfxlog.Logger().
|
||||
WithField("routerId", self.router.Id).
|
||||
WithField("rejected", rejected).
|
||||
Warn("router attempted to remove terminators it does not own; rejected")
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
// filterOwnedTerminators returns the ids owned by requestingRouterId (or not currently present),
|
||||
// dropping ids whose terminator resolves to a different router; rejected counts those drops. A
|
||||
// lookup error keeps the id, so an unresolved owner is not treated as an ownership violation.
|
||||
func filterOwnedTerminators(ids []string, requestingRouterId string, lookup func(string) (routerId string, present bool, err error)) (kept []string, rejected int) {
|
||||
kept = make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
routerId, present, err := lookup(id)
|
||||
if err == nil && present && routerId != requestingRouterId {
|
||||
rejected++
|
||||
continue
|
||||
}
|
||||
kept = append(kept, id)
|
||||
}
|
||||
return kept, rejected
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
Copyright NetFoundry Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package handler_ctrl
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/openziti/ziti/v2/controller/model"
|
||||
"github.com/openziti/ziti/v2/controller/models"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_filterOwnedTerminators(t *testing.T) {
|
||||
const me = "router-me"
|
||||
|
||||
// mine: present, owned by the requesting router; other: present, owned by a different router;
|
||||
// gone: not present; err: lookup fails.
|
||||
lookup := func(id string) (string, bool, error) {
|
||||
switch id {
|
||||
case "mine":
|
||||
return me, true, nil
|
||||
case "other":
|
||||
return "router-other", true, nil
|
||||
case "err":
|
||||
return "", false, errors.New("boom")
|
||||
default: // "gone" and anything else
|
||||
return "", false, nil
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("keeps owned and absent ids", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
kept, rejected := filterOwnedTerminators([]string{"mine", "gone"}, me, lookup)
|
||||
req.Equal([]string{"mine", "gone"}, kept)
|
||||
req.Zero(rejected)
|
||||
})
|
||||
|
||||
t.Run("rejects ids owned by another router", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
kept, rejected := filterOwnedTerminators([]string{"other", "mine"}, me, lookup)
|
||||
req.Equal([]string{"mine"}, kept, "a router may only remove terminators it owns")
|
||||
req.Equal(1, rejected)
|
||||
})
|
||||
|
||||
t.Run("a lookup error keeps the id", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
kept, rejected := filterOwnedTerminators([]string{"err"}, me, lookup)
|
||||
req.Equal([]string{"err"}, kept, "an unresolved owner must not be treated as an ownership violation")
|
||||
req.Zero(rejected)
|
||||
})
|
||||
|
||||
t.Run("mixes kept and rejected", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
kept, rejected := filterOwnedTerminators([]string{"mine", "other", "gone", "err"}, me, lookup)
|
||||
req.Equal([]string{"mine", "gone", "err"}, kept)
|
||||
req.Equal(1, rejected)
|
||||
})
|
||||
}
|
||||
|
||||
// Test_ownsTerminator covers the single-terminator check used by the remove and update handlers,
|
||||
// which reject outright rather than filtering.
|
||||
func Test_ownsTerminator(t *testing.T) {
|
||||
handler := &baseHandler{router: &model.Router{BaseEntity: models.BaseEntity{Id: "router-me"}}}
|
||||
|
||||
t.Run("owned by the requesting router", func(t *testing.T) {
|
||||
require.True(t, handler.ownsTerminator(&model.Terminator{Router: "router-me"}))
|
||||
})
|
||||
|
||||
t.Run("owned by a different router", func(t *testing.T) {
|
||||
require.False(t, handler.ownsTerminator(&model.Terminator{Router: "router-other"}))
|
||||
})
|
||||
|
||||
t.Run("unset owner", func(t *testing.T) {
|
||||
require.False(t, handler.ownsTerminator(&model.Terminator{}),
|
||||
"a terminator with no owner must not be treated as owned by the requester")
|
||||
})
|
||||
}
|
||||
@@ -19,31 +19,94 @@ package handler_ctrl
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/michaelquigley/pfxlog"
|
||||
"github.com/openziti/channel/v4"
|
||||
"github.com/openziti/foundation/v2/stringz"
|
||||
"github.com/openziti/identity"
|
||||
"github.com/openziti/ziti/v2/common/cert"
|
||||
"github.com/openziti/ziti/v2/controller/model"
|
||||
"github.com/openziti/ziti/v2/controller/network"
|
||||
)
|
||||
|
||||
type ConnectHandler struct {
|
||||
identity identity.Identity
|
||||
network *network.Network
|
||||
|
||||
// signingCertRoots holds the edge enrollment signing CA bundle. A router presents its enrollment
|
||||
// certificate as its control channel client certificate, so a deployment whose signing CA sits
|
||||
// outside the controller's own trust bundle would otherwise have every router refused here.
|
||||
signingCertRoots []*x509.Certificate
|
||||
|
||||
// separatelyValidatedTypes holds the control-channel type headers that are dispatched to a
|
||||
// separate, self-validating acceptor (currently the raft mesh, when clustering is enabled).
|
||||
separatelyValidatedTypes map[string]struct{}
|
||||
}
|
||||
|
||||
func NewConnectHandler(identity identity.Identity, network *network.Network) *ConnectHandler {
|
||||
// NewConnectHandler returns a ConnectHandler that admits routers whose leaf certificate chains either to
|
||||
// the controller's own CA bundle or to signingCertRoots, the edge enrollment signing CA bundle.
|
||||
// signingCertRoots may be empty, in which case only the controller's bundle is trusted.
|
||||
func NewConnectHandler(identity identity.Identity, network *network.Network, signingCertRoots []*x509.Certificate) *ConnectHandler {
|
||||
return &ConnectHandler{
|
||||
identity: identity,
|
||||
network: network,
|
||||
identity: identity,
|
||||
network: network,
|
||||
signingCertRoots: signingCertRoots,
|
||||
}
|
||||
}
|
||||
|
||||
// SetSeparatelyValidatedChannelTypes records the control-channel type headers that are dispatched to a
|
||||
// separate, self-validating acceptor (e.g. the raft mesh). Connections carrying one of these types are
|
||||
// skipped by HandleConnection; everything else - router control channel types, unrecognized types, and
|
||||
// legacy (no type header) connections, all of which the dispatcher routes to the router control
|
||||
// acceptor - is validated here. This must be populated before the listener begins accepting.
|
||||
func (self *ConnectHandler) SetSeparatelyValidatedChannelTypes(types map[string]struct{}) {
|
||||
self.separatelyValidatedTypes = types
|
||||
}
|
||||
|
||||
// isSeparatelyValidated reports whether the connection's channel type is handled by a separate,
|
||||
// self-validating acceptor and therefore must not be validated as a router control connection here.
|
||||
func (self *ConnectHandler) isSeparatelyValidated(hello *channel.Hello) bool {
|
||||
underlayType, found := hello.Headers[channel.TypeHeader]
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
_, ok := self.separatelyValidatedTypes[string(underlayType)]
|
||||
return ok
|
||||
}
|
||||
|
||||
// isFirstCtrlConnection reports whether this hello establishes a new channel rather than adding an
|
||||
// underlay to an existing grouped channel. A legacy (non-grouped) dial is always a new channel; for a
|
||||
// grouped dial only the connection carrying IsFirstGroupConnection is.
|
||||
func isFirstCtrlConnection(hello *channel.Hello) bool {
|
||||
headers := channel.Headers(hello.Headers)
|
||||
if grouped, _ := headers.GetBoolHeader(channel.IsGroupedHeader); !grouped {
|
||||
return true
|
||||
}
|
||||
first, _ := headers.GetBoolHeader(channel.IsFirstGroupConnection)
|
||||
return first
|
||||
}
|
||||
|
||||
// withinChurnLimit reports whether an established connection is too new to be displaced by a new one.
|
||||
//
|
||||
// This is admission policy, not the uniqueness guarantee. At most one connection per router is enforced
|
||||
// under the per-router lock in Network.ConnectRouter; this runs against the connected map with no lock
|
||||
// held, so it can only avoid paying for a bind that would be refused there anyway.
|
||||
//
|
||||
// Displacing an established connection costs a round trip: the occupant's teardown runs, the connect is
|
||||
// refused, and the router redials into the freed slot. A connection that has only just been established
|
||||
// is therefore protected for churnLimit, so a flapping router cannot thrash a working channel. A zero
|
||||
// limit disables the protection, making every new connection able to displace the current one.
|
||||
func withinChurnLimit(connected *model.Router, churnLimit time.Duration) bool {
|
||||
return time.Since(connected.ConnectTime) < churnLimit
|
||||
}
|
||||
|
||||
func (self *ConnectHandler) HandleConnection(hello *channel.Hello, certificates []*x509.Certificate) error {
|
||||
if _, found := hello.Headers[channel.TypeHeader]; found {
|
||||
// Connections whose channel type is handled by a separate, self-validating acceptor (e.g. the raft
|
||||
// mesh) are validated there, so skip them. Everything else - router control channel types,
|
||||
// unrecognized types, and legacy (no type header) connections - is dispatched to the router control
|
||||
// acceptor and must be validated here.
|
||||
if self.isSeparatelyValidated(hello) {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -51,63 +114,43 @@ func (self *ConnectHandler) HandleConnection(hello *channel.Hello, certificates
|
||||
|
||||
log := pfxlog.Logger().WithField("routerId", id)
|
||||
|
||||
// verify cert chain
|
||||
if len(certificates) == 0 {
|
||||
return fmt.Errorf("no certificates provided, unable to verify dialer, routerId: %v", id)
|
||||
}
|
||||
|
||||
config := self.identity.ServerTLSConfig()
|
||||
|
||||
opts := x509.VerifyOptions{
|
||||
Roots: config.RootCAs,
|
||||
Intermediates: x509.NewCertPool(),
|
||||
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
|
||||
// Verify the peer's leaf certificate (certificates[0], the certificate whose private key the TLS
|
||||
// handshake proved) chains to the controller CA or the edge signing CA, and bind the router
|
||||
// fingerprint check to that verified leaf. Matching the enrolled fingerprint against any presented
|
||||
// certificate would let a peer present its own leaf followed by a target router's public
|
||||
// certificate and pass without that router's private key.
|
||||
leaf, err := cert.VerifyLeafCertChain(self.identity.CA(), certificates, self.signingCertRoots...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to verify dialer, routerId: %v: %w", id, err)
|
||||
}
|
||||
fingerprint := fmt.Sprintf("%x", sha1.Sum(leaf.Raw))
|
||||
log.Debugf("peer leaf certificate fingerprint [%s], common name [%s]", fingerprint, leaf.Subject.CommonName)
|
||||
|
||||
var validFingerPrints []string
|
||||
var errorList []error
|
||||
|
||||
for _, cert := range certificates {
|
||||
if cert.IsCA {
|
||||
opts.Intermediates.AddCert(cert)
|
||||
}
|
||||
}
|
||||
|
||||
for i, cert := range certificates {
|
||||
if !cert.IsCA {
|
||||
if _, err := cert.Verify(opts); err == nil {
|
||||
fingerprint := fmt.Sprintf("%x", sha1.Sum(cert.Raw))
|
||||
validFingerPrints = append(validFingerPrints, fingerprint)
|
||||
log.Debugf("%d): peer certificate fingerprint [%s]", i, fingerprint)
|
||||
log.Debugf("%d): peer common name [%s]", i, cert.Subject.CommonName)
|
||||
} else {
|
||||
errorList = append(errorList, err)
|
||||
// The churn / already-connected guard applies only when establishing a new channel. Additional
|
||||
// underlays of an existing grouped control channel legitimately arrive while the router is already
|
||||
// connected and must not be rejected here.
|
||||
if isFirstCtrlConnection(hello) {
|
||||
if router := self.network.GetConnectedRouter(id); router != nil {
|
||||
if withinChurnLimit(router, self.network.GetOptions().RouterConnectChurnLimit) {
|
||||
log.WithField("routerName", router.Name).Error("router already connected and churn threshold not met")
|
||||
return fmt.Errorf("router already connected id: %s, name: %s", id, router.Name)
|
||||
}
|
||||
log.WithField("routerName", router.Name).Warn("router already connected, but churn threshold met. replacing connection")
|
||||
}
|
||||
}
|
||||
|
||||
if len(validFingerPrints) == 0 && len(errorList) > 0 {
|
||||
return errors.Join(errorList...)
|
||||
}
|
||||
|
||||
log.Debugf("peer has [%d] valid certificates out of [%v] submitted", len(validFingerPrints), len(certificates))
|
||||
|
||||
if router := self.network.GetConnectedRouter(id); router != nil {
|
||||
if time.Since(router.ConnectTime) < self.network.GetOptions().RouterConnectChurnLimit {
|
||||
log.WithField("routerName", router.Name).Error("router already connected and churn threshold not met")
|
||||
return fmt.Errorf("router already connected id: %s, name: %s", id, router.Name)
|
||||
}
|
||||
log.WithField("routerName", router.Name).Warn("router already connected, but churn threshold met. replacing connection")
|
||||
}
|
||||
|
||||
if r, err := self.network.GetRouter(id); err == nil {
|
||||
if r.Fingerprint == nil {
|
||||
log.Error("router enrollment incomplete")
|
||||
return fmt.Errorf("router enrollment incomplete, routerId: %v", id)
|
||||
}
|
||||
if !stringz.Contains(validFingerPrints, *r.Fingerprint) {
|
||||
log.WithField("fp", *r.Fingerprint).WithField("givenFps", validFingerPrints).Error("router fingerprint mismatch")
|
||||
return fmt.Errorf("incorrect fingerprint/unenrolled router, routerId: %v, given fingerprints: %v", id, validFingerPrints)
|
||||
if fingerprint != *r.Fingerprint {
|
||||
log.WithField("fp", *r.Fingerprint).WithField("givenFp", fingerprint).Error("router fingerprint mismatch")
|
||||
return fmt.Errorf("incorrect fingerprint/unenrolled router, routerId: %v, given fingerprint: %v", id, fingerprint)
|
||||
}
|
||||
if r.Disabled {
|
||||
log.Error("router disabled")
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
/*
|
||||
Copyright NetFoundry Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package handler_ctrl
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"math/big"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/openziti/channel/v4"
|
||||
"github.com/openziti/identity"
|
||||
"github.com/openziti/ziti/v2/common/ctrlchan"
|
||||
"github.com/openziti/ziti/v2/controller/model"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// meshChannelType mirrors controller/raft/mesh.ChannelTypeMesh without importing that package.
|
||||
const meshChannelType = "ctrl.mesh"
|
||||
|
||||
func helloWithType(chType string) *channel.Hello {
|
||||
h := channel.Headers{}
|
||||
if chType != "" {
|
||||
h.PutStringHeader(channel.TypeHeader, chType)
|
||||
}
|
||||
return &channel.Hello{Headers: h}
|
||||
}
|
||||
|
||||
// Test_ConnectHandler_isSeparatelyValidated covers the rule that only channel types dispatched to a
|
||||
// dedicated, self-validating acceptor are skipped; every other type - including unrecognized types and
|
||||
// the mesh type on a non-clustered controller - routes to the router control acceptor and must be
|
||||
// validated here.
|
||||
func Test_ConnectHandler_isSeparatelyValidated(t *testing.T) {
|
||||
// Clustered controller: only the mesh type has a dedicated acceptor.
|
||||
clustered := &ConnectHandler{separatelyValidatedTypes: map[string]struct{}{meshChannelType: {}}}
|
||||
require.True(t, clustered.isSeparatelyValidated(helloWithType(meshChannelType)), "mesh is validated by its own acceptor")
|
||||
require.False(t, clustered.isSeparatelyValidated(helloWithType(ctrlchan.ChannelTypeDefault)), "router ctrl type is validated here")
|
||||
require.False(t, clustered.isSeparatelyValidated(helloWithType(ctrlchan.ChannelTypeHighPriority)))
|
||||
require.False(t, clustered.isSeparatelyValidated(helloWithType("bogus")), "unrecognized types route to the router acceptor and must be validated")
|
||||
require.False(t, clustered.isSeparatelyValidated(helloWithType("")), "legacy (no type) connections are validated here")
|
||||
|
||||
// Non-clustered controller: no dedicated acceptors, so even a mesh-typed connection routes to the
|
||||
// router acceptor and must be validated.
|
||||
standalone := &ConnectHandler{separatelyValidatedTypes: map[string]struct{}{}}
|
||||
require.False(t, standalone.isSeparatelyValidated(helloWithType(meshChannelType)), "mesh on a non-clustered controller must be validated")
|
||||
require.False(t, standalone.isSeparatelyValidated(helloWithType(ctrlchan.ChannelTypeDefault)))
|
||||
}
|
||||
|
||||
func Test_isFirstCtrlConnection(t *testing.T) {
|
||||
// Legacy / non-grouped dial: no grouped header -> treated as a new channel.
|
||||
require.True(t, isFirstCtrlConnection(&channel.Hello{Headers: channel.Headers{}}))
|
||||
|
||||
grouped := channel.Headers{}
|
||||
grouped.PutBoolHeader(channel.IsGroupedHeader, true)
|
||||
grouped.PutBoolHeader(channel.IsFirstGroupConnection, true)
|
||||
require.True(t, isFirstCtrlConnection(&channel.Hello{Headers: grouped}), "grouped first connection")
|
||||
|
||||
additional := channel.Headers{}
|
||||
additional.PutBoolHeader(channel.IsGroupedHeader, true)
|
||||
require.False(t, isFirstCtrlConnection(&channel.Hello{Headers: additional}), "additional underlay (no first flag)")
|
||||
|
||||
notFirst := channel.Headers{}
|
||||
notFirst.PutBoolHeader(channel.IsGroupedHeader, true)
|
||||
notFirst.PutBoolHeader(channel.IsFirstGroupConnection, false)
|
||||
require.False(t, isFirstCtrlConnection(&channel.Hello{Headers: notFirst}), "additional underlay (first=false)")
|
||||
}
|
||||
|
||||
// caPoolIdentity is a minimal identity.Identity whose only useful method is CA. The certificate
|
||||
// verification path of HandleConnection consults nothing else, so the remaining interface methods are
|
||||
// left to the embedded nil interface (never called on the paths exercised here).
|
||||
type caPoolIdentity struct {
|
||||
identity.Identity
|
||||
roots *x509.CertPool
|
||||
}
|
||||
|
||||
func (f *caPoolIdentity) CA() *x509.CertPool { return f.roots }
|
||||
|
||||
type ctCertAndKey struct {
|
||||
cert *x509.Certificate
|
||||
key *ecdsa.PrivateKey
|
||||
}
|
||||
|
||||
var ctSerial int64
|
||||
|
||||
func ctMkCert(t *testing.T, cn string, isCA bool, signer *ctCertAndKey) *ctCertAndKey {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
ctSerial++
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(ctSerial),
|
||||
Subject: pkix.Name{CommonName: cn},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
if isCA {
|
||||
tmpl.IsCA = true
|
||||
tmpl.KeyUsage = x509.KeyUsageCertSign | x509.KeyUsageCRLSign
|
||||
} else {
|
||||
tmpl.KeyUsage = x509.KeyUsageDigitalSignature
|
||||
tmpl.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}
|
||||
}
|
||||
signParent, signKey := tmpl, key
|
||||
if signer != nil {
|
||||
signParent, signKey = signer.cert, signer.key
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, signParent, &key.PublicKey, signKey)
|
||||
require.NoError(t, err)
|
||||
c, err := x509.ParseCertificate(der)
|
||||
require.NoError(t, err)
|
||||
return &ctCertAndKey{cert: c, key: key}
|
||||
}
|
||||
|
||||
// Test_ConnectHandler_HandleConnection_RejectsUntrustedLeaf covers the router control-channel
|
||||
// verification: a connection dispatched to the router acceptor must present a leaf that chains to the
|
||||
// controller CA. In particular, presenting a self-signed leaf followed by a legitimate router's public
|
||||
// certificate as filler must be rejected - the check is bound to the leaf, not to "some presented cert
|
||||
// chains". These paths fail during certificate verification, before any network state is consulted.
|
||||
func Test_ConnectHandler_HandleConnection_RejectsUntrustedLeaf(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
root := ctMkCert(t, "root", true, nil)
|
||||
inter := ctMkCert(t, "int", true, root)
|
||||
roots := x509.NewCertPool()
|
||||
roots.AddCert(root.cert)
|
||||
roots.AddCert(inter.cert)
|
||||
|
||||
handler := &ConnectHandler{
|
||||
identity: &caPoolIdentity{roots: roots},
|
||||
separatelyValidatedTypes: map[string]struct{}{},
|
||||
}
|
||||
|
||||
// A legitimate, CA-chained certificate an attacker could scrape off the wire and present as filler.
|
||||
legit := ctMkCert(t, "legit-router", false, inter)
|
||||
// The attacker's own self-signed leaf - it does not chain to the CA.
|
||||
forged := ctMkCert(t, "forged", false, nil)
|
||||
|
||||
hello := &channel.Hello{IdToken: "router1", Headers: channel.Headers{}}
|
||||
|
||||
req.Error(handler.HandleConnection(hello, nil), "no certificates must be rejected")
|
||||
req.Error(handler.HandleConnection(hello, []*x509.Certificate{forged.cert}),
|
||||
"self-signed leaf that does not chain to the CA must be rejected")
|
||||
req.Error(handler.HandleConnection(hello, []*x509.Certificate{forged.cert, legit.cert}),
|
||||
"self-signed leaf backed by a scraped CA-chained filler cert must be rejected")
|
||||
}
|
||||
|
||||
// Test_ConnectHandler_HandleConnection_SkipsSeparatelyValidated verifies that connections whose type is
|
||||
// handled by a separate acceptor (e.g. the raft mesh on a clustered controller) are not validated here,
|
||||
// even when the presented certificate would fail the router control-channel check.
|
||||
func Test_ConnectHandler_HandleConnection_SkipsSeparatelyValidated(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
forged := ctMkCert(t, "forged", false, nil)
|
||||
handler := &ConnectHandler{
|
||||
separatelyValidatedTypes: map[string]struct{}{meshChannelType: {}},
|
||||
}
|
||||
|
||||
req.NoError(handler.HandleConnection(helloWithType(meshChannelType), []*x509.Certificate{forged.cert}),
|
||||
"mesh-typed connection is validated by its own acceptor and must be skipped here")
|
||||
}
|
||||
|
||||
// Test_withinChurnLimit pins the admission policy that decides whether an already-connected router's
|
||||
// channel may be displaced by a new connection.
|
||||
//
|
||||
// It is the only thing rate-limiting displacement. Network.ConnectRouter always displaces an occupant it
|
||||
// does not recognise, so without this a spurious first-connection hello would tear down a healthy control
|
||||
// channel and force the router to redial. Uniqueness itself is guaranteed under the per-router lock in
|
||||
// ConnectRouter, not here, so this check exists purely to protect a working connection from churn.
|
||||
func Test_withinChurnLimit(t *testing.T) {
|
||||
connectedAt := func(d time.Duration) *model.Router {
|
||||
r := &model.Router{ConnectTime: time.Now().Add(-d)}
|
||||
r.Id = "r1"
|
||||
return r
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
since time.Duration
|
||||
churnLimit time.Duration
|
||||
protected bool
|
||||
}{
|
||||
{"a connection just established is protected", 0, time.Minute, true},
|
||||
{"still protected part way through the window", 30 * time.Second, time.Minute, true},
|
||||
{"displaceable once the window has passed", 2 * time.Minute, time.Minute, false},
|
||||
// A zero limit is a supported setting and means "always allow takeover", which is what the option
|
||||
// existed to make configurable in the first place.
|
||||
{"a zero limit protects nothing", 0, 0, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
require.Equal(t, tt.protected, withinChurnLimit(connectedAt(tt.since), tt.churnLimit))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,16 @@ func (self *removeTerminatorHandler) handleRemoveTerminator(msg *channel.Message
|
||||
return
|
||||
}
|
||||
|
||||
if !self.ownsTerminator(terminator) {
|
||||
log.
|
||||
WithField("routerId", self.router.Id).
|
||||
WithField("terminator", request.TerminatorId).
|
||||
WithField("terminatorRouterId", terminator.Router).
|
||||
Warn("router attempted to remove a terminator it does not own; rejected")
|
||||
handler_common.SendFailure(msg, ch, "terminator not owned by requesting router")
|
||||
return
|
||||
}
|
||||
|
||||
if err := self.network.Terminator.Delete(request.TerminatorId, self.newChangeContext(ch, "fabric.remove.terminator")); err == nil {
|
||||
log.
|
||||
WithField("routerId", ch.Id()).
|
||||
|
||||
@@ -59,23 +59,30 @@ func (self *removeTerminatorsHandler) HandleReceive(msg *channel.Message, ch cha
|
||||
func (self *removeTerminatorsHandler) handleRemoveTerminators(msg *channel.Message, ch channel.Channel, request *ctrl_pb.RemoveTerminatorsRequest) {
|
||||
log := pfxlog.ContextLogger(ch.Label())
|
||||
|
||||
// Don't pre-filter by IsEntityPresent here. The create for a terminator may be
|
||||
// in-flight in raft but not yet applied to the DB. If we skip it here, the create
|
||||
// will apply after we return success, leaving an orphan. By sending all IDs through
|
||||
// raft, the delete will be ordered after the create and ApplyDeleteBatch will handle
|
||||
// non-existent IDs gracefully.
|
||||
if len(request.TerminatorIds) == 0 {
|
||||
handler_common.SendSuccess(msg, ch, "")
|
||||
return
|
||||
}
|
||||
|
||||
if err := self.network.Terminator.DeleteBatch(request.TerminatorIds, self.newChangeContext(ch, "fabric.remove.terminators.batch")); err == nil {
|
||||
// Drop ids this router doesn't own, so it can't remove another router's terminators. Absent ids
|
||||
// are kept (not pre-filtered by presence): the create for a terminator may be in-flight in raft
|
||||
// but not yet applied to the DB, so sending it through raft orders the delete after the create,
|
||||
// and ApplyDeleteBatch handles non-existent ids gracefully.
|
||||
toDelete := self.selectOwnedTerminators(request.TerminatorIds)
|
||||
if len(toDelete) == 0 {
|
||||
handler_common.SendSuccess(msg, ch, "")
|
||||
return
|
||||
}
|
||||
|
||||
if err := self.network.Terminator.DeleteBatch(toDelete, self.newChangeContext(ch, "fabric.remove.terminators.batch")); err == nil {
|
||||
log.
|
||||
WithField("routerId", ch.Id()).
|
||||
WithField("terminatorIds", request.TerminatorIds).
|
||||
WithField("terminatorIds", toDelete).
|
||||
Info("removed terminators")
|
||||
handler_common.SendSuccess(msg, ch, "")
|
||||
} else if command.WasRateLimited(err) {
|
||||
} else if command.WasRateLimited(err) || command.WasLeaderless(err) {
|
||||
// A leaderless cluster (during a membership change) is transient; signal busy so the router retries
|
||||
// rather than treating the removal as a permanent failure.
|
||||
handler_common.SendServerBusy(msg, ch, "remove.terminators")
|
||||
} else {
|
||||
handler_common.SendFailure(msg, ch, err.Error())
|
||||
|
||||
@@ -66,6 +66,16 @@ func (self *updateTerminatorHandler) handleUpdateTerminator(msg *channel.Message
|
||||
return
|
||||
}
|
||||
|
||||
if !self.ownsTerminator(terminator) {
|
||||
log.
|
||||
WithField("routerId", self.router.Id).
|
||||
WithField("terminator", request.TerminatorId).
|
||||
WithField("terminatorRouterId", terminator.Router).
|
||||
Warn("router attempted to update a terminator it does not own; rejected")
|
||||
handler_common.SendFailure(msg, ch, "terminator not owned by requesting router")
|
||||
return
|
||||
}
|
||||
|
||||
if !request.UpdateCost && !request.UpdatePrecedence {
|
||||
// nothing to do
|
||||
handler_common.SendSuccess(msg, ch, "")
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"github.com/openziti/channel/v4"
|
||||
"github.com/openziti/identity"
|
||||
"github.com/openziti/sdk-golang/ziti/edge"
|
||||
"github.com/openziti/ziti/v2/controller/storage/boltz"
|
||||
"github.com/openziti/ziti/v2/common"
|
||||
"github.com/openziti/ziti/v2/common/logcontext"
|
||||
"github.com/openziti/ziti/v2/common/pb/edge_ctrl_pb"
|
||||
@@ -23,6 +22,7 @@ import (
|
||||
"github.com/openziti/ziti/v2/controller/models"
|
||||
"github.com/openziti/ziti/v2/controller/network"
|
||||
"github.com/openziti/ziti/v2/controller/oidc_auth"
|
||||
"github.com/openziti/ziti/v2/controller/storage/boltz"
|
||||
"github.com/openziti/ziti/v2/controller/xt"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
@@ -314,10 +314,49 @@ func (self *baseSessionRequestContext) loadFromBolt(sessionToken string, apiSess
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(sessionToken, oidc_auth.JwtTokenPrefix) {
|
||||
self.session, err = self.handler.getAppEnv().Managers.Session.ReadByToken(sessionToken)
|
||||
|
||||
if err != nil {
|
||||
if boltz.IsErrNotFoundErr(err) {
|
||||
self.err = InvalidSessionError{}
|
||||
} else {
|
||||
self.err = internalError(err)
|
||||
}
|
||||
logrus.
|
||||
WithField("operation", self.handler.Label()).
|
||||
WithError(self.err).Errorf("invalid session")
|
||||
return
|
||||
}
|
||||
|
||||
if self.session.ApiSessionId != self.apiSession.Id {
|
||||
self.err = InvalidSessionError{}
|
||||
logrus.
|
||||
WithField("operation", self.handler.Label()).
|
||||
WithField("sessionId", self.session.Id).
|
||||
WithField("sessionApiSessionId", self.session.ApiSessionId).
|
||||
WithField("apiSessionId", self.apiSession.Id).
|
||||
WithError(self.err).Error("session does not belong to api session")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
serviceAccessClaims, err := self.env.ValidateServiceAccessToken(sessionToken, &self.apiSession.Id)
|
||||
|
||||
if err != nil {
|
||||
self.err = internalError(err)
|
||||
// A token-level failure (bad/expired/mismatched/revoked token) means the client should discard
|
||||
// and re-create its session, so return InvalidSession. An infrastructure failure (e.g. a
|
||||
// revocation datastore read) must stay an internalError, otherwise a transient controller fault
|
||||
// would make clients discard valid sessions and trigger a reauthentication storm.
|
||||
var invalidToken *common.InvalidTokenError
|
||||
if errors.As(err, &invalidToken) {
|
||||
self.err = InvalidSessionError{}
|
||||
logrus.
|
||||
WithField("operation", self.handler.Label()).
|
||||
WithError(err).Error("service access token invalid; treating as invalid session")
|
||||
} else {
|
||||
self.err = internalError(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
package handler_edge_ctrl
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/openziti/channel/v4"
|
||||
"github.com/openziti/ziti/v2/controller/change"
|
||||
"github.com/openziti/ziti/v2/controller/db"
|
||||
"github.com/openziti/ziti/v2/controller/env"
|
||||
"github.com/openziti/ziti/v2/controller/model"
|
||||
"github.com/openziti/ziti/v2/controller/network"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type legacySessionTestHandler struct {
|
||||
appEnv *env.AppEnv
|
||||
}
|
||||
|
||||
func (self *legacySessionTestHandler) getAppEnv() *env.AppEnv {
|
||||
return self.appEnv
|
||||
}
|
||||
|
||||
func (*legacySessionTestHandler) getNetwork() *network.Network {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*legacySessionTestHandler) getChannel() channel.Channel {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*legacySessionTestHandler) Label() string {
|
||||
return "legacy-session-test"
|
||||
}
|
||||
|
||||
func newLegacySessionTestEntities(t *testing.T, testCtx *model.TestContext) (*model.Managers, *model.Identity, *model.EdgeService) {
|
||||
managers := testCtx.GetManagers()
|
||||
identity := &model.Identity{
|
||||
Name: uuid.NewString(),
|
||||
IdentityTypeId: db.DefaultIdentityType,
|
||||
}
|
||||
require.NoError(t, managers.Identity.Create(identity, change.New()))
|
||||
|
||||
service := &model.EdgeService{Name: uuid.NewString()}
|
||||
require.NoError(t, managers.EdgeService.Create(service, change.New()))
|
||||
|
||||
edgeRouter := &model.EdgeRouter{Name: uuid.NewString()}
|
||||
require.NoError(t, managers.EdgeRouter.Create(edgeRouter, change.New()))
|
||||
|
||||
servicePolicy := &model.ServicePolicy{
|
||||
Name: uuid.NewString(),
|
||||
Semantic: db.SemanticAllOf,
|
||||
IdentityRoles: []string{"#all"},
|
||||
ServiceRoles: []string{"#all"},
|
||||
PolicyType: db.PolicyTypeDialName,
|
||||
}
|
||||
require.NoError(t, managers.ServicePolicy.Create(servicePolicy, change.New()))
|
||||
|
||||
edgeRouterPolicy := &model.EdgeRouterPolicy{
|
||||
Name: uuid.NewString(),
|
||||
Semantic: db.SemanticAllOf,
|
||||
IdentityRoles: []string{"#all"},
|
||||
EdgeRouterRoles: []string{"#all"},
|
||||
}
|
||||
require.NoError(t, managers.EdgeRouterPolicy.Create(edgeRouterPolicy, change.New()))
|
||||
|
||||
serviceEdgeRouterPolicy := &model.ServiceEdgeRouterPolicy{
|
||||
Name: uuid.NewString(),
|
||||
Semantic: db.SemanticAllOf,
|
||||
ServiceRoles: []string{"#all"},
|
||||
EdgeRouterRoles: []string{"#all"},
|
||||
}
|
||||
require.NoError(t, managers.ServiceEdgeRouterPolicy.Create(serviceEdgeRouterPolicy, change.New()))
|
||||
|
||||
return managers, identity, service
|
||||
}
|
||||
|
||||
func TestLoadFromBoltSupportsOpaqueServiceSessionTokens(t *testing.T) {
|
||||
testCtx := model.NewTestContext(t)
|
||||
defer testCtx.Cleanup()
|
||||
testCtx.Init()
|
||||
|
||||
managers, identity, service := newLegacySessionTestEntities(t, testCtx)
|
||||
|
||||
apiSession := &model.ApiSession{
|
||||
Token: uuid.NewString(),
|
||||
IdentityId: identity.Id,
|
||||
Identity: identity,
|
||||
LastActivityAt: time.Now(),
|
||||
}
|
||||
_, err := managers.ApiSession.Create(nil, apiSession, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
legacySession := &model.Session{
|
||||
Token: uuid.NewString(),
|
||||
IdentityId: identity.Id,
|
||||
ApiSessionId: apiSession.Id,
|
||||
ServiceId: service.Id,
|
||||
Type: db.SessionTypeDial,
|
||||
}
|
||||
_, err = managers.Session.Create(legacySession, change.New())
|
||||
require.NoError(t, err)
|
||||
|
||||
handler := &legacySessionTestHandler{
|
||||
appEnv: &env.AppEnv{Managers: managers},
|
||||
}
|
||||
requestCtx := &baseSessionRequestContext{handler: handler}
|
||||
|
||||
requestCtx.loadFromBolt(legacySession.Token, apiSession.Token)
|
||||
|
||||
require.NoError(t, requestCtx.err)
|
||||
require.Equal(t, legacySession.Id, requestCtx.session.Id)
|
||||
require.Equal(t, apiSession.Id, requestCtx.apiSession.Id)
|
||||
}
|
||||
|
||||
func TestLoadFromBoltRejectsOpaqueServiceSessionForDifferentApiSession(t *testing.T) {
|
||||
testCtx := model.NewTestContext(t)
|
||||
defer testCtx.Cleanup()
|
||||
testCtx.Init()
|
||||
|
||||
managers, identity, service := newLegacySessionTestEntities(t, testCtx)
|
||||
|
||||
newApiSession := func() *model.ApiSession {
|
||||
apiSession := &model.ApiSession{
|
||||
Token: uuid.NewString(),
|
||||
IdentityId: identity.Id,
|
||||
Identity: identity,
|
||||
LastActivityAt: time.Now(),
|
||||
}
|
||||
_, err := managers.ApiSession.Create(nil, apiSession, nil)
|
||||
require.NoError(t, err)
|
||||
return apiSession
|
||||
}
|
||||
|
||||
ownerApiSession := newApiSession()
|
||||
otherApiSession := newApiSession()
|
||||
legacySession := &model.Session{
|
||||
Token: uuid.NewString(),
|
||||
IdentityId: identity.Id,
|
||||
ApiSessionId: ownerApiSession.Id,
|
||||
ServiceId: service.Id,
|
||||
Type: db.SessionTypeDial,
|
||||
}
|
||||
_, err := managers.Session.Create(legacySession, change.New())
|
||||
require.NoError(t, err)
|
||||
|
||||
handler := &legacySessionTestHandler{
|
||||
appEnv: &env.AppEnv{Managers: managers},
|
||||
}
|
||||
requestCtx := &baseSessionRequestContext{handler: handler}
|
||||
|
||||
requestCtx.loadFromBolt(legacySession.Token, otherApiSession.Token)
|
||||
|
||||
require.IsType(t, InvalidSessionError{}, requestCtx.err)
|
||||
}
|
||||
@@ -58,7 +58,7 @@ func (self *createCircuitHandler) HandleReceiveCreateCircuitV1(msg *channel.Mess
|
||||
}
|
||||
|
||||
ctx := &CreateCircuitRequestContext{
|
||||
baseSessionRequestContext: baseSessionRequestContext{handler: self, msg: msg},
|
||||
baseSessionRequestContext: baseSessionRequestContext{handler: self, msg: msg, env: self.appEnv},
|
||||
req: req,
|
||||
}
|
||||
|
||||
|
||||
@@ -23,20 +23,22 @@ import (
|
||||
"github.com/openziti/channel/v4"
|
||||
"github.com/openziti/identity"
|
||||
"github.com/openziti/sdk-golang/ziti/edge"
|
||||
"github.com/openziti/ziti/v2/controller/storage/boltz"
|
||||
"github.com/openziti/ziti/v2/common/ctrl_msg"
|
||||
"github.com/openziti/ziti/v2/common/logcontext"
|
||||
"github.com/openziti/ziti/v2/common/pb/edge_ctrl_pb"
|
||||
"github.com/openziti/ziti/v2/controller/env"
|
||||
"github.com/openziti/ziti/v2/controller/model"
|
||||
"github.com/openziti/ziti/v2/controller/storage/boltz"
|
||||
"github.com/openziti/ziti/v2/controller/xt"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// NewCreateCircuitV3Handler creates a handler for CreateCircuitV3 requests. These requests
|
||||
// come from routers that have already authorized the dial locally via RDM, so no service
|
||||
// session token is required. Instead, the request carries identity ID, service ID, and
|
||||
// a pre-assigned circuit ID.
|
||||
// session token is required. An API session token is still required: it proves the dial is
|
||||
// being made on behalf of an authenticated identity, and the controller uses its claims,
|
||||
// not the request's identity ID, as the authoritative dialing identity. The request also
|
||||
// carries the service ID and a pre-assigned circuit ID.
|
||||
func NewCreateCircuitV3Handler(appEnv *env.AppEnv, ch channel.Channel) channel.TypedReceiveHandler {
|
||||
handler := &createCircuitHandler{
|
||||
baseRequestHandler: baseRequestHandler{
|
||||
@@ -80,6 +82,7 @@ func (self *createCircuitHandler) createCircuitV3(ctx *createCircuitV3RequestCon
|
||||
if !ctx.loadRouter() {
|
||||
return
|
||||
}
|
||||
ctx.validateApiSession()
|
||||
ctx.setupLogContext()
|
||||
ctx.loadServiceByIdForDial()
|
||||
ctx.verifyEdgeRouterAccessForIdentity()
|
||||
@@ -96,7 +99,7 @@ func (self *createCircuitHandler) createCircuitV3(ctx *createCircuitV3RequestCon
|
||||
}
|
||||
|
||||
log := pfxlog.ContextLogger(self.ch.Label()).
|
||||
WithField("identityId", ctx.req.IdentityId).
|
||||
WithField("identityId", ctx.identityId()).
|
||||
WithField("serviceId", ctx.req.ServiceId).
|
||||
WithField("circuitId", circuitInfo.Id)
|
||||
|
||||
@@ -124,17 +127,69 @@ func (self *createCircuitV3RequestContext) UpdateResponse(m *channel.Message) {
|
||||
}
|
||||
}
|
||||
|
||||
// validateApiSession validates the API session token accompanying the request and
|
||||
// establishes the dialing identity from its claims. Connect-v2 routers authorize the dial
|
||||
// locally against their data model, but the identity ID in the request is router-supplied,
|
||||
// so the controller validates the token itself. This both proves the dial is being made on
|
||||
// behalf of an authenticated identity and re-checks expiration and revocation, which the
|
||||
// router may not have seen yet.
|
||||
func (self *createCircuitV3RequestContext) validateApiSession() {
|
||||
if self.err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
log := logrus.WithField("routerId", self.sourceRouter.Id).
|
||||
WithField("operation", self.handler.Label()).
|
||||
WithField("requestedIdentityId", self.req.IdentityId).
|
||||
WithField("serviceId", self.req.ServiceId)
|
||||
|
||||
if self.req.ApiSessionToken == "" {
|
||||
self.err = InvalidApiSessionError{}
|
||||
log.Error("no api session token provided in create circuit v3 request")
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := self.env.ValidateAccessToken(self.req.ApiSessionToken)
|
||||
if err != nil {
|
||||
self.err = InvalidApiSessionError{}
|
||||
log.WithError(err).Error("invalid api session token in create circuit v3 request")
|
||||
return
|
||||
}
|
||||
|
||||
// The token is authoritative for identity. A mismatch means the router asked for a
|
||||
// circuit on behalf of an identity other than the one that authenticated.
|
||||
if claims.Subject != self.req.IdentityId {
|
||||
self.err = InvalidApiSessionError{}
|
||||
log.WithField("apiSessionIdentityId", claims.Subject).
|
||||
WithField("apiSessionId", claims.ApiSessionId).
|
||||
Error("create circuit v3 request identity does not match api session identity")
|
||||
return
|
||||
}
|
||||
|
||||
self.accessClaims = claims
|
||||
}
|
||||
|
||||
// identityId returns the identity the circuit is being created for, taken from the
|
||||
// validated API session token claims. Only valid once validateApiSession has succeeded.
|
||||
func (self *createCircuitV3RequestContext) identityId() string {
|
||||
if self.accessClaims == nil {
|
||||
return ""
|
||||
}
|
||||
return self.accessClaims.Subject
|
||||
}
|
||||
|
||||
func (self *createCircuitV3RequestContext) setupLogContext() {
|
||||
if self.err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
self.logContext = logcontext.NewContext()
|
||||
traceSpec := self.handler.getAppEnv().TraceManager.GetIdentityTrace(self.req.IdentityId)
|
||||
traceSpec := self.handler.getAppEnv().TraceManager.GetIdentityTrace(self.identityId())
|
||||
if traceSpec != nil && time.Now().Before(traceSpec.Until) {
|
||||
self.logContext.SetChannelsMask(traceSpec.ChannelMask)
|
||||
self.logContext.WithField("traceId", traceSpec.TraceId)
|
||||
}
|
||||
self.logContext.WithField("apiSessionId", self.accessClaims.ApiSessionId)
|
||||
}
|
||||
|
||||
func (self *createCircuitV3RequestContext) loadServiceByIdForDial() {
|
||||
@@ -157,11 +212,11 @@ func (self *createCircuitV3RequestContext) loadServiceByIdForDial() {
|
||||
return
|
||||
}
|
||||
|
||||
dialable, err := self.handler.getAppEnv().Managers.EdgeService.IsDialableByIdentity(self.req.ServiceId, self.req.IdentityId)
|
||||
dialable, err := self.handler.getAppEnv().Managers.EdgeService.IsDialableByIdentity(self.req.ServiceId, self.identityId())
|
||||
if err != nil {
|
||||
self.err = internalError(err)
|
||||
logrus.WithField("serviceId", self.req.ServiceId).
|
||||
WithField("identityId", self.req.IdentityId).
|
||||
WithField("identityId", self.identityId()).
|
||||
WithField("operation", self.handler.Label()).
|
||||
WithError(err).
|
||||
Error("unable to verify dial access to service")
|
||||
@@ -171,7 +226,7 @@ func (self *createCircuitV3RequestContext) loadServiceByIdForDial() {
|
||||
if !dialable {
|
||||
self.err = InvalidServiceError{}
|
||||
logrus.WithField("serviceId", self.req.ServiceId).
|
||||
WithField("identityId", self.req.IdentityId).
|
||||
WithField("identityId", self.identityId()).
|
||||
WithField("operation", self.handler.Label()).
|
||||
Error("identity does not have dial access to service")
|
||||
}
|
||||
@@ -181,16 +236,16 @@ func (self *createCircuitV3RequestContext) verifyEdgeRouterAccessForIdentity() {
|
||||
if self.err != nil {
|
||||
return
|
||||
}
|
||||
self.verifyEdgeRouterAccess(self.req.IdentityId, self.service.Id)
|
||||
self.verifyEdgeRouterAccess(self.identityId(), self.service.Id)
|
||||
}
|
||||
|
||||
func (self *createCircuitV3RequestContext) newCircuitCreateParms(serviceId string, peerData map[uint32][]byte) model.CreateCircuitParams {
|
||||
return &connectV3CircuitParams{
|
||||
circuitId: self.req.CircuitId,
|
||||
serviceId: serviceId,
|
||||
identityId: self.req.IdentityId,
|
||||
identityId: self.identityId(),
|
||||
sourceRouter: self.sourceRouter,
|
||||
clientId: &identity.TokenId{Token: self.req.IdentityId, Data: peerData},
|
||||
clientId: &identity.TokenId{Token: self.identityId(), Data: peerData},
|
||||
logCtx: self.logContext,
|
||||
deadline: time.Now().Add(self.handler.getAppEnv().GetHostController().GetNetwork().GetOptions().RouteTimeout),
|
||||
}
|
||||
|
||||
@@ -58,11 +58,6 @@ func (self *createTerminatorV2Handler) Label() string {
|
||||
}
|
||||
|
||||
func (self *createTerminatorV2Handler) HandleReceive(msg *channel.Message, ch channel.Channel) {
|
||||
if self.appEnv.GetCommandDispatcher().IsLeaderless() {
|
||||
pfxlog.ContextLogger(ch.Label()).Error("cluster has no leader, unable to handle create terminator request")
|
||||
return
|
||||
}
|
||||
|
||||
req := &edge_ctrl_pb.CreateTerminatorV2Request{}
|
||||
if err := proto.Unmarshal(msg.Body, req); err != nil {
|
||||
pfxlog.ContextLogger(ch.Label()).WithError(err).Error("could not unmarshal CreateTerminatorV2Request")
|
||||
@@ -122,6 +117,12 @@ func (self *createTerminatorV2Handler) CreateTerminatorV2(ctx *CreateTerminatorV
|
||||
}, ctx.newChangeContext())
|
||||
|
||||
if err != nil {
|
||||
// A rate-limited or leaderless dispatch is transient; reply busy so the router requeues
|
||||
// promptly instead of treating it as a hard failure.
|
||||
if command.WasRateLimited(err) || command.WasLeaderless(err) {
|
||||
self.returnError(ctx, busyError(err), logger)
|
||||
return
|
||||
}
|
||||
self.returnError(ctx, internalError(err), logger)
|
||||
return
|
||||
}
|
||||
@@ -160,7 +161,7 @@ func (self *createTerminatorV2Handler) CreateTerminatorV2(ctx *CreateTerminatorV
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if command.WasRateLimited(err) {
|
||||
if command.WasRateLimited(err) || command.WasLeaderless(err) {
|
||||
self.returnError(ctx, busyError(err), logger)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -148,7 +148,9 @@ func (self *createTunnelTerminatorV2Handler) CreateTerminator(ctx *createTunnelT
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if command.WasRateLimited(err) {
|
||||
// A rate-limited or leaderless dispatch is transient; reply busy so the router requeues
|
||||
// promptly instead of treating it as a hard failure.
|
||||
if command.WasRateLimited(err) || command.WasLeaderless(err) {
|
||||
self.returnError(ctx, busyError(err), logger)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@ func (enforcer *ServicePolicyEnforcer) Run() error {
|
||||
if session.Type == db.SessionTypeBind {
|
||||
policyType = db.PolicyTypeBind
|
||||
}
|
||||
query := fmt.Sprintf(`id = "%v" and not isEmpty(from servicePolicies where type = %v and anyOf(services) = "%v")`, identity.Id, policyType.Id(), session.ServiceId)
|
||||
query := fmt.Sprintf(`id = "%v" and not isEmpty(from servicePolicies where type = "%v" and anyOf(services) = "%v")`, identity.Id, policyType.String(), session.ServiceId)
|
||||
_, count, err := enforcer.appEnv.GetStores().Identity.QueryIds(tx, query)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -20,8 +20,6 @@ import (
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -280,15 +278,11 @@ func (ro *EnrollRouter) legacyGenericEnrollPemHandler(ae *env.AppEnv, rc *respon
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(rc.Request.Body)
|
||||
|
||||
if err != nil {
|
||||
rc.RespondWithError(fmt.Errorf("could not read body: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
// The request body has already been read and buffered into rc.Body by
|
||||
// CreateRequestContext; reuse that buffer rather than reading rc.Request.Body
|
||||
// again, which would allocate a second full copy of a pre-auth request body.
|
||||
enrollContext.Data = &model.EnrollmentData{
|
||||
ClientCsrPem: body,
|
||||
ClientCsrPem: rc.Body,
|
||||
}
|
||||
|
||||
ro.processEnrollContext(ae, rc, enrollContext)
|
||||
|
||||
@@ -327,11 +327,11 @@ func (r *IdentityRouter) listServices(ae *env.AppEnv, rc *response.RequestContex
|
||||
typeFilter := ""
|
||||
if params.PolicyType != nil {
|
||||
if strings.EqualFold(*params.PolicyType, db.PolicyTypeBind.String()) {
|
||||
typeFilter = fmt.Sprintf(` and type = %d`, db.PolicyTypeBind.Id())
|
||||
typeFilter = fmt.Sprintf(` and type = "%s"`, db.PolicyTypeBind.String())
|
||||
}
|
||||
|
||||
if strings.EqualFold(*params.PolicyType, db.PolicyTypeDial.String()) {
|
||||
typeFilter = fmt.Sprintf(` and type = %d`, db.PolicyTypeDial.Id())
|
||||
typeFilter = fmt.Sprintf(` and type = "%s"`, db.PolicyTypeDial.String())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -386,11 +386,11 @@ func (r *ServiceRouter) listIdentities(ae *env.AppEnv, rc *response.RequestConte
|
||||
typeFilter := ""
|
||||
if params.PolicyType != nil {
|
||||
if strings.EqualFold(*params.PolicyType, db.PolicyTypeBind.String()) {
|
||||
typeFilter = fmt.Sprintf(` and type = %d`, db.PolicyTypeBind.Id())
|
||||
typeFilter = fmt.Sprintf(` and type = "%s"`, db.PolicyTypeBind.String())
|
||||
}
|
||||
|
||||
if strings.EqualFold(*params.PolicyType, db.PolicyTypeDial.String()) {
|
||||
typeFilter = fmt.Sprintf(` and type = %d`, db.PolicyTypeDial.Id())
|
||||
typeFilter = fmt.Sprintf(` and type = "%s"`, db.PolicyTypeDial.String())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -188,6 +188,19 @@ func (r *SessionRouter) Create(ae *env.AppEnv, rc *response.RequestContext, para
|
||||
}
|
||||
|
||||
entity := MapCreateSessionToModel(identity.Id, apiSession.Id, params.Session)
|
||||
|
||||
// Legacy sessions are backed by a durable record, and the JWT must be signed with that
|
||||
// record's id. Create must run before CreateJwt: its dedup can resolve entity.Id to a
|
||||
// pre-existing session for the same (api-session, type, service) rather than the freshly
|
||||
// generated id. Signing first would mint a token whose id has no backing session, so every
|
||||
// subsequent create-circuit/create-terminator would fail to load the session.
|
||||
if rc.HasLegacySecurityToken() {
|
||||
if _, err = ae.Managers.Session.Create(entity, rc.NewChangeContext()); err != nil {
|
||||
rc.RespondWithError(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
jwtStr, err := ae.Managers.Session.CreateJwt(entity, rc.HasLegacySecurityToken())
|
||||
|
||||
if err != nil {
|
||||
@@ -217,14 +230,6 @@ func (r *SessionRouter) Create(ae *env.AppEnv, rc *response.RequestContext, para
|
||||
Meta: &rest_model.Meta{},
|
||||
}
|
||||
|
||||
if rc.HasLegacySecurityToken() {
|
||||
_, err = ae.Managers.Session.Create(entity, rc.NewChangeContext())
|
||||
if err != nil {
|
||||
rc.RespondWithError(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
rc.Respond(newSessionEnvelope, http.StatusCreated)
|
||||
|
||||
r.createTimer.UpdateSince(start)
|
||||
|
||||
@@ -97,6 +97,7 @@ func (ir *VersionRouter) buildVersions(ae *env.AppEnv) *rest_model.Version {
|
||||
Version: buildInfo.Version(),
|
||||
APIVersions: map[string]map[string]rest_model.APIVersion{},
|
||||
Capabilities: []string{},
|
||||
BuildFlags: build.GetBuildFlags(),
|
||||
}
|
||||
|
||||
for apiBinding, apiVersionToPathMap := range webapis.AllApiBindingVersions {
|
||||
|
||||
@@ -42,11 +42,10 @@ const (
|
||||
)
|
||||
|
||||
func newCommandManager(env Env, registry ioc.Registry) *CommandManager {
|
||||
command.GetDefaultDecoders().Clear()
|
||||
result := &CommandManager{
|
||||
env: env,
|
||||
registry: registry,
|
||||
Decoders: command.GetDefaultDecoders(),
|
||||
Decoders: env.GetCommandDispatcher().GetDecoders(),
|
||||
backgroundDelayThreshold: env.GetConfig().Command.Background.DelayThreshold,
|
||||
backgroundWorkTimer: env.GetMetricsRegistry().Timer(backgroundQueueMetricsBase + ".work_timer"),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
/*
|
||||
Copyright NetFoundry Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
cmap "github.com/orcaman/concurrent-map/v2"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/openziti/ziti/v2/common/ctrlchan"
|
||||
"github.com/openziti/ziti/v2/controller/event"
|
||||
)
|
||||
|
||||
// fakeCtrlChannel implements the part of ctrlchan.CtrlChannel that the connection tracker
|
||||
// uses. The embedded interface is left nil so that any other method panics rather than
|
||||
// quietly returning a zero value.
|
||||
type fakeCtrlChannel struct {
|
||||
ctrlchan.CtrlChannel
|
||||
id string
|
||||
closed atomic.Bool
|
||||
}
|
||||
|
||||
func (self *fakeCtrlChannel) PeerId() string {
|
||||
return self.id
|
||||
}
|
||||
|
||||
func (self *fakeCtrlChannel) IsClosed() bool {
|
||||
return self.closed.Load()
|
||||
}
|
||||
|
||||
func newTestConnectionTracker(t *testing.T) *ConnectionTracker {
|
||||
closeNotify := make(chan struct{})
|
||||
t.Cleanup(func() {
|
||||
close(closeNotify)
|
||||
})
|
||||
|
||||
return &ConnectionTracker{
|
||||
connections: cmap.New[*identityConnections](),
|
||||
eventDispatcher: event.DispatcherMock{},
|
||||
scanInterval: time.Millisecond,
|
||||
unknownTimeout: time.Minute,
|
||||
closeNotify: closeNotify,
|
||||
}
|
||||
}
|
||||
|
||||
// TestConnectionTrackerLockOrdering ensures that the scan loop and connect/disconnect
|
||||
// handling can run concurrently against the same identities without deadlocking.
|
||||
//
|
||||
// The tracker uses two locks, the cmap shard lock and the per-identity lock. Acquiring
|
||||
// them in opposite orders in different code paths deadlocks permanently, and because the
|
||||
// shard lock is then never released, every identity read in the controller blocks behind
|
||||
// it.
|
||||
func TestConnectionTrackerLockOrdering(t *testing.T) {
|
||||
tracker := newTestConnectionTracker(t)
|
||||
|
||||
// A small identity set keeps the scan loop and the connect/disconnect handling
|
||||
// contending on the same entries and the same cmap shards.
|
||||
identityIds := []string{"identity-1", "identity-2", "identity-3", "identity-4"}
|
||||
|
||||
var stop atomic.Bool
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, identityId := range identityIds {
|
||||
wg.Add(1)
|
||||
go func(identityId string) {
|
||||
defer wg.Done()
|
||||
ch := &fakeCtrlChannel{id: "router-1"}
|
||||
for !stop.Load() {
|
||||
// leaves the entry with no routers, making it a candidate for the scan
|
||||
// loop to reap while this goroutine is still touching it
|
||||
tracker.MarkConnected(identityId, ch)
|
||||
tracker.MarkDisconnected(identityId, ch)
|
||||
}
|
||||
}(identityId)
|
||||
}
|
||||
|
||||
// An identity no other goroutine touches, so that the scan loop is the only thing
|
||||
// that could take it offline. Nothing may drop an entry that has a live router
|
||||
// connection, so if the scan loop removes it based on a view taken before the
|
||||
// reconnect, this sees it.
|
||||
var sawUnexpectedState atomic.Bool
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
ch := &fakeCtrlChannel{id: "router-2"}
|
||||
for !stop.Load() {
|
||||
tracker.MarkConnected("identity-reconnecting", ch)
|
||||
if tracker.GetIdentityOnlineState("identity-reconnecting") != IdentityStateOnline {
|
||||
sawUnexpectedState.Store(true)
|
||||
return
|
||||
}
|
||||
tracker.MarkDisconnected("identity-reconnecting", ch)
|
||||
}
|
||||
}()
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for !stop.Load() {
|
||||
tracker.ScanForDisconnectedRouters()
|
||||
}
|
||||
}()
|
||||
|
||||
time.AfterFunc(2*time.Second, func() {
|
||||
stop.Store(true)
|
||||
})
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(30 * time.Second):
|
||||
buf := make([]byte, 4*1024*1024)
|
||||
n := runtime.Stack(buf, true)
|
||||
t.Fatalf("connection tracker deadlocked, goroutine dump follows:\n%s", buf[:n])
|
||||
}
|
||||
|
||||
require.False(t, sawUnexpectedState.Load(),
|
||||
"identity reported as not online while it had a live router connection")
|
||||
}
|
||||
|
||||
// TestConnectionTrackerReapsDisconnectedIdentities covers the entry removal path that the
|
||||
// scan loop uses, since that is where the lock ordering has to be respected.
|
||||
func TestConnectionTrackerReapsDisconnectedIdentities(t *testing.T) {
|
||||
req := require.New(t)
|
||||
tracker := newTestConnectionTracker(t)
|
||||
|
||||
ch := &fakeCtrlChannel{id: "router-1"}
|
||||
|
||||
tracker.MarkConnected("identity-1", ch)
|
||||
req.Equal(IdentityStateOnline, tracker.GetIdentityOnlineState("identity-1"))
|
||||
|
||||
tracker.ScanForDisconnectedRouters()
|
||||
req.Equal(1, tracker.connections.Count(), "identity with a connected router should not be reaped")
|
||||
|
||||
tracker.MarkDisconnected("identity-1", ch)
|
||||
req.Equal(IdentityStateOffline, tracker.GetIdentityOnlineState("identity-1"))
|
||||
|
||||
tracker.ScanForDisconnectedRouters()
|
||||
req.Equal(0, tracker.connections.Count(), "identity with no connected routers should be reaped")
|
||||
}
|
||||
|
||||
// TestConnectionTrackerRemoveIfEmptyRechecksMapValue covers the window between the scan
|
||||
// loop deciding an entry is empty and the entry actually being removed. Nothing is locked
|
||||
// across that window, so an identity can reconnect inside it and the removal has to
|
||||
// notice.
|
||||
//
|
||||
// The scan loop is driven a step at a time here because that window cannot be held open
|
||||
// from outside; removeIfEmpty is exactly the step that runs after the decision.
|
||||
func TestConnectionTrackerRemoveIfEmptyRechecksMapValue(t *testing.T) {
|
||||
req := require.New(t)
|
||||
tracker := newTestConnectionTracker(t)
|
||||
|
||||
ch := &fakeCtrlChannel{id: "router-1"}
|
||||
|
||||
tracker.MarkConnected("identity-1", ch)
|
||||
tracker.MarkDisconnected("identity-1", ch)
|
||||
|
||||
// the state the scan loop observes, and acts on, before removing the entry
|
||||
entry, found := tracker.connections.Get("identity-1")
|
||||
req.True(found)
|
||||
entry.RLock()
|
||||
empty := len(entry.routers) == 0
|
||||
entry.RUnlock()
|
||||
req.True(empty, "scan loop would decide to remove this entry")
|
||||
|
||||
// the identity reconnects before the removal runs
|
||||
tracker.MarkConnected("identity-1", ch)
|
||||
|
||||
tracker.removeIfEmpty("identity-1")
|
||||
|
||||
req.Equal(1, tracker.connections.Count(), "reconnected identity should not be removed")
|
||||
req.Equal(IdentityStateOnline, tracker.GetIdentityOnlineState("identity-1"))
|
||||
}
|
||||
|
||||
// TestConnectionTrackerRemoveIfEmptyRemovesEmptyEntry is the other half of
|
||||
// TestConnectionTrackerRemoveIfEmptyRechecksMapValue, so that the recheck cannot be
|
||||
// satisfied by simply never removing anything.
|
||||
func TestConnectionTrackerRemoveIfEmptyRemovesEmptyEntry(t *testing.T) {
|
||||
req := require.New(t)
|
||||
tracker := newTestConnectionTracker(t)
|
||||
|
||||
ch := &fakeCtrlChannel{id: "router-1"}
|
||||
|
||||
tracker.MarkConnected("identity-1", ch)
|
||||
tracker.MarkDisconnected("identity-1", ch)
|
||||
req.Equal(1, tracker.connections.Count())
|
||||
|
||||
tracker.removeIfEmpty("identity-1")
|
||||
req.Equal(0, tracker.connections.Count())
|
||||
}
|
||||
@@ -19,11 +19,11 @@ package model
|
||||
import (
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/michaelquigley/pfxlog"
|
||||
nfpem "github.com/openziti/foundation/v2/pem"
|
||||
"github.com/openziti/ziti/v2/controller/storage/boltz"
|
||||
"github.com/openziti/ziti/v2/common/pb/edge_cmd_pb"
|
||||
"github.com/openziti/ziti/v2/controller/change"
|
||||
"github.com/openziti/ziti/v2/controller/command"
|
||||
@@ -31,6 +31,7 @@ import (
|
||||
"github.com/openziti/ziti/v2/controller/event"
|
||||
"github.com/openziti/ziti/v2/controller/fields"
|
||||
"github.com/openziti/ziti/v2/controller/models"
|
||||
"github.com/openziti/ziti/v2/controller/storage/boltz"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
@@ -291,7 +292,7 @@ func (self *ControllerManager) UpdateControllerState(peers []*event.ClusterPeer,
|
||||
Id: peer.Id,
|
||||
},
|
||||
Name: peer.ServerCert[0].Subject.CommonName,
|
||||
CertPem: nfpem.EncodeToString(peer.ServerCert[0]),
|
||||
CertPem: certChainPem(peer.ServerCert),
|
||||
Fingerprint: nfpem.FingerprintFromCertificate(peer.ServerCert[0]),
|
||||
CtrlAddress: peer.Addr,
|
||||
IsOnline: true,
|
||||
@@ -403,7 +404,7 @@ func (self *ControllerManager) UpdateSelfOnNewLeader() {
|
||||
Id: peer.Id,
|
||||
},
|
||||
Name: peer.ServerCert[0].Subject.CommonName,
|
||||
CertPem: nfpem.EncodeToString(peer.ServerCert[0]),
|
||||
CertPem: certChainPem(peer.ServerCert),
|
||||
Fingerprint: nfpem.FingerprintFromCertificate(peer.ServerCert[0]),
|
||||
CtrlAddress: peer.Addr,
|
||||
IsOnline: true,
|
||||
@@ -411,15 +412,15 @@ func (self *ControllerManager) UpdateSelfOnNewLeader() {
|
||||
ApiAddresses: apiAddressesFromPeer(peer),
|
||||
}
|
||||
disconnectFields := fields.UpdatedFieldsMap{
|
||||
db.FieldControllerIsOnline: struct{}{},
|
||||
db.FieldControllerCertPem: struct{}{},
|
||||
db.FieldControllerFingerprint: struct{}{},
|
||||
db.FieldControllerCtrlAddress: struct{}{},
|
||||
db.FieldControllerApiAddresses: struct{}{},
|
||||
db.FieldControllerApiAddressUrl: struct{}{},
|
||||
db.FieldControllerApiAddressVersion: struct{}{},
|
||||
db.FieldControllerIsPreferredLeader: struct{}{},
|
||||
db.FieldName: struct{}{},
|
||||
db.FieldControllerIsOnline: struct{}{},
|
||||
db.FieldControllerCertPem: struct{}{},
|
||||
db.FieldControllerFingerprint: struct{}{},
|
||||
db.FieldControllerCtrlAddress: struct{}{},
|
||||
db.FieldControllerApiAddresses: struct{}{},
|
||||
db.FieldControllerApiAddressUrl: struct{}{},
|
||||
db.FieldControllerApiAddressVersion: struct{}{},
|
||||
db.FieldControllerIsPreferredLeader: struct{}{},
|
||||
db.FieldName: struct{}{},
|
||||
}
|
||||
|
||||
changeCtx := change.New()
|
||||
@@ -432,6 +433,15 @@ func (self *ControllerManager) UpdateSelfOnNewLeader() {
|
||||
}
|
||||
|
||||
// apiAddressFromPeer converts event.ClusterPeer API Addresses to model API Addresses
|
||||
// certChainPem encodes certs (leaf first) as concatenated PEM.
|
||||
func certChainPem(certs []*x509.Certificate) string {
|
||||
sb := strings.Builder{}
|
||||
for _, cert := range certs {
|
||||
sb.WriteString(nfpem.EncodeToString(cert))
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func apiAddressesFromPeer(peer *event.ClusterPeer) map[string][]ApiAddress {
|
||||
result := map[string][]ApiAddress{}
|
||||
|
||||
|
||||
@@ -17,14 +17,18 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/openziti/ziti/v2/controller/storage/ast"
|
||||
"github.com/openziti/ziti/v2/controller/storage/boltz"
|
||||
"strings"
|
||||
|
||||
"github.com/openziti/foundation/v2/errorz"
|
||||
"github.com/openziti/ziti/v2/common/pb/edge_cmd_pb"
|
||||
"github.com/openziti/ziti/v2/controller/apierror"
|
||||
"github.com/openziti/ziti/v2/controller/change"
|
||||
"github.com/openziti/ziti/v2/controller/command"
|
||||
"github.com/openziti/ziti/v2/controller/db"
|
||||
"github.com/openziti/ziti/v2/controller/fields"
|
||||
"github.com/openziti/ziti/v2/controller/models"
|
||||
"github.com/openziti/ziti/v2/controller/storage/ast"
|
||||
"github.com/openziti/ziti/v2/controller/storage/boltz"
|
||||
"github.com/pkg/errors"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
@@ -33,6 +37,7 @@ import (
|
||||
func NewExternalJwtSignerManager(env Env) *ExternalJwtSignerManager {
|
||||
manager := &ExternalJwtSignerManager{
|
||||
baseEntityManager: newBaseEntityManager[*ExternalJwtSigner, *db.ExternalJwtSigner](env, env.GetStores().ExternalJwtSigner),
|
||||
jwksFetchPolicy: NewJwksFetchPolicy(JwksFetchConfig(env)),
|
||||
}
|
||||
manager.impl = manager
|
||||
|
||||
@@ -43,6 +48,10 @@ func NewExternalJwtSignerManager(env Env) *ExternalJwtSignerManager {
|
||||
|
||||
type ExternalJwtSignerManager struct {
|
||||
baseEntityManager[*ExternalJwtSigner, *db.ExternalJwtSigner]
|
||||
|
||||
// jwksFetchPolicy is used to reject a jwksEndpoint at create/update time that could never
|
||||
// be fetched. The same policy governs the fetch itself.
|
||||
jwksFetchPolicy *JwksFetchPolicy
|
||||
}
|
||||
|
||||
func (self *ExternalJwtSignerManager) NewModelEntity() *ExternalJwtSigner {
|
||||
@@ -50,6 +59,10 @@ func (self *ExternalJwtSignerManager) NewModelEntity() *ExternalJwtSigner {
|
||||
}
|
||||
|
||||
func (self *ExternalJwtSignerManager) Create(entity *ExternalJwtSigner, ctx *change.Context) error {
|
||||
if err := self.validateJwksEndpoint(entity); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return DispatchCreate[*ExternalJwtSigner](self, entity, ctx)
|
||||
}
|
||||
|
||||
@@ -59,9 +72,29 @@ func (self *ExternalJwtSignerManager) ApplyCreate(cmd *command.CreateEntityComma
|
||||
}
|
||||
|
||||
func (self *ExternalJwtSignerManager) Update(entity *ExternalJwtSigner, checker fields.UpdatedFields, ctx *change.Context) error {
|
||||
if err := self.validateJwksEndpoint(entity); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return DispatchUpdate[*ExternalJwtSigner](self, entity, checker, ctx)
|
||||
}
|
||||
|
||||
// validateJwksEndpoint rejects a jwksEndpoint that the configured jwks fetch policy could
|
||||
// never fetch, so the operator finds out on create/update rather than on a failed fetch. An
|
||||
// endpoint that passes here can still be refused at fetch time, which is where the
|
||||
// authoritative check lives.
|
||||
func (self *ExternalJwtSignerManager) validateJwksEndpoint(entity *ExternalJwtSigner) error {
|
||||
if entity.JwksEndpoint == nil || strings.TrimSpace(*entity.JwksEndpoint) == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := self.jwksFetchPolicy.ValidateEndpoint(*entity.JwksEndpoint); err != nil {
|
||||
return apierror.NewBadRequestFieldError(*errorz.NewFieldError(err.Error(), db.FieldExternalJwtSignerJwksEndpoint, *entity.JwksEndpoint))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *ExternalJwtSignerManager) ApplyUpdate(cmd *command.UpdateEntityCommand[*ExternalJwtSigner], ctx boltz.MutateContext) error {
|
||||
return self.updateEntity(cmd.Entity, cmd.UpdatedFields, ctx)
|
||||
}
|
||||
|
||||
@@ -1094,6 +1094,14 @@ const (
|
||||
IdentityStateUnknown IdentityOnlineState = 2
|
||||
)
|
||||
|
||||
// identityConnections tracks which routers an identity is currently connected to, along
|
||||
// with the last connectivity state reported for it.
|
||||
//
|
||||
// Two locks protect the connection tracker: the shard lock inside the ConcurrentMap of
|
||||
// these, and the lock on each of these. They must always be acquired in that order,
|
||||
// shard lock first. The cmap Upsert and RemoveCb callbacks run with the shard lock held,
|
||||
// so taking this lock inside one of them is fine; taking the shard lock while already
|
||||
// holding this one is not, and will deadlock.
|
||||
type identityConnections struct {
|
||||
sync.RWMutex
|
||||
routers map[string]ctrlchan.CtrlChannel
|
||||
@@ -1186,19 +1194,36 @@ func (self *ConnectionTracker) ScanForDisconnectedRouters() {
|
||||
}
|
||||
}
|
||||
|
||||
entry.Val.Lock()
|
||||
if len(entry.Val.routers) == 0 {
|
||||
self.connections.RemoveCb(entry.Key, func(key string, v *identityConnections, exists bool) bool {
|
||||
if v != nil {
|
||||
return len(v.routers) == 0
|
||||
}
|
||||
return true
|
||||
})
|
||||
// This check is only a filter, so that we don't take the shard write lock for
|
||||
// every identity on every scan. The entry lock must be released before calling
|
||||
// removeIfEmpty, which takes the shard lock.
|
||||
entry.Val.RLock()
|
||||
empty := len(entry.Val.routers) == 0
|
||||
entry.Val.RUnlock()
|
||||
|
||||
if empty {
|
||||
self.removeIfEmpty(entry.Key)
|
||||
}
|
||||
entry.Val.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// removeIfEmpty drops an identity's connection entry if it has no router connections.
|
||||
// The identity may have reconnected since the caller decided the entry was empty, so the
|
||||
// value currently in the map is what decides, not whatever the caller was looking at.
|
||||
//
|
||||
// This takes the shard lock, so it must not be called while holding an
|
||||
// identityConnections lock. See identityConnections for the lock ordering rules.
|
||||
func (self *ConnectionTracker) removeIfEmpty(identityId string) {
|
||||
self.connections.RemoveCb(identityId, func(key string, v *identityConnections, exists bool) bool {
|
||||
if v == nil {
|
||||
return true
|
||||
}
|
||||
v.RLock()
|
||||
defer v.RUnlock()
|
||||
return len(v.routers) == 0
|
||||
})
|
||||
}
|
||||
|
||||
func (self *ConnectionTracker) MarkConnected(identityId string, ch ctrlchan.CtrlChannel) {
|
||||
pfxlog.Logger().WithField("identityId", identityId).WithField("routerId", ch.PeerId()).Trace("marking identity connected to router")
|
||||
var postUpsertCallback func()
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
/*
|
||||
Copyright NetFoundry Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/openziti/jwks"
|
||||
"github.com/openziti/ziti/v2/controller/config"
|
||||
"github.com/openziti/ziti/v2/controller/db"
|
||||
)
|
||||
|
||||
// builtInBlockedCidrs are always refused for a JWKS fetch and cannot be re-enabled by
|
||||
// configuration. They are the addresses that turn the controller's network position into a
|
||||
// credential oracle, plus the addresses that have no meaning as an IdP endpoint.
|
||||
var builtInBlockedCidrs = []string{
|
||||
"169.254.0.0/16", // IPv4 link-local, includes cloud instance metadata (169.254.169.254) and ECS task metadata (169.254.170.2)
|
||||
"fe80::/10", // IPv6 link-local
|
||||
"fd00:ec2::254/128", // AWS instance metadata over IPv6, inside unique-local space
|
||||
"224.0.0.0/24", // IPv4 link-local multicast
|
||||
"ff02::/16", // IPv6 link-local multicast
|
||||
"0.0.0.0/32", // IPv4 unspecified
|
||||
"::/128", // IPv6 unspecified
|
||||
}
|
||||
|
||||
// JwksFetchConfig returns the [edge.externalJwtSigners.jwksFetch] settings from the
|
||||
// environment, falling back to the defaults when no edge configuration is present.
|
||||
func JwksFetchConfig(env Env) config.JwksFetch {
|
||||
if cfg := env.GetConfig(); cfg != nil && cfg.Edge != nil {
|
||||
return cfg.Edge.ExternalJwtSigners.JwksFetch
|
||||
}
|
||||
|
||||
return config.DefaultJwksFetch()
|
||||
}
|
||||
|
||||
// JwksFetchPolicy decides whether the controller may fetch a given JWKS endpoint. The
|
||||
// endpoint URL is operator-supplied, so without this the controller's network position would
|
||||
// be reachable by whoever can write an external JWT signer.
|
||||
//
|
||||
// A hop is fetched only if it passes both gates, and both gates are applied to the initial
|
||||
// request and to every redirect. Neither gate can authorize what the other refuses: host
|
||||
// matching only ever narrows what may be fetched.
|
||||
//
|
||||
// CheckHostname is the hostname gate, applied to the URL's hostname:
|
||||
//
|
||||
// 1. deniedHostnames - blocked
|
||||
// 2. allowedHostnames - when non-empty and the host does not match, blocked
|
||||
// 3. anything else - passes
|
||||
//
|
||||
// CheckIP is the address gate, applied to the address being connected to, first-match-wins,
|
||||
// deny before allow:
|
||||
//
|
||||
// 1. builtInBlockedCidrs - blocked, not overridable
|
||||
// 2. deniedIPs - blocked, not overridable by allowedIPs
|
||||
// 3. allowedIPs - allowed, a carve-out of tier 4 only
|
||||
// 4. blockPrivateAddresses and the address is private or loopback - blocked
|
||||
// 5. anything else - allowed
|
||||
type JwksFetchPolicy struct {
|
||||
blockPrivateAddresses bool
|
||||
builtInBlocked []*net.IPNet
|
||||
deniedIPs []*net.IPNet
|
||||
allowedIPs []*net.IPNet
|
||||
deniedHostnames []string
|
||||
allowedHostnames []string
|
||||
}
|
||||
|
||||
// NewJwksFetchPolicy returns a JwksFetchPolicy built from the [edge.externalJwtSigners.jwksFetch]
|
||||
// configuration section.
|
||||
func NewJwksFetchPolicy(cfg config.JwksFetch) *JwksFetchPolicy {
|
||||
result := &JwksFetchPolicy{
|
||||
blockPrivateAddresses: cfg.BlockPrivateAddresses,
|
||||
deniedIPs: cfg.DeniedIPs,
|
||||
allowedIPs: cfg.AllowedIPs,
|
||||
deniedHostnames: cfg.DeniedHostnames,
|
||||
allowedHostnames: cfg.AllowedHostnames,
|
||||
}
|
||||
|
||||
for _, cidr := range builtInBlockedCidrs {
|
||||
_, ipNet, err := net.ParseCIDR(cidr)
|
||||
|
||||
if err != nil {
|
||||
// builtInBlockedCidrs is a compile-time constant list, a parse failure is a bug
|
||||
panic(fmt.Errorf("invalid built-in blocked CIDR %s: %w", cidr, err))
|
||||
}
|
||||
|
||||
result.builtInBlocked = append(result.builtInBlocked, ipNet)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// CheckHostname returns nil if the controller may fetch from the given URL hostname, and an
|
||||
// error otherwise. It is applied to the initial endpoint and to every redirect hop.
|
||||
//
|
||||
// Hostname matching narrows only. A hostname that passes here is still subject to CheckIP, and
|
||||
// a caller may be able to reach the same target under a different name, so this is a filter on
|
||||
// top of the address gate rather than a boundary of its own.
|
||||
func (self *JwksFetchPolicy) CheckHostname(hostname string) error {
|
||||
normalized := config.NormalizeHostname(hostname)
|
||||
|
||||
if normalized == "" {
|
||||
return fmt.Errorf("jwks endpoint url must include a hostname")
|
||||
}
|
||||
|
||||
if matchesHostname(self.deniedHostnames, normalized) {
|
||||
return fmt.Errorf("jwks endpoint hostname %s is not permitted, it matches [edge.externalJwtSigners.jwksFetch.deniedHostnames]", normalized)
|
||||
}
|
||||
|
||||
if len(self.allowedHostnames) > 0 && !matchesHostname(self.allowedHostnames, normalized) {
|
||||
return fmt.Errorf("jwks endpoint hostname %s is not permitted, [edge.externalJwtSigners.jwksFetch.allowedHostnames] is set and does not include it", normalized)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// matchesHostname reports whether a normalized hostname matches any of the given normalized
|
||||
// patterns. A pattern is either an exact hostname, or a "*.suffix" wildcard that matches any
|
||||
// subdomain of suffix at any depth but never suffix itself: "*.sub.host.com" matches
|
||||
// "idp.sub.host.com" and "a.b.sub.host.com", but not "sub.host.com".
|
||||
func matchesHostname(patterns []string, hostname string) bool {
|
||||
for _, pattern := range patterns {
|
||||
if suffix, isWildcard := strings.CutPrefix(pattern, "*."); isWildcard {
|
||||
if strings.HasSuffix(hostname, "."+suffix) {
|
||||
return true
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if hostname == pattern {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// CheckIP returns nil if the controller may connect to the given address for a JWKS fetch,
|
||||
// and an error describing which tier refused it otherwise. An address that cannot be
|
||||
// classified is refused.
|
||||
func (self *JwksFetchPolicy) CheckIP(ip net.IP) error {
|
||||
if ip == nil {
|
||||
return fmt.Errorf("jwks endpoint address could not be parsed")
|
||||
}
|
||||
|
||||
// classify IPv4 and IPv4-mapped IPv6 addresses identically
|
||||
if ip4 := ip.To4(); ip4 != nil {
|
||||
ip = ip4
|
||||
}
|
||||
|
||||
if containsIP(self.builtInBlocked, ip) {
|
||||
return fmt.Errorf("jwks endpoint address %s is not permitted, it is a metadata, link-local or unspecified address", ip)
|
||||
}
|
||||
|
||||
if containsIP(self.deniedIPs, ip) {
|
||||
return fmt.Errorf("jwks endpoint address %s is not permitted, it matches [edge.externalJwtSigners.jwksFetch.deniedIPs]", ip)
|
||||
}
|
||||
|
||||
if containsIP(self.allowedIPs, ip) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if self.blockPrivateAddresses && (ip.IsPrivate() || ip.IsLoopback()) {
|
||||
return fmt.Errorf("jwks endpoint address %s is not permitted, private and loopback addresses are blocked by [edge.externalJwtSigners.jwksFetch.blockPrivateAddresses]", ip)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateEndpoint checks an operator-supplied jwksEndpoint URL at create/update time so an
|
||||
// obviously unusable endpoint is reported immediately instead of failing later during a
|
||||
// fetch. It rejects a scheme other than http/https, a missing host, and a host that is a
|
||||
// literal blocked IP address.
|
||||
//
|
||||
// This is advisory: no name resolution happens here, so a hostname that resolves to a blocked
|
||||
// address passes this check and is refused by the dialer at fetch time. The dial-time check
|
||||
// remains the authoritative one.
|
||||
func (self *JwksFetchPolicy) ValidateEndpoint(endpoint string) error {
|
||||
target, err := url.Parse(strings.TrimSpace(endpoint))
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not parse jwks endpoint url: %w", err)
|
||||
}
|
||||
|
||||
if err = validateJwksEndpointScheme(target); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
host := target.Hostname()
|
||||
|
||||
if err = self.CheckHostname(host); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
return self.CheckIP(ip)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkJwksEndpointAllowed returns an error when a signer's jwksEndpoint is one the given
|
||||
// policy refuses. A signer with no jwksEndpoint, or a blank one, is not reported.
|
||||
func checkJwksEndpointAllowed(policy *JwksFetchPolicy, signer *db.ExternalJwtSigner) error {
|
||||
if signer.JwksEndpoint == nil || strings.TrimSpace(*signer.JwksEndpoint) == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return policy.ValidateEndpoint(*signer.JwksEndpoint)
|
||||
}
|
||||
|
||||
// CheckDialAddress applies CheckIP to a host:port address as it is about to be dialed. The
|
||||
// address has already been resolved at that point, which is what makes the check
|
||||
// rebinding-safe: the address that is connected to is the address that is checked.
|
||||
func (self *JwksFetchPolicy) CheckDialAddress(address string) error {
|
||||
host, _, err := net.SplitHostPort(address)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not parse jwks endpoint dial address %s: %w", address, err)
|
||||
}
|
||||
|
||||
return self.CheckIP(net.ParseIP(host))
|
||||
}
|
||||
|
||||
// containsIP reports whether any of the given networks contains the given address.
|
||||
func containsIP(ipNets []*net.IPNet, ip net.IP) bool {
|
||||
for _, ipNet := range ipNets {
|
||||
if ipNet.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
var _ jwks.Resolver = (*HardenedJwksResolver)(nil)
|
||||
|
||||
// HardenedJwksResolver fetches JWKS responses over HTTP(S) with the constraints an
|
||||
// operator-supplied URL requires: a bounded total time, a bounded number of redirects, an
|
||||
// http/https-only scheme, and a JwksFetchPolicy check of every address that is connected to.
|
||||
//
|
||||
// The address check is installed as the dialer's control function, so it is the single choke
|
||||
// point for the first request and for every redirect hop: it runs after DNS resolution and
|
||||
// before the connection is made, which is what a check of the URL's hostname cannot do.
|
||||
type HardenedJwksResolver struct {
|
||||
client *http.Client
|
||||
policy *JwksFetchPolicy
|
||||
}
|
||||
|
||||
// NewHardenedJwksResolver returns a HardenedJwksResolver configured from the
|
||||
// [edge.externalJwtSigners.jwksFetch] configuration section.
|
||||
func NewHardenedJwksResolver(cfg config.JwksFetch) *HardenedJwksResolver {
|
||||
if cfg.Timeout <= 0 {
|
||||
cfg.Timeout = config.DefaultJwksFetchTimeout
|
||||
}
|
||||
|
||||
if cfg.MaxRedirects < 0 {
|
||||
cfg.MaxRedirects = config.DefaultJwksFetchMaxRedirects
|
||||
}
|
||||
|
||||
policy := NewJwksFetchPolicy(cfg)
|
||||
|
||||
dialer := &net.Dialer{
|
||||
Timeout: cfg.Timeout,
|
||||
Control: func(_ string, address string, _ syscall.RawConn) error {
|
||||
return policy.CheckDialAddress(address)
|
||||
},
|
||||
}
|
||||
|
||||
transport := &http.Transport{
|
||||
DialContext: dialer.DialContext,
|
||||
TLSHandshakeTimeout: cfg.Timeout,
|
||||
// JWKS fetches are infrequent, so take a fresh, policy-checked connection every time
|
||||
// rather than reusing one
|
||||
DisableKeepAlives: true,
|
||||
}
|
||||
|
||||
maxRedirects := cfg.MaxRedirects
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: cfg.Timeout,
|
||||
Transport: transport,
|
||||
CheckRedirect: func(request *http.Request, via []*http.Request) error {
|
||||
if len(via) > maxRedirects {
|
||||
return fmt.Errorf("jwks endpoint exceeded the maximum of %d redirect(s)", maxRedirects)
|
||||
}
|
||||
|
||||
if err := validateJwksEndpointScheme(request.URL); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// every hop is checked on its own, an allowed first hop does not carry over
|
||||
return policy.CheckHostname(request.URL.Hostname())
|
||||
},
|
||||
}
|
||||
|
||||
return &HardenedJwksResolver{
|
||||
client: client,
|
||||
policy: policy,
|
||||
}
|
||||
}
|
||||
|
||||
// Get implements jwks.Resolver. It returns the parsed JWKS response and the raw response body.
|
||||
func (self *HardenedJwksResolver) Get(endpoint string) (*jwks.Response, []byte, error) {
|
||||
target, err := url.Parse(strings.TrimSpace(endpoint))
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("could not parse jwks endpoint url: %w", err)
|
||||
}
|
||||
|
||||
if err = validateJwksEndpointScheme(target); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if err = self.policy.CheckHostname(target.Hostname()); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
request, err := http.NewRequest(http.MethodGet, target.String(), nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("could not create jwks endpoint request: %w", err)
|
||||
}
|
||||
|
||||
request.Header.Set("accept", "application/json")
|
||||
|
||||
response, err := self.client.Do(request)
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = response.Body.Close()
|
||||
}()
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return nil, nil, fmt.Errorf("could not fetch JWKS, status code was not 200 OK, got %d", response.StatusCode)
|
||||
}
|
||||
|
||||
contentType := strings.ToLower(strings.TrimSpace(strings.Split(response.Header.Get("content-type"), ";")[0]))
|
||||
|
||||
if contentType != "application/json" && contentType != "application/jwk-set+json" && contentType != "application/jwk+json" {
|
||||
return nil, nil, fmt.Errorf("invalid content type %s, expected application/json", contentType)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(response.Body)
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("could not read jwks response: %w", err)
|
||||
}
|
||||
|
||||
jwksResponse := &jwks.Response{}
|
||||
|
||||
if err = json.Unmarshal(body, jwksResponse); err != nil {
|
||||
return nil, nil, fmt.Errorf("could not parse jwks response: %w", err)
|
||||
}
|
||||
|
||||
return jwksResponse, body, nil
|
||||
}
|
||||
|
||||
// validateJwksEndpointScheme allows only http and https. Anything else either cannot be
|
||||
// fetched or would let the URL scheme choose the transport.
|
||||
func validateJwksEndpointScheme(target *url.URL) error {
|
||||
switch strings.ToLower(target.Scheme) {
|
||||
case "http", "https":
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("invalid jwks endpoint scheme %s, only http and https are supported", target.Scheme)
|
||||
}
|
||||
@@ -0,0 +1,848 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/openziti/ziti/v2/controller/config"
|
||||
"github.com/openziti/ziti/v2/controller/db"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_JwksFetchPolicy_CheckIP(t *testing.T) {
|
||||
t.Run("with default settings", func(t *testing.T) {
|
||||
policy := NewJwksFetchPolicy(config.DefaultJwksFetch())
|
||||
|
||||
t.Run("built-in blocked addresses are blocked", func(t *testing.T) {
|
||||
blocked := []string{
|
||||
"169.254.169.254", // cloud instance metadata
|
||||
"169.254.170.2", // ECS task metadata
|
||||
"fd00:ec2::254", // AWS IMDS over IPv6
|
||||
"169.254.10.10", // link-local
|
||||
"fe80::1", // link-local
|
||||
"224.0.0.1", // link-local multicast
|
||||
"ff02::1", // link-local multicast
|
||||
"0.0.0.0", // unspecified
|
||||
"::", // unspecified
|
||||
}
|
||||
|
||||
for _, address := range blocked {
|
||||
t.Run(address, func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
err := policy.CheckIP(net.ParseIP(address))
|
||||
|
||||
req.Error(err, "%s must be blocked by the built-in tier", address)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("private and loopback addresses are allowed, the default posture is compatible", func(t *testing.T) {
|
||||
allowed := []string{"127.0.0.1", "::1", "10.1.2.3", "172.16.0.1", "192.168.1.1", "fc00::1"}
|
||||
|
||||
for _, address := range allowed {
|
||||
t.Run(address, func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
req.NoError(policy.CheckIP(net.ParseIP(address)))
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a public address is allowed", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
req.NoError(policy.CheckIP(net.ParseIP("93.184.216.34")))
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("with blockPrivateAddresses enabled", func(t *testing.T) {
|
||||
jwksFetch := config.DefaultJwksFetch()
|
||||
jwksFetch.BlockPrivateAddresses = true
|
||||
|
||||
policy := NewJwksFetchPolicy(jwksFetch)
|
||||
|
||||
t.Run("private and loopback addresses are blocked", func(t *testing.T) {
|
||||
blocked := []string{"127.0.0.1", "::1", "10.1.2.3", "172.16.0.1", "192.168.1.1", "fc00::1"}
|
||||
|
||||
for _, address := range blocked {
|
||||
t.Run(address, func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
req.Error(policy.CheckIP(net.ParseIP(address)))
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a public address is still allowed", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
req.NoError(policy.CheckIP(net.ParseIP("93.184.216.34")))
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("deniedIPs blocks an otherwise allowed public address", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
jwksFetch := config.DefaultJwksFetch()
|
||||
jwksFetch.DeniedIPs = mustCidrs(t, "203.0.113.0/24")
|
||||
|
||||
policy := NewJwksFetchPolicy(jwksFetch)
|
||||
|
||||
req.Error(policy.CheckIP(net.ParseIP("203.0.113.5")))
|
||||
req.NoError(policy.CheckIP(net.ParseIP("203.0.114.5")))
|
||||
})
|
||||
|
||||
t.Run("allowedIPs carves an exception out of blockPrivateAddresses", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
jwksFetch := config.DefaultJwksFetch()
|
||||
jwksFetch.BlockPrivateAddresses = true
|
||||
jwksFetch.AllowedIPs = mustCidrs(t, "10.1.0.0/16")
|
||||
|
||||
policy := NewJwksFetchPolicy(jwksFetch)
|
||||
|
||||
req.NoError(policy.CheckIP(net.ParseIP("10.1.2.3")), "the carve-out address must be reachable")
|
||||
req.Error(policy.CheckIP(net.ParseIP("10.2.2.3")), "a private address outside the carve-out must stay blocked")
|
||||
})
|
||||
|
||||
t.Run("allowedIPs cannot override the built-in blocked tier", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
jwksFetch := config.DefaultJwksFetch()
|
||||
jwksFetch.AllowedIPs = mustCidrs(t, "169.254.0.0/16", "fd00:ec2::/64")
|
||||
|
||||
policy := NewJwksFetchPolicy(jwksFetch)
|
||||
|
||||
req.Error(policy.CheckIP(net.ParseIP("169.254.169.254")), "the metadata service must never be reachable")
|
||||
req.Error(policy.CheckIP(net.ParseIP("fd00:ec2::254")), "the IPv6 metadata service must never be reachable")
|
||||
})
|
||||
|
||||
t.Run("allowedIPs cannot override deniedIPs", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
jwksFetch := config.DefaultJwksFetch()
|
||||
jwksFetch.DeniedIPs = mustCidrs(t, "10.0.0.0/8")
|
||||
jwksFetch.AllowedIPs = mustCidrs(t, "10.1.2.3/32")
|
||||
|
||||
policy := NewJwksFetchPolicy(jwksFetch)
|
||||
|
||||
req.Error(policy.CheckIP(net.ParseIP("10.1.2.3")), "deny wins over allow")
|
||||
})
|
||||
|
||||
t.Run("an IPv4-mapped IPv6 address is classified as its IPv4 address", func(t *testing.T) {
|
||||
t.Run("a mapped metadata address is blocked", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
policy := NewJwksFetchPolicy(config.DefaultJwksFetch())
|
||||
|
||||
req.Error(policy.CheckIP(net.ParseIP("::ffff:169.254.169.254")))
|
||||
})
|
||||
|
||||
t.Run("a mapped private address is blocked when blockPrivateAddresses is set", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
jwksFetch := config.DefaultJwksFetch()
|
||||
jwksFetch.BlockPrivateAddresses = true
|
||||
|
||||
policy := NewJwksFetchPolicy(jwksFetch)
|
||||
|
||||
req.Error(policy.CheckIP(net.ParseIP("::ffff:10.1.2.3")))
|
||||
})
|
||||
|
||||
t.Run("a mapped address matches an IPv4 allowedIPs carve-out", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
jwksFetch := config.DefaultJwksFetch()
|
||||
jwksFetch.BlockPrivateAddresses = true
|
||||
jwksFetch.AllowedIPs = mustCidrs(t, "10.1.0.0/16")
|
||||
|
||||
policy := NewJwksFetchPolicy(jwksFetch)
|
||||
|
||||
req.NoError(policy.CheckIP(net.ParseIP("::ffff:10.1.2.3")))
|
||||
})
|
||||
|
||||
t.Run("a mapped address matches an IPv4 deniedIPs entry", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
jwksFetch := config.DefaultJwksFetch()
|
||||
jwksFetch.DeniedIPs = mustCidrs(t, "203.0.113.0/24")
|
||||
|
||||
policy := NewJwksFetchPolicy(jwksFetch)
|
||||
|
||||
req.Error(policy.CheckIP(net.ParseIP("::ffff:203.0.113.5")))
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("an unparsable address is blocked", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
policy := NewJwksFetchPolicy(config.DefaultJwksFetch())
|
||||
|
||||
req.Error(policy.CheckIP(nil), "an address that could not be parsed must never be treated as allowed")
|
||||
})
|
||||
}
|
||||
|
||||
func Test_JwksFetchPolicy_CheckHostname(t *testing.T) {
|
||||
t.Run("with no host lists configured every host passes", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
policy := NewJwksFetchPolicy(config.DefaultJwksFetch())
|
||||
|
||||
req.NoError(policy.CheckHostname("idp.example.com"))
|
||||
req.NoError(policy.CheckHostname("10.0.0.5"))
|
||||
})
|
||||
|
||||
t.Run("an empty host is blocked", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
policy := NewJwksFetchPolicy(config.DefaultJwksFetch())
|
||||
|
||||
req.Error(policy.CheckHostname(""))
|
||||
})
|
||||
|
||||
t.Run("deniedHostnames blocks a matching host", func(t *testing.T) {
|
||||
jwksFetch := config.DefaultJwksFetch()
|
||||
jwksFetch.DeniedHostnames = []string{"blocked.example.com", "*.internal.example.com"}
|
||||
|
||||
policy := NewJwksFetchPolicy(jwksFetch)
|
||||
|
||||
t.Run("an exact match is blocked", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
req.Error(policy.CheckHostname("blocked.example.com"))
|
||||
})
|
||||
|
||||
t.Run("a wildcard suffix match is blocked", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
req.Error(policy.CheckHostname("idp.internal.example.com"))
|
||||
req.Error(policy.CheckHostname("deep.idp.internal.example.com"))
|
||||
})
|
||||
|
||||
t.Run("the wildcard suffix itself is not matched", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
req.NoError(policy.CheckHostname("internal.example.com"), "*.internal.example.com covers subdomains only")
|
||||
})
|
||||
|
||||
t.Run("a wildcard matches whole labels only", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
// xinternal.example.com ends with internal.example.com as a string, but the
|
||||
// wildcard replaces a whole label, so it is a different host
|
||||
req.NoError(policy.CheckHostname("xinternal.example.com"))
|
||||
req.NoError(policy.CheckHostname("notinternal.example.com"))
|
||||
})
|
||||
|
||||
t.Run("an unlisted host passes", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
req.NoError(policy.CheckHostname("idp.example.com"))
|
||||
})
|
||||
|
||||
t.Run("matching is case insensitive and ignores a trailing dot", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
req.Error(policy.CheckHostname("BLOCKED.example.com"))
|
||||
req.Error(policy.CheckHostname("blocked.example.com."))
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("allowedHostnames is exclusive when set", func(t *testing.T) {
|
||||
jwksFetch := config.DefaultJwksFetch()
|
||||
jwksFetch.AllowedHostnames = []string{"idp.example.com", "*.idp.example.org"}
|
||||
|
||||
policy := NewJwksFetchPolicy(jwksFetch)
|
||||
|
||||
t.Run("a listed host passes", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
req.NoError(policy.CheckHostname("idp.example.com"))
|
||||
req.NoError(policy.CheckHostname("eu.idp.example.org"))
|
||||
})
|
||||
|
||||
t.Run("an unlisted host is blocked", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
req.Error(policy.CheckHostname("evil.example.com"))
|
||||
})
|
||||
|
||||
t.Run("a literal IP host is blocked", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
req.Error(policy.CheckHostname("93.184.216.34"), "an allowedHostnames list can only be satisfied by a name")
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("deniedHostnames wins over allowedHostnames", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
jwksFetch := config.DefaultJwksFetch()
|
||||
jwksFetch.DeniedHostnames = []string{"old.idp.example.com"}
|
||||
jwksFetch.AllowedHostnames = []string{"*.idp.example.com"}
|
||||
|
||||
policy := NewJwksFetchPolicy(jwksFetch)
|
||||
|
||||
req.Error(policy.CheckHostname("old.idp.example.com"))
|
||||
req.NoError(policy.CheckHostname("new.idp.example.com"))
|
||||
})
|
||||
|
||||
t.Run("an allowed host does not authorize a blocked address", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
jwksFetch := config.DefaultJwksFetch()
|
||||
jwksFetch.AllowedHostnames = []string{"idp.example.com"}
|
||||
|
||||
policy := NewJwksFetchPolicy(jwksFetch)
|
||||
|
||||
req.NoError(policy.CheckHostname("idp.example.com"))
|
||||
req.Error(policy.CheckIP(net.ParseIP("169.254.169.254")), "the hostname gate must never widen the address gate")
|
||||
})
|
||||
}
|
||||
|
||||
func Test_JwksFetchPolicy_ValidateEndpoint(t *testing.T) {
|
||||
t.Run("with default settings", func(t *testing.T) {
|
||||
policy := NewJwksFetchPolicy(config.DefaultJwksFetch())
|
||||
|
||||
t.Run("an http or https endpoint is accepted", func(t *testing.T) {
|
||||
endpoints := []string{
|
||||
"https://idp.example.com/.well-known/jwks.json",
|
||||
"http://idp.example.com/.well-known/jwks.json",
|
||||
"HTTPS://idp.example.com/jwks",
|
||||
}
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
t.Run(endpoint, func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
req.NoError(policy.ValidateEndpoint(endpoint))
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a non-http scheme is rejected", func(t *testing.T) {
|
||||
endpoints := []string{"file:///etc/passwd", "ftp://idp.example.com/jwks", "idp.example.com/jwks", ""}
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
t.Run(endpoint, func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
req.Error(policy.ValidateEndpoint(endpoint))
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an endpoint without a host is rejected", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
req.Error(policy.ValidateEndpoint("http:///jwks"))
|
||||
})
|
||||
|
||||
t.Run("a literal metadata address is rejected", func(t *testing.T) {
|
||||
endpoints := []string{
|
||||
"http://169.254.169.254/latest/meta-data/",
|
||||
"http://169.254.170.2/v2/credentials",
|
||||
"http://[fd00:ec2::254]/latest/meta-data/",
|
||||
"http://169.254.169.254:8080/jwks",
|
||||
}
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
t.Run(endpoint, func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
req.Error(policy.ValidateEndpoint(endpoint))
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a literal private address is accepted, the default posture allows it", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
req.NoError(policy.ValidateEndpoint("https://10.1.2.3/jwks"))
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("with blockPrivateAddresses enabled", func(t *testing.T) {
|
||||
jwksFetch := config.DefaultJwksFetch()
|
||||
jwksFetch.BlockPrivateAddresses = true
|
||||
|
||||
policy := NewJwksFetchPolicy(jwksFetch)
|
||||
|
||||
t.Run("a literal private address is rejected", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
req.Error(policy.ValidateEndpoint("https://10.1.2.3/jwks"))
|
||||
})
|
||||
|
||||
t.Run("a hostname is accepted, the dial-time check remains authoritative", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
// no DNS resolution happens at create/update time, so a hostname that resolves to
|
||||
// a blocked address is caught by the dialer instead
|
||||
req.NoError(policy.ValidateEndpoint("https://localhost/jwks"))
|
||||
})
|
||||
|
||||
t.Run("a literal private address in allowedIPs is accepted", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
carveOut := config.DefaultJwksFetch()
|
||||
carveOut.BlockPrivateAddresses = true
|
||||
carveOut.AllowedIPs = mustCidrs(t, "10.1.0.0/16")
|
||||
|
||||
req.NoError(NewJwksFetchPolicy(carveOut).ValidateEndpoint("https://10.1.2.3/jwks"))
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("with host lists configured", func(t *testing.T) {
|
||||
jwksFetch := config.DefaultJwksFetch()
|
||||
jwksFetch.DeniedHostnames = []string{"old.idp.example.com"}
|
||||
jwksFetch.AllowedHostnames = []string{"idp.example.com", "*.idp.example.org"}
|
||||
|
||||
policy := NewJwksFetchPolicy(jwksFetch)
|
||||
|
||||
t.Run("an allowed host is accepted", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
req.NoError(policy.ValidateEndpoint("https://idp.example.com/.well-known/jwks.json"))
|
||||
req.NoError(policy.ValidateEndpoint("https://eu.idp.example.org/jwks"))
|
||||
})
|
||||
|
||||
t.Run("a host outside allowedHostnames is rejected", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
req.Error(policy.ValidateEndpoint("https://other.example.com/jwks"))
|
||||
})
|
||||
|
||||
t.Run("a denied host is rejected", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
req.Error(policy.ValidateEndpoint("https://old.idp.example.com/jwks"))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func Test_checkJwksEndpointAllowed(t *testing.T) {
|
||||
jwksFetch := config.DefaultJwksFetch()
|
||||
jwksFetch.AllowedHostnames = []string{"idp.example.com"}
|
||||
|
||||
policy := NewJwksFetchPolicy(jwksFetch)
|
||||
|
||||
t.Run("a signer without a jwks endpoint is not reported", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
req.NoError(checkJwksEndpointAllowed(policy, &db.ExternalJwtSigner{}))
|
||||
})
|
||||
|
||||
t.Run("a signer with a blank jwks endpoint is not reported", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
endpoint := " "
|
||||
|
||||
req.NoError(checkJwksEndpointAllowed(policy, &db.ExternalJwtSigner{JwksEndpoint: &endpoint}))
|
||||
})
|
||||
|
||||
t.Run("a signer with an allowed jwks endpoint is not reported", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
endpoint := "https://idp.example.com/jwks"
|
||||
|
||||
req.NoError(checkJwksEndpointAllowed(policy, &db.ExternalJwtSigner{JwksEndpoint: &endpoint}))
|
||||
})
|
||||
|
||||
t.Run("a signer whose jwks endpoint the configuration now refuses is reported", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
// the signer was created before the host list was set, so it is orphaned by the
|
||||
// current configuration and the operator needs to know at startup
|
||||
endpoint := "https://old-idp.example.com/jwks"
|
||||
|
||||
err := checkJwksEndpointAllowed(policy, &db.ExternalJwtSigner{JwksEndpoint: &endpoint})
|
||||
|
||||
req.Error(err)
|
||||
req.Contains(err.Error(), "allowedHostnames")
|
||||
})
|
||||
|
||||
t.Run("a signer whose jwks endpoint is a blocked address is reported", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
endpoint := "http://169.254.169.254/latest/meta-data/"
|
||||
|
||||
req.Error(checkJwksEndpointAllowed(NewJwksFetchPolicy(config.DefaultJwksFetch()),
|
||||
&db.ExternalJwtSigner{JwksEndpoint: &endpoint}))
|
||||
})
|
||||
}
|
||||
|
||||
func Test_HardenedJwksResolver_Get(t *testing.T) {
|
||||
const jwksBody = `{"keys":[{"kty":"RSA","kid":"test-kid","n":"AQAB","e":"AQAB"}]}`
|
||||
|
||||
t.Run("a valid JWKS endpoint resolves", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
server := newTestJwksServer(t, jwksBody)
|
||||
|
||||
resolver := NewHardenedJwksResolver(config.DefaultJwksFetch())
|
||||
|
||||
response, body, err := resolver.Get(server.URL + "/jwks")
|
||||
|
||||
req.NoError(err)
|
||||
req.Len(response.Keys, 1)
|
||||
req.Equal("test-kid", response.Keys[0].KeyId)
|
||||
req.Equal(jwksBody, string(body))
|
||||
})
|
||||
|
||||
t.Run("a blocked address is refused without contacting the endpoint", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
server := newTestJwksServer(t, jwksBody)
|
||||
|
||||
jwksFetch := config.DefaultJwksFetch()
|
||||
jwksFetch.BlockPrivateAddresses = true
|
||||
|
||||
resolver := NewHardenedJwksResolver(jwksFetch)
|
||||
|
||||
_, _, err := resolver.Get(server.URL + "/jwks")
|
||||
|
||||
req.Error(err)
|
||||
req.Contains(err.Error(), "is not permitted")
|
||||
req.Zero(server.requestCount(), "the request must be stopped before it reaches the endpoint")
|
||||
})
|
||||
|
||||
t.Run("a hostname that resolves to a blocked address is refused at dial time", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
server := newTestJwksServer(t, jwksBody)
|
||||
|
||||
// the URL host is a name, so only the resolved address can be checked - this is the
|
||||
// DNS rebinding case, and it is why enforcement lives in the dialer
|
||||
endpoint, err := server.urlWithHost("localhost")
|
||||
req.NoError(err)
|
||||
|
||||
jwksFetch := config.DefaultJwksFetch()
|
||||
jwksFetch.BlockPrivateAddresses = true
|
||||
|
||||
resolver := NewHardenedJwksResolver(jwksFetch)
|
||||
|
||||
_, _, err = resolver.Get(endpoint)
|
||||
|
||||
req.Error(err)
|
||||
req.Contains(err.Error(), "is not permitted")
|
||||
req.Zero(server.requestCount(), "the request must be stopped before it reaches the endpoint")
|
||||
})
|
||||
|
||||
t.Run("a redirect to a blocked address is refused", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
server := newTestJwksServer(t, jwksBody)
|
||||
|
||||
resolver := NewHardenedJwksResolver(config.DefaultJwksFetch())
|
||||
|
||||
_, _, err := resolver.Get(server.URL + "/redirect-to-metadata")
|
||||
|
||||
req.Error(err, "the metadata address must be blocked on a redirect hop, not just on the first hop")
|
||||
req.Contains(err.Error(), "is not permitted", "the redirect hop must be refused by the address policy")
|
||||
})
|
||||
|
||||
t.Run("a host that is not in allowedHostnames is refused without contacting the endpoint", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
server := newTestJwksServer(t, jwksBody)
|
||||
|
||||
endpoint, err := server.urlWithHost("localhost")
|
||||
req.NoError(err)
|
||||
|
||||
jwksFetch := config.DefaultJwksFetch()
|
||||
jwksFetch.AllowedHostnames = []string{"idp.example.com"}
|
||||
|
||||
resolver := NewHardenedJwksResolver(jwksFetch)
|
||||
|
||||
_, _, err = resolver.Get(endpoint)
|
||||
|
||||
req.Error(err)
|
||||
req.Contains(err.Error(), "allowedHostnames")
|
||||
req.Zero(server.requestCount(), "the request must be stopped before it reaches the endpoint")
|
||||
})
|
||||
|
||||
t.Run("a host in deniedHostnames is refused without contacting the endpoint", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
server := newTestJwksServer(t, jwksBody)
|
||||
|
||||
endpoint, err := server.urlWithHost("localhost")
|
||||
req.NoError(err)
|
||||
|
||||
jwksFetch := config.DefaultJwksFetch()
|
||||
jwksFetch.DeniedHostnames = []string{"localhost"}
|
||||
|
||||
resolver := NewHardenedJwksResolver(jwksFetch)
|
||||
|
||||
_, _, err = resolver.Get(endpoint)
|
||||
|
||||
req.Error(err)
|
||||
req.Contains(err.Error(), "deniedHostnames")
|
||||
req.Zero(server.requestCount())
|
||||
})
|
||||
|
||||
t.Run("a host in allowedHostnames is fetched", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
server := newTestJwksServer(t, jwksBody)
|
||||
|
||||
endpoint, err := server.urlWithHost("localhost")
|
||||
req.NoError(err)
|
||||
|
||||
jwksFetch := config.DefaultJwksFetch()
|
||||
jwksFetch.AllowedHostnames = []string{"localhost"}
|
||||
|
||||
resolver := NewHardenedJwksResolver(jwksFetch)
|
||||
|
||||
response, _, err := resolver.Get(endpoint)
|
||||
|
||||
req.NoError(err)
|
||||
req.Len(response.Keys, 1)
|
||||
})
|
||||
|
||||
t.Run("a redirect to a host that is not in allowedHostnames is refused", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
server := newTestJwksServer(t, jwksBody)
|
||||
|
||||
endpoint, err := server.urlWithHost("localhost")
|
||||
req.NoError(err)
|
||||
|
||||
jwksFetch := config.DefaultJwksFetch()
|
||||
jwksFetch.AllowedHostnames = []string{"localhost"}
|
||||
|
||||
resolver := NewHardenedJwksResolver(jwksFetch)
|
||||
|
||||
// the first hop is an allowed host, the redirect target is not - each hop is checked
|
||||
// on its own
|
||||
_, _, err = resolver.Get(strings.Replace(endpoint, "/jwks", "/redirect-to-unlisted-host", 1))
|
||||
|
||||
req.Error(err)
|
||||
req.Contains(err.Error(), "allowedHostnames")
|
||||
})
|
||||
|
||||
t.Run("a redirect within the cap is followed", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
server := newTestJwksServer(t, jwksBody)
|
||||
|
||||
resolver := NewHardenedJwksResolver(config.DefaultJwksFetch())
|
||||
|
||||
response, _, err := resolver.Get(server.URL + "/redirect-to-jwks")
|
||||
|
||||
req.NoError(err)
|
||||
req.Len(response.Keys, 1)
|
||||
})
|
||||
|
||||
t.Run("exceeding maxRedirects is an error", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
server := newTestJwksServer(t, jwksBody)
|
||||
|
||||
jwksFetch := config.DefaultJwksFetch()
|
||||
jwksFetch.MaxRedirects = 2
|
||||
|
||||
resolver := NewHardenedJwksResolver(jwksFetch)
|
||||
|
||||
_, _, err := resolver.Get(server.URL + "/redirect-loop")
|
||||
|
||||
req.Error(err)
|
||||
req.Contains(err.Error(), "redirect")
|
||||
})
|
||||
|
||||
t.Run("maxRedirects of zero refuses to follow any redirect", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
server := newTestJwksServer(t, jwksBody)
|
||||
|
||||
jwksFetch := config.DefaultJwksFetch()
|
||||
jwksFetch.MaxRedirects = 0
|
||||
|
||||
resolver := NewHardenedJwksResolver(jwksFetch)
|
||||
|
||||
_, _, err := resolver.Get(server.URL + "/redirect-to-jwks")
|
||||
|
||||
req.Error(err)
|
||||
req.Contains(err.Error(), "redirect")
|
||||
})
|
||||
|
||||
t.Run("a slow endpoint is cut off at the timeout", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
server := newTestJwksServer(t, jwksBody)
|
||||
|
||||
jwksFetch := config.DefaultJwksFetch()
|
||||
jwksFetch.Timeout = 100 * time.Millisecond
|
||||
|
||||
resolver := NewHardenedJwksResolver(jwksFetch)
|
||||
|
||||
start := time.Now()
|
||||
_, _, err := resolver.Get(server.URL + "/hang")
|
||||
elapsed := time.Since(start)
|
||||
|
||||
req.Error(err)
|
||||
req.Less(elapsed, 10*time.Second, "the fetch must be bounded by the configured timeout")
|
||||
})
|
||||
|
||||
t.Run("a non-http scheme is refused", func(t *testing.T) {
|
||||
endpoints := []string{
|
||||
"file:///etc/passwd",
|
||||
"ftp://example.com/jwks",
|
||||
"gopher://example.com:70/jwks",
|
||||
"/no/scheme/at/all",
|
||||
}
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
t.Run(endpoint, func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
resolver := NewHardenedJwksResolver(config.DefaultJwksFetch())
|
||||
|
||||
_, _, err := resolver.Get(endpoint)
|
||||
|
||||
req.Error(err)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an unparsable url is refused", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
resolver := NewHardenedJwksResolver(config.DefaultJwksFetch())
|
||||
|
||||
_, _, err := resolver.Get("http://[::1")
|
||||
|
||||
req.Error(err)
|
||||
})
|
||||
|
||||
t.Run("a non-200 status is an error", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
server := newTestJwksServer(t, jwksBody)
|
||||
|
||||
resolver := NewHardenedJwksResolver(config.DefaultJwksFetch())
|
||||
|
||||
_, _, err := resolver.Get(server.URL + "/not-found")
|
||||
|
||||
req.Error(err)
|
||||
})
|
||||
|
||||
t.Run("a non-json content type is an error", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
server := newTestJwksServer(t, jwksBody)
|
||||
|
||||
resolver := NewHardenedJwksResolver(config.DefaultJwksFetch())
|
||||
|
||||
_, _, err := resolver.Get(server.URL + "/html")
|
||||
|
||||
req.Error(err)
|
||||
})
|
||||
|
||||
t.Run("a body that is not a JWKS response is an error", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
server := newTestJwksServer(t, jwksBody)
|
||||
|
||||
resolver := NewHardenedJwksResolver(config.DefaultJwksFetch())
|
||||
|
||||
_, _, err := resolver.Get(server.URL + "/not-json")
|
||||
|
||||
req.Error(err)
|
||||
})
|
||||
}
|
||||
|
||||
// testJwksServer is a local endpoint that serves the routes the resolver tests exercise and
|
||||
// counts the requests that reach it.
|
||||
type testJwksServer struct {
|
||||
*httptest.Server
|
||||
requests atomic.Int32
|
||||
}
|
||||
|
||||
// requestCount returns how many requests reached the server.
|
||||
func (self *testJwksServer) requestCount() int {
|
||||
return int(self.requests.Load())
|
||||
}
|
||||
|
||||
// urlWithHost returns the server's URL with its host replaced, keeping the port.
|
||||
func (self *testJwksServer) urlWithHost(host string) (string, error) {
|
||||
parsed, err := url.Parse(self.URL)
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
parsed.Host = net.JoinHostPort(host, parsed.Port())
|
||||
|
||||
return parsed.String() + "/jwks", nil
|
||||
}
|
||||
|
||||
// newTestJwksServer starts a JWKS endpoint for the duration of the test.
|
||||
func newTestJwksServer(t *testing.T, jwksBody string) *testJwksServer {
|
||||
result := &testJwksServer{}
|
||||
|
||||
// released on test cleanup so the /hang route does not outlive the test
|
||||
done := make(chan struct{})
|
||||
|
||||
result.Server = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
result.requests.Add(1)
|
||||
|
||||
switch request.URL.Path {
|
||||
case "/jwks":
|
||||
writer.Header().Set("content-type", "application/json")
|
||||
_, _ = writer.Write([]byte(jwksBody))
|
||||
case "/redirect-to-jwks":
|
||||
http.Redirect(writer, request, "/jwks", http.StatusFound)
|
||||
case "/redirect-loop":
|
||||
http.Redirect(writer, request, "/redirect-loop", http.StatusFound)
|
||||
case "/redirect-to-metadata":
|
||||
http.Redirect(writer, request, "http://169.254.169.254/latest/meta-data/", http.StatusFound)
|
||||
case "/redirect-to-unlisted-host":
|
||||
http.Redirect(writer, request, "http://unlisted.example.com/jwks", http.StatusFound)
|
||||
case "/hang":
|
||||
<-done
|
||||
case "/html":
|
||||
writer.Header().Set("content-type", "text/html")
|
||||
_, _ = writer.Write([]byte("<html></html>"))
|
||||
case "/not-json":
|
||||
writer.Header().Set("content-type", "application/json")
|
||||
_, _ = writer.Write([]byte("this is not json"))
|
||||
default:
|
||||
writer.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
|
||||
t.Cleanup(func() {
|
||||
close(done)
|
||||
result.Close()
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// mustCidrs parses CIDRs for test setup, failing the test if any cannot be parsed.
|
||||
func mustCidrs(t *testing.T, values ...string) []*net.IPNet {
|
||||
t.Helper()
|
||||
|
||||
var result []*net.IPNet
|
||||
|
||||
for _, value := range values {
|
||||
_, ipNet, err := net.ParseCIDR(value)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("could not parse test CIDR %s: %v", value, err)
|
||||
}
|
||||
|
||||
result = append(result, ipNet)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -27,6 +27,9 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/openziti/channel/v4/protobufs"
|
||||
"github.com/openziti/channel/v4"
|
||||
"github.com/openziti/ziti/v2/common/concurrency"
|
||||
"github.com/openziti/ziti/v2/common/ctrlchan"
|
||||
"github.com/openziti/ziti/v2/common/inspect"
|
||||
"github.com/openziti/ziti/v2/common/pb/cmd_pb"
|
||||
"github.com/openziti/ziti/v2/common/pb/ctrl_pb"
|
||||
@@ -38,9 +41,9 @@ import (
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
"github.com/michaelquigley/pfxlog"
|
||||
"github.com/openziti/ziti/v2/controller/storage/boltz"
|
||||
"github.com/openziti/ziti/v2/controller/db"
|
||||
"github.com/openziti/ziti/v2/controller/models"
|
||||
"github.com/openziti/ziti/v2/controller/storage/boltz"
|
||||
cmap "github.com/orcaman/concurrent-map/v2"
|
||||
"github.com/pkg/errors"
|
||||
"go.etcd.io/bbolt"
|
||||
@@ -79,8 +82,9 @@ func NewRouter(id, name, fingerprint string, cost uint16, noTraversal bool) *Rou
|
||||
|
||||
type RouterManager struct {
|
||||
baseEntityManager[*Router, *db.Router]
|
||||
cache cmap.ConcurrentMap[string, *Router]
|
||||
connected cmap.ConcurrentMap[string, *Router]
|
||||
cache cmap.ConcurrentMap[string, *Router]
|
||||
connected cmap.ConcurrentMap[string, *Router]
|
||||
connectLocks *concurrency.StripedIdLocker
|
||||
}
|
||||
|
||||
func newRouterManager(env Env) *RouterManager {
|
||||
@@ -89,6 +93,7 @@ func newRouterManager(env Env) *RouterManager {
|
||||
baseEntityManager: newBaseEntityManager[*Router, *db.Router](env, routerStore),
|
||||
cache: cmap.New[*Router](),
|
||||
connected: cmap.New[*Router](),
|
||||
connectLocks: concurrency.NewStripedIdLocker(256),
|
||||
}
|
||||
result.impl = result
|
||||
|
||||
@@ -115,28 +120,61 @@ func (self *RouterManager) NewModelEntity() *Router {
|
||||
return &Router{}
|
||||
}
|
||||
|
||||
func (self *RouterManager) MarkConnected(r *Router) {
|
||||
if router, _ := self.connected.Get(r.Id); router != nil {
|
||||
if ch := router.Control; ch != nil {
|
||||
if err := ch.Close(); err != nil {
|
||||
pfxlog.Logger().WithError(err).Error("error closing control channel")
|
||||
}
|
||||
// LockConnectFor acquires the per-router connect lock for the given router id and returns the unlock
|
||||
// function. It serializes a router's connect and disconnect processing so they cannot interleave. The
|
||||
// returned unlock is idempotent, so a caller may both defer it (as a leak-safety net across all exit
|
||||
// paths) and call it early (e.g. to release before closing a channel outside the lock) without
|
||||
// double-unlocking. The flag is unshared and only ever touched by the goroutine holding the lock, so it
|
||||
// needs no synchronization.
|
||||
func (self *RouterManager) LockConnectFor(id string) func() {
|
||||
rawUnlock := self.connectLocks.LockFor(id)
|
||||
unlocked := false
|
||||
return func() {
|
||||
if !unlocked {
|
||||
unlocked = true
|
||||
rawUnlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MarkConnected publishes r as the current connection for its router id. Callers must serialize with
|
||||
// LockConnectFor and must have already ensured the slot is free (ConnectRouter rejects a busy slot rather
|
||||
// than taking over here), so this only records the connection; it does not close any prior channel.
|
||||
func (self *RouterManager) MarkConnected(r *Router) {
|
||||
r.Connected.Store(true)
|
||||
self.connected.Set(r.Id, r)
|
||||
}
|
||||
|
||||
// MarkDisconnected gives up r's registration, and does nothing at all if r is not the registration holder.
|
||||
//
|
||||
// A connection only owns the state on its own instance, so a connection that has already been replaced must
|
||||
// not clear it. What makes that worth enforcing rather than assuming is where the connected flag is read:
|
||||
// the handler for a router's link reports drops them when it is false, so clearing it for the wrong
|
||||
// connection silences a router that is up and reporting, with nothing to correct it.
|
||||
//
|
||||
// This is all-or-nothing rather than a fix for instances shared between connections. Pointer identity
|
||||
// cannot tell two connections apart when they share an instance, so in that case the shared instance is the
|
||||
// registration holder and its state is cleared here regardless. Only giving each connection its own
|
||||
// instance prevents that.
|
||||
func (self *RouterManager) MarkDisconnected(r *Router) {
|
||||
r.Connected.Store(false)
|
||||
self.connected.RemoveCb(r.Id, func(key string, v *Router, exists bool) bool {
|
||||
removed := self.connected.RemoveCb(r.Id, func(key string, v *Router, exists bool) bool {
|
||||
if exists && v != r {
|
||||
pfxlog.Logger().WithField("routerId", r.Id).Info("router not current connect, not clearing from connected map")
|
||||
return false
|
||||
}
|
||||
return exists
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
// Under the shard lock so the flag and the map entry change together: the link report path looks the
|
||||
// router up and then reads the flag, and would otherwise see a removed entry still marked connected.
|
||||
r.Connected.Store(false)
|
||||
return true
|
||||
})
|
||||
|
||||
if !removed {
|
||||
return
|
||||
}
|
||||
|
||||
r.routerLinks.Clear()
|
||||
}
|
||||
|
||||
@@ -196,6 +234,46 @@ func (self *RouterManager) Exists(id string) (bool, error) {
|
||||
return exists, err
|
||||
}
|
||||
|
||||
// NewCtrlChanRouter builds the Router instance representing one control-channel connection, with the
|
||||
// channel already recorded on it.
|
||||
//
|
||||
// The instance is deliberately kept out of the router cache. The connect path writes connection-scoped
|
||||
// state onto it (Control, ConnectTime, VersionInfo, Connected, links), and the controller decides which of
|
||||
// two racing connections for a router is current by comparing instances. Handing a cached instance to a
|
||||
// second connection makes the two indistinguishable: neither the connect path's occupied-slot check nor the
|
||||
// disconnect path's currency check can tell them apart, so a connection that dies takes its replacement's
|
||||
// registration down with it, and each connection's writes land on the other's state.
|
||||
//
|
||||
// Read, by contrast, is cache-backed and returns router entity data. Connection state belongs to
|
||||
// GetConnected, not to Read.
|
||||
func (self *RouterManager) NewCtrlChanRouter(ch channel.Channel) (*Router, error) {
|
||||
r, err := self.readUncached(ch.Id())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r == nil {
|
||||
return nil, fmt.Errorf("no router with id [%v] found", ch.Id())
|
||||
}
|
||||
|
||||
multiCh, ok := ch.(channel.MultiChannel)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("control channel for router [%v] is not a multi-channel, got %T", ch.Id(), ch)
|
||||
}
|
||||
|
||||
ctrlCh, ok := multiCh.GetUnderlayHandler().(ctrlchan.CtrlChannel)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("control channel for router [%v] has unexpected underlay handler type %T", ch.Id(), multiCh.GetUnderlayHandler())
|
||||
}
|
||||
|
||||
r.Control = ctrlCh
|
||||
r.ConnectTime = time.Now()
|
||||
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// readUncached reads a router straight from the database, bypassing the cache in both directions: it
|
||||
// neither reads a cached instance nor publishes the one it creates. Callers needing an instance they can
|
||||
// own get it here.
|
||||
func (self *RouterManager) readUncached(id string) (*Router, error) {
|
||||
entity := &Router{}
|
||||
err := self.GetDb().View(func(tx *bbolt.Tx) error {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
Copyright NetFoundry Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
cmap "github.com/orcaman/concurrent-map/v2"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newMarkDisconnectedTestManager() *RouterManager {
|
||||
return &RouterManager{connected: cmap.New[*Router]()}
|
||||
}
|
||||
|
||||
// TestMarkDisconnected_LeavesStateOfNonHolderAlone: a connection owns only the state on its own instance, so
|
||||
// one that has already been replaced must not clear it. The connected flag decides whether the controller
|
||||
// accepts that router's link reports, so clearing it for the wrong connection silences a router that is up
|
||||
// and reporting with nothing to correct it.
|
||||
func TestMarkDisconnected_LeavesStateOfNonHolderAlone(t *testing.T) {
|
||||
mgr := newMarkDisconnectedTestManager()
|
||||
|
||||
superseded := NewRouter("r1", "r1", "", 0, false)
|
||||
current := NewRouter("r1", "r1", "", 0, false)
|
||||
|
||||
superseded.Connected.Store(true)
|
||||
superseded.routerLinks.Add(&Link{Id: "l1", DstId: "dst"}, "dst")
|
||||
|
||||
mgr.MarkConnected(current)
|
||||
|
||||
mgr.MarkDisconnected(superseded)
|
||||
|
||||
require.Same(t, current, mgr.GetConnected("r1"), "the registration holder must be left in place")
|
||||
require.True(t, current.Connected.Load(), "the holder's connected flag must not be cleared")
|
||||
require.True(t, superseded.Connected.Load(),
|
||||
"a non-holder's state is not this call's to clear")
|
||||
require.Len(t, superseded.GetLinks(), 1, "a non-holder's links are not this call's to clear")
|
||||
}
|
||||
|
||||
// TestMarkDisconnected_ClearsStateOfHolder is the ordinary case: the registration holder disconnecting gives
|
||||
// up the registration and its per-connection state together.
|
||||
func TestMarkDisconnected_ClearsStateOfHolder(t *testing.T) {
|
||||
mgr := newMarkDisconnectedTestManager()
|
||||
|
||||
current := NewRouter("r1", "r1", "", 0, false)
|
||||
current.routerLinks.Add(&Link{Id: "l1", DstId: "dst"}, "dst")
|
||||
mgr.MarkConnected(current)
|
||||
require.True(t, current.Connected.Load())
|
||||
|
||||
mgr.MarkDisconnected(current)
|
||||
|
||||
require.Nil(t, mgr.GetConnected("r1"), "the registration must be given up")
|
||||
require.False(t, current.Connected.Load(), "the holder's connected flag must be cleared")
|
||||
require.Empty(t, current.GetLinks(), "the holder's links must be cleared")
|
||||
}
|
||||
|
||||
// TestMarkDisconnected_UnregisteredIsANoop: a connection that never registered, or whose registration is
|
||||
// already gone, has nothing to give up.
|
||||
func TestMarkDisconnected_UnregisteredIsANoop(t *testing.T) {
|
||||
mgr := newMarkDisconnectedTestManager()
|
||||
|
||||
r := NewRouter("r1", "r1", "", 0, false)
|
||||
r.Connected.Store(true)
|
||||
|
||||
mgr.MarkDisconnected(r)
|
||||
|
||||
require.Nil(t, mgr.GetConnected("r1"))
|
||||
require.True(t, r.Connected.Load(), "with no registration to give up there is nothing to clear")
|
||||
}
|
||||
@@ -45,6 +45,9 @@ type Router struct {
|
||||
Control ctrlchan.CtrlChannel
|
||||
Connected atomic.Bool
|
||||
ConnectTime time.Time
|
||||
// VersionInfo is reported in the router's hello and is not persisted, so it is only populated on the
|
||||
// instance built for a control-channel connection. It is nil on an instance loaded from the database.
|
||||
// Read it from GetConnected rather than from whatever instance is to hand.
|
||||
VersionInfo *versions.VersionInfo
|
||||
routerLinks RouterLinks
|
||||
Cost uint16
|
||||
|
||||
@@ -32,10 +32,10 @@ import (
|
||||
nfPem "github.com/openziti/foundation/v2/pem"
|
||||
"github.com/openziti/foundation/v2/stringz"
|
||||
"github.com/openziti/jwks"
|
||||
"github.com/openziti/ziti/v2/controller/storage/boltz"
|
||||
"github.com/openziti/ziti/v2/common"
|
||||
"github.com/openziti/ziti/v2/controller/apierror"
|
||||
"github.com/openziti/ziti/v2/controller/db"
|
||||
"github.com/openziti/ziti/v2/controller/storage/boltz"
|
||||
cmap "github.com/orcaman/concurrent-map/v2"
|
||||
"go.etcd.io/bbolt"
|
||||
)
|
||||
@@ -54,6 +54,10 @@ type TokenIssuerCache struct {
|
||||
// due to xweb API address binds
|
||||
controllerIssuers cmap.ConcurrentMap[string, common.TokenIssuer]
|
||||
|
||||
// jwksResolver fetches JWKS endpoints for external issuers. It is shared by every
|
||||
// external issuer so that they all fetch under the same configured constraints.
|
||||
jwksResolver *HardenedJwksResolver
|
||||
|
||||
env Env
|
||||
}
|
||||
|
||||
@@ -64,6 +68,7 @@ func NewTokenIssuerCache(env Env) *TokenIssuerCache {
|
||||
env: env,
|
||||
externalIssuers: cmap.New[common.TokenIssuer](),
|
||||
controllerIssuers: cmap.New[common.TokenIssuer](),
|
||||
jwksResolver: NewHardenedJwksResolver(JwksFetchConfig(env)),
|
||||
}
|
||||
|
||||
env.GetStores().ExternalJwtSigner.AddEntityEventListenerF(result.onExtJwtCreate, boltz.EntityCreatedAsync)
|
||||
@@ -172,7 +177,7 @@ func (a *TokenIssuerCache) onExtJwtCreate(signer *db.ExternalJwtSigner) {
|
||||
|
||||
signerRec := &TokenIssuerExtJwt{
|
||||
externalJwtSigner: signer,
|
||||
jwksResolver: &jwks.HttpResolver{},
|
||||
jwksResolver: a.jwksResolver,
|
||||
kidToPubKey: map[string]common.IssuerPublicKey{},
|
||||
}
|
||||
|
||||
@@ -219,6 +224,21 @@ func (a *TokenIssuerCache) onExtJwtDelete(signer *db.ExternalJwtSigner) {
|
||||
a.externalIssuers.Remove(*signer.Issuer)
|
||||
}
|
||||
|
||||
// reportBlockedJwksEndpoint logs an existing external JWT signer whose jwksEndpoint the current
|
||||
// [edge.externalJwtSigners.jwksFetch] configuration refuses. A configuration change can orphan a
|
||||
// signer that was created while its endpoint was still permitted, so this is reported at startup
|
||||
// rather than only when a fetch is attempted. Endpoints that resolve to a blocked address are not
|
||||
// visible here, as no name resolution is done; those are reported by the fetch itself.
|
||||
func (a *TokenIssuerCache) reportBlockedJwksEndpoint(signer *db.ExternalJwtSigner) {
|
||||
if err := checkJwksEndpointAllowed(a.jwksResolver.policy, signer); err != nil {
|
||||
pfxlog.Logger().WithFields(map[string]interface{}{
|
||||
"id": signer.Id,
|
||||
"name": signer.Name,
|
||||
"jwksEndpoint": *signer.JwksEndpoint,
|
||||
}).WithError(err).Error("external jwt signer jwks endpoint is not permitted by the current jwks fetch configuration, its keys cannot be resolved and authentication with this signer will fail")
|
||||
}
|
||||
}
|
||||
|
||||
// loadExisting loads all external JWT signers and controllers during initialization.
|
||||
func (a *TokenIssuerCache) loadExisting() {
|
||||
err := a.env.GetDb().View(func(tx *bbolt.Tx) error {
|
||||
@@ -235,6 +255,8 @@ func (a *TokenIssuerCache) loadExisting() {
|
||||
continue
|
||||
}
|
||||
|
||||
a.reportBlockedJwksEndpoint(signer)
|
||||
|
||||
a.onExtJwtCreate(signer)
|
||||
}
|
||||
|
||||
@@ -485,15 +507,30 @@ func (a *TokenIssuerCache) IterateControllerIssuers(f func(issuer common.TokenIs
|
||||
})
|
||||
}
|
||||
|
||||
// GetIssuerByKid searches both external JWT signers and controller issuers for the one
|
||||
// that owns the given key ID. Returns nil if no issuer claims that kid.
|
||||
// GetIssuerByKid searches enabled external JWT signers and then controller issuers for the one
|
||||
// that owns the given key ID. Disabled external signers are skipped. Returns nil if no issuer
|
||||
// claims that kid.
|
||||
//
|
||||
// External signers can share a kid when they draw from a common signing-key pool, so an external
|
||||
// match is ambiguous and does not identify a token's issuer. Callers needing a definitive binding
|
||||
// must resolve by issuer claim instead.
|
||||
func (a *TokenIssuerCache) GetIssuerByKid(kid string) common.TokenIssuer {
|
||||
for _, issuer := range a.externalIssuers.Items() {
|
||||
if !issuer.IsEnabled() {
|
||||
continue
|
||||
}
|
||||
if pubKey, ok := issuer.PubKeyByKid(kid); ok && pubKey.PubKey != nil {
|
||||
return issuer
|
||||
}
|
||||
}
|
||||
|
||||
return a.GetControllerIssuerByKid(kid)
|
||||
}
|
||||
|
||||
// GetControllerIssuerByKid returns the controller TokenIssuer that owns the given key ID, or nil if
|
||||
// no controller issuer claims that kid. A controller issuer's key ID is the fingerprint of its TLS
|
||||
// certificate, so this resolves controller-issued tokens by kid without consulting external signers.
|
||||
func (a *TokenIssuerCache) GetControllerIssuerByKid(kid string) common.TokenIssuer {
|
||||
for _, controller := range a.controllerIssuers.Items() {
|
||||
if pubKey, ok := controller.PubKeyByKid(kid); ok && pubKey.PubKey != nil {
|
||||
return controller
|
||||
@@ -619,6 +656,10 @@ func (r *TokenIssuerExtJwt) VerifyToken(token string) *common.TokenVerificationR
|
||||
return result
|
||||
}
|
||||
|
||||
if result.Error = r.verifyIssuerAndAudience(claims); result.Error != nil {
|
||||
return result
|
||||
}
|
||||
|
||||
result.IdClaimValue, result.Error = resolveStringClaimSelector(claims, r.IdentityIdClaimsSelector())
|
||||
|
||||
if result.Error != nil {
|
||||
@@ -628,8 +669,47 @@ func (r *TokenIssuerExtJwt) VerifyToken(token string) *common.TokenVerificationR
|
||||
return result
|
||||
}
|
||||
|
||||
// verifyIssuerAndAudience confirms the token's issuer and audience claims match this signer's
|
||||
// configured values. Signature verification alone resolves the signing key by kid, which is not
|
||||
// sufficient: a signer that shares keys across audiences (for example a common JWKS) would
|
||||
// otherwise accept a validly-signed token minted for a different audience or issuer. Callers must
|
||||
// invoke this only after the signature has been verified.
|
||||
func (r *TokenIssuerExtJwt) verifyIssuerAndAudience(claims jwt.MapClaims) error {
|
||||
issuer, err := claims.GetIssuer()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not retrieve issuer claim from token: %w", err)
|
||||
}
|
||||
|
||||
if issuer == "" {
|
||||
return errors.New("token claims did not contain an issuer")
|
||||
}
|
||||
|
||||
if issuer != r.ExpectedIssuer() {
|
||||
return fmt.Errorf("token issuer [%s] does not match expected issuer [%s]", issuer, r.ExpectedIssuer())
|
||||
}
|
||||
|
||||
audiences, err := claims.GetAudience()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not retrieve audience claim from token: %w", err)
|
||||
}
|
||||
|
||||
if len(audiences) == 0 {
|
||||
return errors.New("token claims did not contain an audience")
|
||||
}
|
||||
|
||||
if !stringz.Contains(audiences, r.ExpectedAudience()) {
|
||||
return fmt.Errorf("token audience %v does not match expected audience [%s]", audiences, r.ExpectedAudience())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveStringSliceClaimProperty extracts a string or string array from JWT claims using a JSON pointer.
|
||||
// Returns a string slice even if the claim is a single string value.
|
||||
// Returns a string slice even if the claim is a single string value. An unset selector, a pointer that does
|
||||
// not resolve against the claims, and a null claim all resolve to no values without error. A claim that is
|
||||
// present but neither a string nor an array of strings errors.
|
||||
func resolveStringSliceClaimProperty(claims jwt.MapClaims, property string) ([]string, error) {
|
||||
if property == "" {
|
||||
return nil, nil
|
||||
@@ -648,7 +728,19 @@ func resolveStringSliceClaimProperty(claims jwt.MapClaims, property string) ([]s
|
||||
val, _, err := jsonPointer.Get(claims)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not resolve json pointer: %s: %w", property, err)
|
||||
// The role attributes claim is optional, so a pointer that does not resolve against this
|
||||
// token's claims yields no attributes rather than an error, matching the unset-selector and
|
||||
// empty-claim cases. This covers an absent key as well as a traversal failure, such as
|
||||
// indexing into a scalar.
|
||||
pfxlog.Logger().WithError(err).WithField("selector", property).
|
||||
Debug("attribute claim selector did not resolve, enrolling with no role attributes")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if val == nil {
|
||||
pfxlog.Logger().WithField("selector", property).
|
||||
Warn("attribute claim selector resolved to a null claim, enrolling with no role attributes")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
strVal, ok := val.(string)
|
||||
@@ -663,7 +755,7 @@ func resolveStringSliceClaimProperty(claims jwt.MapClaims, property string) ([]s
|
||||
arrVals, ok := val.([]any)
|
||||
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("could not resolve json pointer: %s: value is not a string or an array of strings, got: %v", property, arrVals)
|
||||
return nil, fmt.Errorf("could not resolve json pointer: %s: value is not a string or an array of strings, got: %v", property, val)
|
||||
}
|
||||
|
||||
var attributes []string
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/openziti/ziti/v2/common"
|
||||
"github.com/openziti/ziti/v2/controller/db"
|
||||
"github.com/openziti/ziti/v2/controller/storage/boltz"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Test_TokenIssuerExtJwt_VerifyToken_EnforcesIssuerAndAudience verifies that a validly-signed
|
||||
// token minted for a different issuer or audience is rejected. The by-inspection enrollment path
|
||||
// already enforces this; VerifyToken (used by the by-issuer-id enrollment path) must not be weaker.
|
||||
func Test_TokenIssuerExtJwt_VerifyToken_EnforcesIssuerAndAudience(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
testRootCa := newRootCa()
|
||||
leafKeyPair := testRootCa.NewLeafWithAKID()
|
||||
|
||||
jwksEndpoint := "https://example.com/.well-known/jwks"
|
||||
|
||||
jwksResolver, err := newTestJwksResolver()
|
||||
req.NoError(err)
|
||||
|
||||
leafKey, err := newKey(leafKeyPair.cert, []*x509.Certificate{leafKeyPair.cert, testRootCa.cert})
|
||||
req.NoError(err)
|
||||
|
||||
jwksResolver.AddKey(leafKey, leafKeyPair.key)
|
||||
|
||||
expectedIssuer := "https://idp.example.com"
|
||||
expectedAudience := "ziti-controller"
|
||||
|
||||
signerRec := &TokenIssuerExtJwt{
|
||||
kidToPubKey: map[string]common.IssuerPublicKey{},
|
||||
externalJwtSigner: &db.ExternalJwtSigner{
|
||||
BaseExtEntity: boltz.BaseExtEntity{
|
||||
Id: "fake-id",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
},
|
||||
Name: "test-signer",
|
||||
JwksEndpoint: &jwksEndpoint,
|
||||
Issuer: &expectedIssuer,
|
||||
Audience: &expectedAudience,
|
||||
Enabled: true,
|
||||
},
|
||||
jwksResolver: jwksResolver,
|
||||
}
|
||||
|
||||
req.NoError(signerRec.Resolve(false))
|
||||
|
||||
sign := func(claims jwt.MapClaims) string {
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
token.Header["kid"] = leafKey.KeyId
|
||||
signed, err := token.SignedString(leafKeyPair.key)
|
||||
req.NoError(err)
|
||||
return signed
|
||||
}
|
||||
|
||||
t.Run("accepts a token with matching issuer and audience", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
signed := sign(jwt.MapClaims{
|
||||
"iss": expectedIssuer,
|
||||
"aud": expectedAudience,
|
||||
"sub": "user-123",
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
})
|
||||
|
||||
result := signerRec.VerifyToken(signed)
|
||||
req.Truef(result.IsValid(), "token with matching issuer/audience must be accepted: %v", result.Error)
|
||||
})
|
||||
|
||||
t.Run("rejects a validly-signed token with a foreign audience", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
signed := sign(jwt.MapClaims{
|
||||
"iss": expectedIssuer,
|
||||
"aud": "some-other-audience",
|
||||
"sub": "user-123",
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
})
|
||||
|
||||
result := signerRec.VerifyToken(signed)
|
||||
req.False(result.IsValid(), "token minted for a foreign audience must be rejected")
|
||||
})
|
||||
|
||||
t.Run("rejects a validly-signed token with a foreign issuer", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
signed := sign(jwt.MapClaims{
|
||||
"iss": "https://attacker.example.com",
|
||||
"aud": expectedAudience,
|
||||
"sub": "user-123",
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
})
|
||||
|
||||
result := signerRec.VerifyToken(signed)
|
||||
req.False(result.IsValid(), "token minted by a foreign issuer must be rejected")
|
||||
})
|
||||
|
||||
t.Run("rejects a token missing the audience claim", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
signed := sign(jwt.MapClaims{
|
||||
"iss": expectedIssuer,
|
||||
"sub": "user-123",
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
})
|
||||
|
||||
result := signerRec.VerifyToken(signed)
|
||||
req.False(result.IsValid(), "token without an audience claim must be rejected")
|
||||
})
|
||||
}
|
||||
|
||||
func Test_resolveStringSliceClaimProperty(t *testing.T) {
|
||||
t.Run("returns empty when the selector is unset", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
vals, err := resolveStringSliceClaimProperty(jwt.MapClaims{"roles": "admin"}, "")
|
||||
|
||||
req.NoError(err)
|
||||
req.Empty(vals)
|
||||
})
|
||||
|
||||
t.Run("returns empty when the claim is absent at the selected path", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
vals, err := resolveStringSliceClaimProperty(jwt.MapClaims{"name": "bob"}, "/roles")
|
||||
|
||||
req.NoError(err)
|
||||
req.Empty(vals)
|
||||
})
|
||||
|
||||
t.Run("returns empty when a nested claim is absent at the selected path", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
claims := jwt.MapClaims{"resource_access": map[string]any{"other": "x"}}
|
||||
|
||||
vals, err := resolveStringSliceClaimProperty(claims, "/resource_access/ziti/roles")
|
||||
|
||||
req.NoError(err)
|
||||
req.Empty(vals)
|
||||
})
|
||||
|
||||
t.Run("returns empty when the claim is present but null", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
vals, err := resolveStringSliceClaimProperty(jwt.MapClaims{"roles": nil}, "/roles")
|
||||
|
||||
req.NoError(err)
|
||||
req.Empty(vals)
|
||||
})
|
||||
|
||||
t.Run("returns empty when a nested claim is present but null", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
claims := jwt.MapClaims{"resource_access": map[string]any{"ziti": map[string]any{"roles": nil}}}
|
||||
|
||||
vals, err := resolveStringSliceClaimProperty(claims, "/resource_access/ziti/roles")
|
||||
|
||||
req.NoError(err)
|
||||
req.Empty(vals)
|
||||
})
|
||||
|
||||
t.Run("returns empty when the claim is present but an empty string", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
vals, err := resolveStringSliceClaimProperty(jwt.MapClaims{"roles": ""}, "/roles")
|
||||
|
||||
req.NoError(err)
|
||||
req.Empty(vals)
|
||||
})
|
||||
|
||||
t.Run("returns a single value when the claim is a string", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
vals, err := resolveStringSliceClaimProperty(jwt.MapClaims{"roles": "admin"}, "/roles")
|
||||
|
||||
req.NoError(err)
|
||||
req.Equal([]string{"admin"}, vals)
|
||||
})
|
||||
|
||||
t.Run("returns all values when the claim is a string array", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
claims := jwt.MapClaims{"roles": []any{"admin", "support"}}
|
||||
|
||||
vals, err := resolveStringSliceClaimProperty(claims, "/roles")
|
||||
|
||||
req.NoError(err)
|
||||
req.Equal([]string{"admin", "support"}, vals)
|
||||
})
|
||||
|
||||
t.Run("resolves a nested claim that is present", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
claims := jwt.MapClaims{"resource_access": map[string]any{"ziti": map[string]any{"roles": []any{"admin"}}}}
|
||||
|
||||
vals, err := resolveStringSliceClaimProperty(claims, "/resource_access/ziti/roles")
|
||||
|
||||
req.NoError(err)
|
||||
req.Equal([]string{"admin"}, vals)
|
||||
})
|
||||
|
||||
t.Run("errors when the claim is present but not a string or array of strings", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
claims := jwt.MapClaims{"roles": map[string]any{"unexpected": "object"}}
|
||||
|
||||
_, err := resolveStringSliceClaimProperty(claims, "/roles")
|
||||
|
||||
req.Error(err)
|
||||
req.ErrorContains(err, "map[unexpected:object]")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
Copyright NetFoundry Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package network
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/openziti/foundation/v2/versions"
|
||||
"github.com/openziti/ziti/v2/common/inspect"
|
||||
"github.com/openziti/ziti/v2/common/pb/mgmt_pb"
|
||||
"github.com/openziti/ziti/v2/controller/model"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// newDbLoadedSrcLink builds a link whose source is a router instance loaded from the database. Such an
|
||||
// instance carries no version, since a router's version arrives in its hello and is not persisted.
|
||||
func newDbLoadedSrcLink(t *testing.T, srcId string) *model.Link {
|
||||
t.Helper()
|
||||
src := &model.Router{}
|
||||
src.Id = srcId
|
||||
require.Nil(t, src.VersionInfo, "a database-loaded router carries no version")
|
||||
|
||||
return &model.Link{Id: "l1", DstId: "dst", Src: src}
|
||||
}
|
||||
|
||||
// TestCheckLinkConns_UnknownVersionIsSkipped: with no connected instance there is no way to know what the
|
||||
// router reports, so the comparison is skipped rather than failed, matching how a router too old to report
|
||||
// conn info is treated.
|
||||
func TestCheckLinkConns_UnknownVersionIsSkipped(t *testing.T) {
|
||||
_, network, _ := newConnectTestNetwork(t)
|
||||
|
||||
link := newDbLoadedSrcLink(t, "r1")
|
||||
|
||||
result := &mgmt_pb.RouterLinkDetail{IsValid: true}
|
||||
network.checkLinkConns(link, &inspect.LinkInspectDetail{}, result)
|
||||
|
||||
require.True(t, result.IsValid, "an unknown router version must not make a link invalid")
|
||||
require.Empty(t, result.Messages)
|
||||
}
|
||||
|
||||
// TestCheckLinkConns_UsesConnectedVersion: the version lives on the connected instance, so reading it off
|
||||
// whatever instance the link references reports a link as invalid whenever that instance is one loaded from
|
||||
// the database rather than the connected one.
|
||||
func TestCheckLinkConns_UsesConnectedVersion(t *testing.T) {
|
||||
_, network, addr := newConnectTestNetwork(t)
|
||||
|
||||
link := newDbLoadedSrcLink(t, "r1")
|
||||
|
||||
connected := model.NewRouterForTest("r1", "", addr, &fakeCtrlChannel{}, 0, false)
|
||||
connected.VersionInfo = &versions.VersionInfo{Version: "v1.0.0"}
|
||||
network.Router.MarkConnected(connected)
|
||||
|
||||
result := &mgmt_pb.RouterLinkDetail{IsValid: true}
|
||||
network.checkLinkConns(link, &inspect.LinkInspectDetail{}, result)
|
||||
|
||||
require.True(t, result.IsValid, "the connected instance's version must be what decides the comparison")
|
||||
require.Empty(t, result.Messages)
|
||||
}
|
||||
|
||||
// TestCheckLinkConns_ComparesWhenVersionKnown: once the version is known and high enough, the conn info
|
||||
// comparison proceeds, so skipping above does not quietly disable the check.
|
||||
func TestCheckLinkConns_ComparesWhenVersionKnown(t *testing.T) {
|
||||
_, network, addr := newConnectTestNetwork(t)
|
||||
|
||||
connected := model.NewRouterForTest("r1", "", addr, &fakeCtrlChannel{}, 0, false)
|
||||
connected.VersionInfo = &versions.VersionInfo{Version: "v1.6.6"}
|
||||
network.Router.MarkConnected(connected)
|
||||
|
||||
link := &model.Link{Id: "l1", DstId: "dst", Src: connected}
|
||||
|
||||
// The router reports a connection the controller does not know about, which the comparison must catch.
|
||||
routerLink := &inspect.LinkInspectDetail{
|
||||
Connections: []*inspect.LinkConnection{
|
||||
{Type: "default", Source: "127.0.0.1:1000", Dest: "127.0.0.1:2000"},
|
||||
},
|
||||
}
|
||||
|
||||
result := &mgmt_pb.RouterLinkDetail{IsValid: true}
|
||||
network.checkLinkConns(link, routerLink, result)
|
||||
|
||||
// A conn count mismatch reports a message without marking the link invalid, so the message is what
|
||||
// shows the comparison ran rather than being skipped.
|
||||
require.NotEmpty(t, result.Messages, "a known version must let the conn info comparison run")
|
||||
require.Contains(t, result.Messages[0], "len(ctrlConns)")
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
Copyright NetFoundry Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package network
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/michaelquigley/pfxlog"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// TestMain configures logging once for the whole package, keeping the path-finding perf tests quiet.
|
||||
// GlobalInit writes package-level logger state, so calling it from inside a test races any goroutine a
|
||||
// previous test left running that logs while shutting down: a TestContext's api-session heartbeat
|
||||
// collector, for instance, does a final flush after its close notification, and that flush logs. Doing
|
||||
// this before any test starts leaves no concurrent reader to race with.
|
||||
func TestMain(m *testing.M) {
|
||||
pfxlog.GlobalInit(logrus.WarnLevel, pfxlog.DefaultOptions())
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
+159
-22
@@ -33,6 +33,7 @@ import (
|
||||
|
||||
"github.com/michaelquigley/pfxlog"
|
||||
"github.com/openziti/channel/v4/protobufs"
|
||||
"github.com/openziti/channel/v4"
|
||||
"github.com/openziti/foundation/v2/concurrenz"
|
||||
"github.com/openziti/foundation/v2/debugz"
|
||||
"github.com/openziti/foundation/v2/goroutines"
|
||||
@@ -190,6 +191,7 @@ func (self *Network) decodeSyncSnapshotCommand(_ int32, data []byte) (command.Co
|
||||
cmd := &command.SyncSnapshotCommand{
|
||||
TimelineId: msg.SnapshotId,
|
||||
Snapshot: msg.Snapshot,
|
||||
ClusterId: msg.ClusterId,
|
||||
SnapshotSink: self.RestoreSnapshot,
|
||||
}
|
||||
|
||||
@@ -264,9 +266,11 @@ func (network *Network) GetConnectedRouter(routerId string) *model.Router {
|
||||
return network.Router.GetConnected(routerId)
|
||||
}
|
||||
|
||||
func (network *Network) GetReloadedRouter(routerId string) (*model.Router, error) {
|
||||
network.Router.RemoveFromCache(routerId)
|
||||
return network.Router.Read(routerId)
|
||||
// NewCtrlChanRouter returns the Router instance representing a new control-channel connection, with the
|
||||
// channel recorded on it. Each connection gets its own instance, which is what lets the connect and
|
||||
// disconnect paths tell two racing connections for one router apart.
|
||||
func (network *Network) NewCtrlChanRouter(ch channel.Channel) (*model.Router, error) {
|
||||
return network.Router.NewCtrlChanRouter(ch)
|
||||
}
|
||||
|
||||
func (network *Network) GetRouter(routerId string) (*model.Router, error) {
|
||||
@@ -347,7 +351,47 @@ func (network *Network) ConnectedRouter(id string) bool {
|
||||
return network.Router.IsConnected(id)
|
||||
}
|
||||
|
||||
func (network *Network) ConnectRouter(r *model.Router) {
|
||||
var (
|
||||
// ErrConnectRejected indicates a router connect was rejected because another connection for the same
|
||||
// router is already current. It is returned by ConnectRouter and propagated out of the bind handler so
|
||||
// NewChannel closes the rejected connection's underlay without starting rx or registering it; the
|
||||
// router then redials.
|
||||
ErrConnectRejected = errors.New("router connect rejected: another connection is already current")
|
||||
|
||||
// ErrConnectChannelClosed indicates a router connect was refused because its control channel was
|
||||
// already closed by the time the connect decision was made, so the connection must not be registered.
|
||||
ErrConnectChannelClosed = errors.New("router connect rejected: control channel already closed")
|
||||
)
|
||||
|
||||
// IsConnectRejected reports whether err is (or wraps) a connect refusal that the router recovers from by
|
||||
// redialing, so the accept path can log it at info rather than treating it as a bind failure.
|
||||
func IsConnectRejected(err error) bool {
|
||||
return errors.Is(err, ErrConnectRejected) || errors.Is(err, ErrConnectChannelClosed)
|
||||
}
|
||||
|
||||
// ConnectRouter registers r as the current connection for its router id, serialized per router. If the
|
||||
// slot is already held by a different connection it rejects this one (returning ErrConnectRejected) and
|
||||
// displaces the occupant so its teardown runs; the router redials into the freed slot. A connection whose
|
||||
// channel is already closed is refused outright (ErrConnectChannelClosed) rather than registered. There is
|
||||
// at most one connection per router in the connected map at a time.
|
||||
func (network *Network) ConnectRouter(r *model.Router) error {
|
||||
unlock := network.Router.LockConnectFor(r.Id)
|
||||
defer unlock() // leak-safety net; idempotent, so the explicit unlocks below are the ones that matter
|
||||
|
||||
if cur := network.Router.GetConnected(r.Id); cur != nil && cur != r {
|
||||
// Displace the occupant outside the lock: the teardown acquires the stripe itself (we have
|
||||
// released it), so there is no reentrant self-deadlock.
|
||||
unlock()
|
||||
network.displaceConnection(cur)
|
||||
return ErrConnectRejected
|
||||
}
|
||||
|
||||
// Its close handler has already run and never fires again, so nothing would remove it from the
|
||||
// connected map and every redial would bounce off a slot that can never be freed.
|
||||
if r.Control == nil || r.Control.IsClosed() {
|
||||
return ErrConnectChannelClosed
|
||||
}
|
||||
|
||||
network.Link.BuildRouterLinks(r)
|
||||
network.Router.MarkConnected(r)
|
||||
|
||||
@@ -358,7 +402,10 @@ func (network *Network) ConnectRouter(r *model.Router) {
|
||||
go h.RouterConnected(r)
|
||||
}
|
||||
}
|
||||
unlock()
|
||||
|
||||
go network.ValidateTerminators(r)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (network *Network) ValidateTerminators(r *model.Router) {
|
||||
@@ -461,9 +508,59 @@ func (n *Network) ValidateRouterErtTerminators(filter string, cb ErtTerminatorVa
|
||||
return int64(len(result.Entities)), evalF, nil
|
||||
}
|
||||
|
||||
// isCurrentConnection reports whether r is still the router's current, connected connection, by pointer
|
||||
// identity against the connected map (mirrors the check in NotifyExistingLink). A stale or superseded
|
||||
// connection returns false.
|
||||
func (network *Network) isCurrentConnection(r *model.Router) bool {
|
||||
return network.Router.GetConnected(r.Id) == r && r.Connected.Load()
|
||||
}
|
||||
|
||||
// displaceConnection removes cur, the connection occupying its router's connected slot, so that a redial
|
||||
// can take the slot. Closing the channel is not sufficient on its own: if it is already closed, its close
|
||||
// handler has already run and will never run again, so nothing would remove cur and every subsequent
|
||||
// connect would be rejected against a slot that can never be freed. The teardown is therefore also
|
||||
// invoked directly; it is gated on connection currency, so it is a no-op once the close handler has
|
||||
// cleared the slot. Must be called with the router's connect stripe released, since the teardown
|
||||
// acquires it.
|
||||
func (network *Network) displaceConnection(cur *model.Router) {
|
||||
if ch := cur.Control; ch != nil && !ch.IsClosed() {
|
||||
if err := ch.Close(); err != nil {
|
||||
pfxlog.Logger().WithError(err).WithField("routerId", cur.Id).
|
||||
Error("error closing superseded control channel while rejecting connect")
|
||||
}
|
||||
}
|
||||
network.DisconnectRouter(cur)
|
||||
}
|
||||
|
||||
func (network *Network) DisconnectRouter(r *model.Router) {
|
||||
// 1: remove Links for Router
|
||||
for _, l := range r.GetLinks() {
|
||||
// Lock-free pre-check: a stale/superseded disconnect (e.g. the old connection after a takeover) has
|
||||
// nothing to tear down and must not touch the live connection's state; bail without blocking.
|
||||
if !network.isCurrentConnection(r) {
|
||||
return
|
||||
}
|
||||
|
||||
unlock := network.Router.LockConnectFor(r.Id)
|
||||
defer unlock()
|
||||
|
||||
// Re-check under the stripe: a newer connection may have taken over between the pre-check and the
|
||||
// lock. The teardown is all-or-nothing and must not run against a superseded connection.
|
||||
if !network.isCurrentConnection(r) {
|
||||
return
|
||||
}
|
||||
|
||||
// Snapshot the router's links before marking it disconnected: MarkDisconnected clears the
|
||||
// router's link set (routerLinks.Clear()), so a later r.GetLinks() would return nothing and
|
||||
// the link-removal/reroute cascade below would be skipped entirely.
|
||||
links := r.GetLinks()
|
||||
|
||||
// Mark the router disconnected before the RerouteLink cascade, so reroute and everything it
|
||||
// calls (shortestPath, connected-map reads) sees the dying router as gone. Otherwise reroute
|
||||
// runs while the router still appears connected and can compute a replacement path through the
|
||||
// very router that is being removed.
|
||||
network.Router.MarkDisconnected(r)
|
||||
|
||||
// remove Links for Router, rerouting circuits off any that were connected
|
||||
for _, l := range links {
|
||||
wasConnected := l.CurrentState().Mode == model.Connected
|
||||
if l.Src.Id == r.Id {
|
||||
network.Link.Remove(l)
|
||||
@@ -472,8 +569,6 @@ func (network *Network) DisconnectRouter(r *model.Router) {
|
||||
network.RerouteLink(l)
|
||||
}
|
||||
}
|
||||
// 2: remove Router
|
||||
network.Router.MarkDisconnected(r)
|
||||
|
||||
for _, h := range network.routerPresenceHandlers.Value() {
|
||||
h.RouterDisconnected(r)
|
||||
@@ -487,23 +582,33 @@ func (network *Network) NotifyExistingLink(srcRouter *model.Router, reportedLink
|
||||
WithField("destRouterId", reportedLink.DestRouterId).
|
||||
WithField("iteration", reportedLink.Iteration)
|
||||
|
||||
// Publish under the stripe DisconnectRouter holds: checking currency and then publishing without it
|
||||
// lets a report recreate a link after the teardown has snapshotted and cleared it. Events go out after
|
||||
// the unlock, since a dispatcher may be slow and this stripe is shared with connect and disconnect.
|
||||
unlock := network.Router.LockConnectFor(srcRouter.Id)
|
||||
|
||||
src := network.Router.GetConnected(srcRouter.Id)
|
||||
if src == nil {
|
||||
unlock()
|
||||
log.Info("ignoring links message processed after router disconnected")
|
||||
return
|
||||
}
|
||||
|
||||
if src != srcRouter || !srcRouter.Connected.Load() {
|
||||
unlock()
|
||||
log.Info("ignoring links message processed from old router connection")
|
||||
return
|
||||
}
|
||||
|
||||
dst := network.Router.GetConnected(reportedLink.DestRouterId)
|
||||
link, created := network.Link.RouterReportedLink(reportedLink, src, dst)
|
||||
|
||||
unlock()
|
||||
|
||||
if dst == nil {
|
||||
network.NotifyLinkIdEvent(reportedLink.Id, event.LinkFromRouterDisconnectedDest)
|
||||
}
|
||||
|
||||
link, created := network.Link.RouterReportedLink(reportedLink, src, dst)
|
||||
if created {
|
||||
network.NotifyLinkEvent(link, event.LinkFromRouterNew)
|
||||
log.Info("router reported link added")
|
||||
@@ -1360,13 +1465,16 @@ func (network *Network) RestoreSnapshot(cmd *command.SyncSnapshotCommand, index
|
||||
}
|
||||
if currentTimelineId != "" && currentTimelineId == cmd.TimelineId {
|
||||
log.WithField("timelineId", cmd.TimelineId).Info("snapshot already current, skipping reload")
|
||||
// DB already restored; ensure cluster id then raft index (index last, see main path).
|
||||
if err = network.ensureClusterId(cmd.ClusterId); err != nil {
|
||||
return fmt.Errorf("failed to set cluster id for already-current snapshot (%w)", err)
|
||||
}
|
||||
if err = network.ensureRaftIndex(index); err != nil {
|
||||
return fmt.Errorf("failed to set raft index for already-current snapshot (%w)", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.WithError(err).Error("unable to read current raft index before DB restore")
|
||||
}
|
||||
|
||||
buf := bytes.NewBuffer(cmd.Snapshot)
|
||||
reader, err := gzip.NewReader(buf)
|
||||
if err != nil {
|
||||
@@ -1374,14 +1482,15 @@ func (network *Network) RestoreSnapshot(cmd *command.SyncSnapshotCommand, index
|
||||
}
|
||||
|
||||
network.GetDb().RestoreFromReader(reader)
|
||||
err = network.GetDb().Update(nil, func(ctx boltz.MutateContext) error {
|
||||
raftBucket := boltz.GetOrCreatePath(ctx.Tx(), db.RootBucket, db.MetadataBucket)
|
||||
raftBucket.SetInt64(db.FieldRaftIndex, int64(index), nil)
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.WithError(err).Errorf("failed to set index after restore")
|
||||
// Write the cluster id before the raft index. The index is the completion gate on restart (the
|
||||
// FSM skips entries at or below the stored index), so persist it last: any earlier failure then
|
||||
// replays and retries instead of skipping the command with a blank cluster id.
|
||||
if err = network.ensureClusterId(cmd.ClusterId); err != nil {
|
||||
return fmt.Errorf("failed to set cluster id after db restore (%w)", err)
|
||||
}
|
||||
if err = network.ensureRaftIndex(index); err != nil {
|
||||
return fmt.Errorf("failed to set raft index after db restore (%w)", err)
|
||||
}
|
||||
|
||||
time.AfterFunc(5*time.Second, func() {
|
||||
@@ -1392,6 +1501,29 @@ func (network *Network) RestoreSnapshot(cmd *command.SyncSnapshotCommand, index
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureClusterId writes the cluster id if one is not already set (no-op when empty or matching).
|
||||
// The snapshot restore replaces the whole db, which carries no cluster id, so it must be set here.
|
||||
func (network *Network) ensureClusterId(clusterId string) error {
|
||||
if clusterId == "" {
|
||||
return nil
|
||||
}
|
||||
_, err := db.InitClusterId(network.GetDb(), nil, clusterId)
|
||||
return err
|
||||
}
|
||||
|
||||
// ensureRaftIndex records the raft index if the stored one is behind it, returning an error on
|
||||
// failure so a missed index update surfaces rather than being treated as success.
|
||||
func (network *Network) ensureRaftIndex(index uint64) error {
|
||||
return network.GetDb().Update(nil, func(ctx boltz.MutateContext) error {
|
||||
if db.LoadCurrentRaftIndex(ctx.Tx()) >= index {
|
||||
return nil
|
||||
}
|
||||
raftBucket := boltz.GetOrCreatePath(ctx.Tx(), db.RootBucket, db.MetadataBucket)
|
||||
raftBucket.SetInt64(db.FieldRaftIndex, int64(index), nil)
|
||||
return raftBucket.GetError()
|
||||
})
|
||||
}
|
||||
|
||||
func (network *Network) AddInspectTarget(target InspectTarget) {
|
||||
network.inspectionTargets.Append(target)
|
||||
}
|
||||
@@ -1516,9 +1648,14 @@ func (network *Network) checkLinkConns(ctrlLink *model.Link, routerLink *inspect
|
||||
})
|
||||
}
|
||||
|
||||
// ensure that conn info is being reported
|
||||
// The version comes from the hello, so only the connected instance has it. Not knowing it means the
|
||||
// comparison cannot be made, which is not a fault of the link.
|
||||
if srcR := ctrlLink.Src; srcR != nil {
|
||||
hasMinVersion, err := srcR.VersionInfo.HasMinimumVersion("v1.6.6")
|
||||
connectedSrc := network.Router.GetConnected(srcR.Id)
|
||||
if connectedSrc == nil || connectedSrc.VersionInfo == nil {
|
||||
return
|
||||
}
|
||||
hasMinVersion, err := connectedSrc.VersionInfo.HasMinimumVersion("v1.6.6")
|
||||
if err != nil {
|
||||
result.IsValid = false
|
||||
result.Messages = append(result.Messages, err.Error())
|
||||
|
||||
@@ -144,6 +144,16 @@ func (network *Network) shortestPath(srcR *model.Router, dstR *model.Router) ([]
|
||||
return nil, 0, errors.New("not routable (!srcR||!dstR)")
|
||||
}
|
||||
|
||||
// The graph below is pointer-keyed, so an endpoint held as a different instance of the same router is
|
||||
// not a node in it and the search reports the router unroutable from itself. Callers legitimately hold
|
||||
// other instances: each connection has its own, and the router cache holds a database-loaded one.
|
||||
if connected := network.Router.GetConnected(srcR.Id); connected != nil {
|
||||
srcR = connected
|
||||
}
|
||||
if connected := network.Router.GetConnected(dstR.Id); connected != nil {
|
||||
dstR = connected
|
||||
}
|
||||
|
||||
if srcR == dstR {
|
||||
return []*model.Router{srcR}, 0, nil
|
||||
}
|
||||
|
||||
@@ -22,14 +22,9 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/openziti/ziti/v2/controller/model"
|
||||
|
||||
"github.com/michaelquigley/pfxlog"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func TestShortestPathAgainstEstablished(t *testing.T) {
|
||||
pfxlog.GlobalInit(logrus.WarnLevel, pfxlog.DefaultOptions())
|
||||
|
||||
ctx := model.NewTestContext(t)
|
||||
defer ctx.Cleanup()
|
||||
|
||||
@@ -151,7 +146,6 @@ func TestShortestPathAgainstEstablished(t *testing.T) {
|
||||
|
||||
func BenchmarkShortestPathPerfWithRouterChanges(b *testing.B) {
|
||||
b.StopTimer()
|
||||
pfxlog.GlobalInit(logrus.WarnLevel, pfxlog.DefaultOptions())
|
||||
|
||||
ctx := model.NewTestContext(b)
|
||||
defer ctx.Cleanup()
|
||||
@@ -242,7 +236,6 @@ type expectedRoute struct {
|
||||
|
||||
func BenchmarkShortestPathPerf(b *testing.B) {
|
||||
b.StopTimer()
|
||||
pfxlog.GlobalInit(logrus.WarnLevel, pfxlog.DefaultOptions())
|
||||
|
||||
ctx := model.NewTestContext(b)
|
||||
defer ctx.Cleanup()
|
||||
@@ -314,7 +307,6 @@ func BenchmarkShortestPathPerf(b *testing.B) {
|
||||
|
||||
func BenchmarkMoreRealisticShortestPathPerf(b *testing.B) {
|
||||
//b.StopTimer()
|
||||
pfxlog.GlobalInit(logrus.WarnLevel, pfxlog.DefaultOptions())
|
||||
|
||||
ctx := model.NewTestContext(b)
|
||||
defer ctx.Cleanup()
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
/*
|
||||
Copyright NetFoundry Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package network
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/openziti/channel/v4"
|
||||
"github.com/openziti/transport/v2"
|
||||
"github.com/openziti/transport/v2/tcp"
|
||||
"github.com/openziti/ziti/v2/common/ctrlchan"
|
||||
"github.com/openziti/ziti/v2/common/pb/ctrl_pb"
|
||||
"github.com/openziti/ziti/v2/controller/change"
|
||||
"github.com/openziti/ziti/v2/controller/model"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// fakeCtrlChannel is a minimal ctrlchan.CtrlChannel double for connect/disconnect lifecycle tests.
|
||||
// It embeds the interface (unimplemented methods panic if called), and implements only the close-related
|
||||
// methods the reject/kick path exercises. onClose, if set, runs synchronously on the first Close to
|
||||
// simulate the real channel close handler firing DisconnectRouter.
|
||||
type fakeCtrlChannel struct {
|
||||
ctrlchan.CtrlChannel
|
||||
closed atomic.Bool
|
||||
onClose func()
|
||||
}
|
||||
|
||||
func (f *fakeCtrlChannel) Close() error {
|
||||
if f.closed.CompareAndSwap(false, true) && f.onClose != nil {
|
||||
f.onClose()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeCtrlChannel) IsClosed() bool { return f.closed.Load() }
|
||||
|
||||
func (f *fakeCtrlChannel) IsConnected() bool { return !f.closed.Load() }
|
||||
|
||||
func newConnectTestNetwork(t *testing.T) (*model.TestContext, *Network, transport.Address) {
|
||||
ctx := model.NewTestContext(t)
|
||||
t.Cleanup(ctx.Cleanup)
|
||||
|
||||
config := newTestConfig(ctx)
|
||||
t.Cleanup(func() { close(config.closeNotify) })
|
||||
|
||||
network, err := NewNetwork(config, ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
addr, err := tcp.AddressParser{}.Parse("tcp:0.0.0.0:0")
|
||||
require.NoError(t, err)
|
||||
|
||||
return ctx, network, addr
|
||||
}
|
||||
|
||||
// TestConnectRouter_RejectsAndKicksWhenBusy: a connect into an occupied slot returns ErrConnectRejected,
|
||||
// kicks the occupant (whose teardown clears the slot), and a subsequent redial then connects cleanly.
|
||||
func TestConnectRouter_RejectsAndKicksWhenBusy(t *testing.T) {
|
||||
_, network, addr := newConnectTestNetwork(t)
|
||||
|
||||
currentCh := &fakeCtrlChannel{}
|
||||
cur := model.NewRouterForTest("r1", "", addr, currentCh, 0, false)
|
||||
// Simulate the real close handler: closing the occupant runs its DisconnectRouter.
|
||||
currentCh.onClose = func() { network.DisconnectRouter(cur) }
|
||||
|
||||
require.NoError(t, network.ConnectRouter(cur))
|
||||
require.Equal(t, cur, network.Router.GetConnected("r1"))
|
||||
|
||||
newCh := &fakeCtrlChannel{}
|
||||
rNew := model.NewRouterForTest("r1", "", addr, newCh, 0, false)
|
||||
err := network.ConnectRouter(rNew)
|
||||
require.ErrorIs(t, err, ErrConnectRejected)
|
||||
require.True(t, IsConnectRejected(err))
|
||||
require.True(t, currentCh.IsClosed(), "occupant should have been kicked")
|
||||
require.Nil(t, network.Router.GetConnected("r1"), "kicked occupant's teardown should clear the slot")
|
||||
require.False(t, rNew.Connected.Load(), "rejected connect must not be registered")
|
||||
|
||||
// Redial into the now-clear slot succeeds.
|
||||
rRedial := model.NewRouterForTest("r1", "", addr, &fakeCtrlChannel{}, 0, false)
|
||||
require.NoError(t, network.ConnectRouter(rRedial))
|
||||
require.Equal(t, rRedial, network.Router.GetConnected("r1"))
|
||||
require.True(t, rRedial.Connected.Load())
|
||||
}
|
||||
|
||||
// TestConnectRouter_SetsUpWhenClear: a connect into an empty slot registers the router.
|
||||
func TestConnectRouter_SetsUpWhenClear(t *testing.T) {
|
||||
_, network, addr := newConnectTestNetwork(t)
|
||||
|
||||
r := model.NewRouterForTest("r1", "", addr, &fakeCtrlChannel{}, 0, false)
|
||||
require.NoError(t, network.ConnectRouter(r))
|
||||
require.Equal(t, r, network.Router.GetConnected("r1"))
|
||||
require.True(t, r.Connected.Load())
|
||||
}
|
||||
|
||||
// TestDisconnectRouter_IgnoresStaleConnection: a disconnect for a superseded connection must not disturb
|
||||
// the current one (the §9 race guard).
|
||||
func TestDisconnectRouter_IgnoresStaleConnection(t *testing.T) {
|
||||
_, network, addr := newConnectTestNetwork(t)
|
||||
|
||||
rNew := model.NewRouterForTest("r1", "", addr, &fakeCtrlChannel{}, 0, false)
|
||||
require.NoError(t, network.ConnectRouter(rNew))
|
||||
require.Equal(t, rNew, network.Router.GetConnected("r1"))
|
||||
|
||||
// A stale disconnect for a different (old) instance of the same router id.
|
||||
rOld := model.NewRouterForTest("r1", "", addr, &fakeCtrlChannel{}, 0, false)
|
||||
network.DisconnectRouter(rOld)
|
||||
|
||||
require.Equal(t, rNew, network.Router.GetConnected("r1"), "stale disconnect must not evict the current connection")
|
||||
require.True(t, rNew.Connected.Load())
|
||||
}
|
||||
|
||||
// TestDisconnectRouter_CurrentTearsDown: a disconnect for the current connection clears it.
|
||||
func TestDisconnectRouter_CurrentTearsDown(t *testing.T) {
|
||||
_, network, addr := newConnectTestNetwork(t)
|
||||
|
||||
r := model.NewRouterForTest("r1", "", addr, &fakeCtrlChannel{}, 0, false)
|
||||
require.NoError(t, network.ConnectRouter(r))
|
||||
require.Equal(t, r, network.Router.GetConnected("r1"))
|
||||
|
||||
network.DisconnectRouter(r)
|
||||
require.Nil(t, network.Router.GetConnected("r1"))
|
||||
require.False(t, r.Connected.Load())
|
||||
}
|
||||
|
||||
// TestReject_DisplacesOccupantThatCannotTearItselfDown is the regression guard against a permanent
|
||||
// reject loop. A connection whose channel closes without its close handler running can never remove
|
||||
// itself from the connected map, so a reject that merely closed the channel would leave the slot occupied
|
||||
// forever and every redial would be rejected against a slot nothing can free. The reject must displace
|
||||
// the occupant itself, so the next redial finds the slot clear.
|
||||
func TestReject_DisplacesOccupantThatCannotTearItselfDown(t *testing.T) {
|
||||
_, network, addr := newConnectTestNetwork(t)
|
||||
|
||||
// No onClose: the occupant's channel goes closed without any teardown running, modeling both a
|
||||
// channel already closed before the reject and one whose close handler has already run.
|
||||
currentCh := &fakeCtrlChannel{}
|
||||
cur := model.NewRouterForTest("r1", "", addr, currentCh, 0, false)
|
||||
require.NoError(t, network.ConnectRouter(cur))
|
||||
require.Equal(t, cur, network.Router.GetConnected("r1"))
|
||||
currentCh.closed.Store(true)
|
||||
|
||||
rNew := model.NewRouterForTest("r1", "", addr, &fakeCtrlChannel{}, 0, false)
|
||||
require.ErrorIs(t, network.ConnectRouter(rNew), ErrConnectRejected)
|
||||
require.Nil(t, network.Router.GetConnected("r1"), "reject must displace an occupant that cannot tear itself down")
|
||||
require.False(t, cur.Connected.Load())
|
||||
require.False(t, rNew.Connected.Load(), "rejected connect must not be registered")
|
||||
|
||||
// The redial therefore makes progress instead of bouncing off the slot forever.
|
||||
rRedial := model.NewRouterForTest("r1", "", addr, &fakeCtrlChannel{}, 0, false)
|
||||
require.NoError(t, network.ConnectRouter(rRedial))
|
||||
require.Equal(t, rRedial, network.Router.GetConnected("r1"))
|
||||
require.True(t, rRedial.Connected.Load())
|
||||
}
|
||||
|
||||
// TestConnectRouter_RefusesAlreadyClosedChannel: a connect whose channel is already closed must be
|
||||
// refused rather than registered. Registering it would put a connection in the connected map that no
|
||||
// disconnect can ever remove, since its close handler has already run, wedging the router out of this
|
||||
// controller permanently.
|
||||
func TestConnectRouter_RefusesAlreadyClosedChannel(t *testing.T) {
|
||||
_, network, addr := newConnectTestNetwork(t)
|
||||
|
||||
deadCh := &fakeCtrlChannel{}
|
||||
deadCh.closed.Store(true)
|
||||
r := model.NewRouterForTest("r1", "", addr, deadCh, 0, false)
|
||||
|
||||
err := network.ConnectRouter(r)
|
||||
require.ErrorIs(t, err, ErrConnectChannelClosed)
|
||||
require.True(t, IsConnectRejected(err), "an already-closed channel is a refusal the router redials after")
|
||||
require.Nil(t, network.Router.GetConnected("r1"), "a closed connection must not occupy the slot")
|
||||
require.False(t, r.Connected.Load())
|
||||
|
||||
// A subsequent healthy connect is unaffected.
|
||||
rOk := model.NewRouterForTest("r1", "", addr, &fakeCtrlChannel{}, 0, false)
|
||||
require.NoError(t, network.ConnectRouter(rOk))
|
||||
require.Equal(t, rOk, network.Router.GetConnected("r1"))
|
||||
}
|
||||
|
||||
// TestConnectDisconnectRace exercises concurrent connect/disconnect for one router id under the race
|
||||
// detector, asserting the map never ends holding a disconnected router.
|
||||
func TestConnectDisconnectRace(t *testing.T) {
|
||||
_, network, addr := newConnectTestNetwork(t)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 64; i++ {
|
||||
wg.Add(1)
|
||||
go func(connect bool) {
|
||||
defer wg.Done()
|
||||
if connect {
|
||||
r := model.NewRouterForTest("r1", "", addr, &fakeCtrlChannel{}, 0, false)
|
||||
_ = network.ConnectRouter(r)
|
||||
} else if cur := network.Router.GetConnected("r1"); cur != nil {
|
||||
network.DisconnectRouter(cur)
|
||||
}
|
||||
}(i%2 == 0)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if cur := network.Router.GetConnected("r1"); cur != nil {
|
||||
require.True(t, cur.Connected.Load(), "map must not hold a disconnected router")
|
||||
}
|
||||
}
|
||||
|
||||
// fakeConnectChannel is a channel.MultiChannel double reporting a fixed router id and a real
|
||||
// ListenerCtrlChannel as its underlay handler, which is what the accept path records on the router. It
|
||||
// embeds the interface, so any method these tests do not exercise panics rather than returning a zero value.
|
||||
type fakeConnectChannel struct {
|
||||
channel.MultiChannel
|
||||
id string
|
||||
underlayHandler channel.UnderlayHandler
|
||||
}
|
||||
|
||||
func (f *fakeConnectChannel) Id() string { return f.id }
|
||||
func (f *fakeConnectChannel) GetUnderlayHandler() channel.UnderlayHandler { return f.underlayHandler }
|
||||
func (f *fakeConnectChannel) SetLogicalName(string) {}
|
||||
|
||||
func newFakeConnectChannel(routerId string) *fakeConnectChannel {
|
||||
ctrlCh := ctrlchan.NewListenerCtrlChannel()
|
||||
ch := &fakeConnectChannel{id: routerId, underlayHandler: ctrlCh}
|
||||
// The channel framework hands the ctrl channel its channel on creation; do the same here so the
|
||||
// double records it the way a real connection would.
|
||||
ctrlCh.ChannelCreated(ch)
|
||||
return ch
|
||||
}
|
||||
|
||||
// newPersistedRouter stores a router so the connect path can load it. Create publishes the instance it was
|
||||
// given into the router cache, which is the instance a connection must not be handed.
|
||||
func newPersistedRouter(t *testing.T, network *Network, addr transport.Address, id string) *model.Router {
|
||||
t.Helper()
|
||||
router := model.NewRouterForTest(id, "", addr, nil, 0, false)
|
||||
require.NoError(t, network.Router.Create(router, change.New()))
|
||||
return router
|
||||
}
|
||||
|
||||
// TestNewCtrlChanRouter_InstanceIsNotShared is the guard on the assumption every currency check in the
|
||||
// connect and disconnect paths rests on: each connection gets its own Router instance. Two connections
|
||||
// sharing one instance are indistinguishable to those checks, so a connect into an occupied slot is not
|
||||
// rejected and the first connection's teardown dismantles the second's registration, leaving a live
|
||||
// control channel whose router is not registered and can never re-register.
|
||||
func TestNewCtrlChanRouter_InstanceIsNotShared(t *testing.T) {
|
||||
_, network, addr := newConnectTestNetwork(t)
|
||||
|
||||
cached := newPersistedRouter(t, network, addr, "r1")
|
||||
|
||||
first, err := network.NewCtrlChanRouter(newFakeConnectChannel("r1"))
|
||||
require.NoError(t, err)
|
||||
second, err := network.NewCtrlChanRouter(newFakeConnectChannel("r1"))
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NotSame(t, first, second, "each connection must get its own router instance")
|
||||
require.NotSame(t, cached, first, "a connection must not be handed the cached instance")
|
||||
require.NotSame(t, cached, second, "a connection must not be handed the cached instance")
|
||||
|
||||
// The connection's instance must not become the cached one either, or the next connection would be
|
||||
// handed it.
|
||||
readBack, err := network.Router.Read("r1")
|
||||
require.NoError(t, err)
|
||||
require.NotSame(t, first, readBack, "a connection's instance must stay out of the router cache")
|
||||
require.NotSame(t, second, readBack, "a connection's instance must stay out of the router cache")
|
||||
}
|
||||
|
||||
// TestNewCtrlChanRouter_ConcurrentConnectsAreNotShared covers the way instances came to be shared: the
|
||||
// load was an eviction followed by a read-through read, so two connects racing could both evict and the
|
||||
// later read could then hit the instance the earlier one had just published.
|
||||
func TestNewCtrlChanRouter_ConcurrentConnectsAreNotShared(t *testing.T) {
|
||||
_, network, addr := newConnectTestNetwork(t)
|
||||
newPersistedRouter(t, network, addr, "r1")
|
||||
|
||||
const connects = 16
|
||||
start := make(chan struct{})
|
||||
results := make([]*model.Router, connects)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 0; i < connects; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
r, err := network.NewCtrlChanRouter(newFakeConnectChannel("r1"))
|
||||
if err == nil {
|
||||
results[idx] = r
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
seen := map[*model.Router]int{}
|
||||
for idx, r := range results {
|
||||
require.NotNil(t, r, "connect %d failed to load a router", idx)
|
||||
seen[r]++
|
||||
}
|
||||
require.Len(t, seen, connects, "every concurrent connect must get its own router instance")
|
||||
}
|
||||
|
||||
// TestNewCtrlChanRouter_RecordsTheChannel: the instance arrives carrying its connection, so no caller has
|
||||
// to remember to attach it, and the channel it carries is the one it was built for.
|
||||
func TestNewCtrlChanRouter_RecordsTheChannel(t *testing.T) {
|
||||
_, network, addr := newConnectTestNetwork(t)
|
||||
newPersistedRouter(t, network, addr, "r1")
|
||||
|
||||
ch := newFakeConnectChannel("r1")
|
||||
r, err := network.NewCtrlChanRouter(ch)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NotNil(t, r.Control, "the connection's channel must be recorded")
|
||||
require.Same(t, ch, r.Control.GetChannel(), "the recorded channel must be the one the instance was built for")
|
||||
require.False(t, r.ConnectTime.IsZero(), "connect time must be recorded")
|
||||
require.False(t, r.Connected.Load(), "loading a router must not register it as connected")
|
||||
}
|
||||
|
||||
// TestNewCtrlChanRouter_UnknownRouter: a channel from a router the controller has no record of is refused
|
||||
// rather than yielding an empty instance.
|
||||
func TestNewCtrlChanRouter_UnknownRouter(t *testing.T) {
|
||||
_, network, _ := newConnectTestNetwork(t)
|
||||
|
||||
r, err := network.NewCtrlChanRouter(newFakeConnectChannel("nope"))
|
||||
require.Error(t, err)
|
||||
require.Nil(t, r)
|
||||
}
|
||||
|
||||
// TestNotifyExistingLink_RaceDisconnect is the invariant that link publication and disconnect teardown
|
||||
// cannot interleave: once a router is disconnected, no link may remain naming it as its source.
|
||||
//
|
||||
// Checking currency and then publishing without holding the router's connect stripe is a check-then-act.
|
||||
// A report can find the connection current, and by the time it reaches the link manager the teardown has
|
||||
// already taken its snapshot of the router's links and cleared them, so the link is recreated after
|
||||
// everything that would have removed it has run. It is then invisible to the router (its index was
|
||||
// cleared) while still in the controller's link table with a disconnected source, and a reconnect
|
||||
// reporting the same iteration can adopt that stale source rather than rebuilding the link.
|
||||
//
|
||||
// Run under -race, and repeated, since the window is small.
|
||||
func TestNotifyExistingLink_RaceDisconnect(t *testing.T) {
|
||||
_, network, addr := newConnectTestNetwork(t)
|
||||
|
||||
// The destination is left unconnected: only the source router's disconnect can recreate a stale link,
|
||||
// so connecting a second router would add the peer-state sync to the race for no extra coverage.
|
||||
for i := 0; i < 200; i++ {
|
||||
r := model.NewRouterForTest("r1", "", addr, &fakeCtrlChannel{}, 0, false)
|
||||
require.NoError(t, network.ConnectRouter(r))
|
||||
|
||||
reported := &ctrl_pb.RouterLinks_RouterLink{
|
||||
Id: fmt.Sprintf("l-%d", i),
|
||||
DestRouterId: "r2",
|
||||
LinkProtocol: "tls",
|
||||
DialAddress: "tcp:localhost:1234",
|
||||
Iteration: 1,
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
network.NotifyExistingLink(r, reported)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
network.DisconnectRouter(r)
|
||||
}()
|
||||
wg.Wait()
|
||||
|
||||
require.Nil(t, network.Router.GetConnected("r1"), "the router must be disconnected")
|
||||
for _, l := range network.Link.All() {
|
||||
require.NotEqual(t, "r1", l.Src.Id,
|
||||
"iteration %d: a link published by a router that has been disconnected must not survive", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -201,6 +201,13 @@ func (self *RouterMessaging) syncStates() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Sending on a closed channel fails immediately, so keeping these queued retries as fast as the
|
||||
// event loop spins. Reconnecting resyncs everything, so discard them.
|
||||
if ch := notifyRouter.Control; ch == nil || ch.IsClosed() {
|
||||
delete(self.routerUpdates, k)
|
||||
continue
|
||||
}
|
||||
|
||||
if v.sendInProgress.Load() {
|
||||
continue
|
||||
}
|
||||
@@ -243,15 +250,15 @@ func (self *RouterMessaging) syncStates() {
|
||||
|
||||
currentStatesVersion := updates.version
|
||||
queueErr := self.routerCommPool.QueueOrError(func() {
|
||||
ch := notifyRouter.Control
|
||||
if ch == nil {
|
||||
return
|
||||
}
|
||||
|
||||
success := true
|
||||
if err := protobufs.MarshalTyped(changes).WithTimeout(time.Second * 1).SendAndWaitForWire(ch.GetDefaultSender()); err != nil {
|
||||
pfxlog.Logger().WithError(err).WithField("routerId", notifyRouter.Id).Error("failed to send peer state changes to router")
|
||||
success = false
|
||||
// The done event must be queued on every path, including a missing channel: it is what clears
|
||||
// sendInProgress, and without it this router's updates would never be attempted again.
|
||||
success := false
|
||||
if ch := notifyRouter.Control; ch != nil {
|
||||
if err := protobufs.MarshalTyped(changes).WithTimeout(time.Second * 1).SendAndWaitForWire(ch.GetDefaultSender()); err != nil {
|
||||
pfxlog.Logger().WithError(err).WithField("routerId", notifyRouter.Id).Error("failed to send peer state changes to router")
|
||||
} else {
|
||||
success = true
|
||||
}
|
||||
}
|
||||
|
||||
self.queueEvent(&routerPeerChangesSendDone{
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
Copyright NetFoundry Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package network
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/openziti/ziti/v2/controller/model"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestSyncStates_DiscardsUpdatesForClosedChannel covers the retry behaviour for peer state changes bound
|
||||
// for a router whose control channel has closed while it is still in the connected map. Sending on a
|
||||
// closed channel fails immediately instead of blocking, and a failed send is retried as soon as the event
|
||||
// loop turns, so retaining the updates spins the loop and floods the log. Reconnecting resyncs everything,
|
||||
// so the pending changes are discarded instead. Updates for a router with a live channel must still be
|
||||
// retained.
|
||||
func TestSyncStates_DiscardsUpdatesForClosedChannel(t *testing.T) {
|
||||
ctx, network, addr := newConnectTestNetwork(t)
|
||||
|
||||
// A standalone instance, so syncStates can be driven directly; the Network's own RouterMessaging runs
|
||||
// an event loop goroutine that would race these map accesses.
|
||||
rm := NewRouterMessaging(ctx, network.RouterMessaging.routerCommPool)
|
||||
|
||||
deadCh := &fakeCtrlChannel{}
|
||||
dead := model.NewRouterForTest("r1", "", addr, deadCh, 0, false)
|
||||
network.Router.MarkConnected(dead)
|
||||
// The channel closes without any teardown running, so it stays in the connected map.
|
||||
deadCh.closed.Store(true)
|
||||
rm.routerUpdates["r1"] = &routerUpdates{changedRouters: map[string]struct{}{"other": {}}}
|
||||
|
||||
// A live router with a send already in flight, which syncStates skips without queueing another.
|
||||
liveCh := &fakeCtrlChannel{}
|
||||
live := model.NewRouterForTest("r2", "", addr, liveCh, 0, false)
|
||||
network.Router.MarkConnected(live)
|
||||
liveUpdates := &routerUpdates{changedRouters: map[string]struct{}{"other": {}}}
|
||||
liveUpdates.sendInProgress.Store(true)
|
||||
rm.routerUpdates["r2"] = liveUpdates
|
||||
|
||||
rm.syncStates()
|
||||
|
||||
require.NotContains(t, rm.routerUpdates, "r1", "updates for a closed channel must be discarded, not retried")
|
||||
require.Contains(t, rm.routerUpdates, "r2", "updates for a live channel must be retained")
|
||||
}
|
||||
@@ -24,6 +24,8 @@ import (
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/michaelquigley/pfxlog"
|
||||
"github.com/openziti/ziti/v2/common"
|
||||
"github.com/openziti/ziti/v2/controller/api"
|
||||
"github.com/openziti/ziti/v2/controller/apierror"
|
||||
"github.com/openziti/ziti/v2/controller/db"
|
||||
"github.com/openziti/ziti/v2/controller/model"
|
||||
"github.com/pkg/errors"
|
||||
@@ -99,6 +101,15 @@ func NewNativeOnlyOP(ctx context.Context, env model.Env, config Config) (http.Ha
|
||||
}
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// OIDC endpoints buffer request bodies before authentication, so cap what an
|
||||
// unauthenticated client can make the controller hold in memory
|
||||
if r.ContentLength > api.MaxRequestBodySize {
|
||||
renderJsonApiError(w, apierror.NewRequestEntityTooLarge())
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, api.MaxRequestBodySize)
|
||||
|
||||
for iss, handler := range handlers {
|
||||
if err := iss.ValidFor(r.Host); err == nil {
|
||||
handler.ServeHTTP(w, r)
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"github.com/michaelquigley/pfxlog"
|
||||
"github.com/openziti/edge-api/rest_model"
|
||||
"github.com/openziti/foundation/v2/errorz"
|
||||
"github.com/openziti/ziti/v2/controller/apierror"
|
||||
)
|
||||
|
||||
// render will attempt to send a responses on the provided http.ResponseWriter. All error output will be directed to the
|
||||
@@ -93,7 +94,13 @@ func renderJsonApiError(w http.ResponseWriter, err *errorz.ApiError) {
|
||||
|
||||
func errorToRestApiError(err error) (*rest_model.APIError, int) {
|
||||
var typedErr *errorz.ApiError
|
||||
var maxBytesErr *http.MaxBytesError
|
||||
switch {
|
||||
case errors.As(err, &maxBytesErr):
|
||||
return &rest_model.APIError{
|
||||
Code: apierror.RequestEntityTooLargeCode,
|
||||
Message: apierror.RequestEntityTooLargeMessage,
|
||||
}, http.StatusRequestEntityTooLarge
|
||||
case errors.As(err, &typedErr):
|
||||
restErr := &rest_model.APIError{
|
||||
Code: typedErr.AppCode,
|
||||
|
||||
@@ -455,7 +455,6 @@ func (s *HybridStorage) Authenticate(authCtx model.AuthContext, id string, confi
|
||||
|
||||
if certAuth != nil {
|
||||
authRequest.IsCertExtendable = certAuth.IsIssuedByNetwork
|
||||
authRequest.IsCertExtendable = true
|
||||
authRequest.IsCertKeyRollRequested = certAuth.IsKeyRollRequested
|
||||
authRequest.ImproperClientCertChain = result.ImproperClientCertChain()
|
||||
}
|
||||
|
||||
@@ -30,11 +30,11 @@ import (
|
||||
|
||||
"github.com/hashicorp/raft"
|
||||
"github.com/michaelquigley/pfxlog"
|
||||
"github.com/openziti/ziti/v2/controller/storage/boltz"
|
||||
"github.com/openziti/ziti/v2/controller/change"
|
||||
"github.com/openziti/ziti/v2/controller/command"
|
||||
"github.com/openziti/ziti/v2/controller/db"
|
||||
"github.com/openziti/ziti/v2/controller/event"
|
||||
"github.com/openziti/ziti/v2/controller/storage/boltz"
|
||||
"github.com/sirupsen/logrus"
|
||||
"go.etcd.io/bbolt"
|
||||
bbolterrors "go.etcd.io/bbolt/errors"
|
||||
@@ -306,7 +306,8 @@ func (self *BoltDbFsm) Apply(log *raft.Log) interface{} {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Infof("apply log with type %T", cmd)
|
||||
logger = logger.WithField("cmdType", fmt.Sprintf("%T", cmd))
|
||||
logger.Info("applying log")
|
||||
changeCtx := cmd.GetChangeContext()
|
||||
if changeCtx == nil {
|
||||
changeCtx = change.New().SetSourceType("unattributed").SetChangeAuthorType(change.AuthorTypeUnattributed)
|
||||
@@ -319,8 +320,13 @@ func (self *BoltDbFsm) Apply(log *raft.Log) interface{} {
|
||||
})
|
||||
|
||||
if err = cmd.Apply(ctx); err != nil {
|
||||
if _, critical := cmd.(command.CriticalCommand); critical {
|
||||
// Base-state command failed; halt instead of advancing over incomplete state. The
|
||||
// in-tx index update rolled back with the apply, so raft replays it on restart.
|
||||
logger.WithError(err).Fatal("failed to apply critical base-state command; halting rather than advancing over incomplete state")
|
||||
}
|
||||
logger.WithError(err).Error("applying log resulted in error")
|
||||
// if this errored, assume that we haven't updated the index in the db
|
||||
// apply rolled back the in-tx index update; persist it here since raft advances regardless
|
||||
self.updateIndex(log.Index)
|
||||
}
|
||||
|
||||
|
||||
+108
-26
@@ -29,6 +29,7 @@ import (
|
||||
|
||||
"github.com/openziti/foundation/v2/concurrenz"
|
||||
"github.com/openziti/foundation/v2/versions"
|
||||
"github.com/openziti/ziti/v2/common/cert"
|
||||
"github.com/openziti/ziti/v2/controller/event"
|
||||
|
||||
"github.com/hashicorp/raft"
|
||||
@@ -42,12 +43,13 @@ import (
|
||||
|
||||
const (
|
||||
// New header IDs, starting at 2000 to avoid conflicts with channel base headers (0-12+)
|
||||
PeerAddrHeader = 2000
|
||||
SigningCertHeader = 2001
|
||||
ApiAddressesHeader = 2002
|
||||
RaftConnIdHeader = 2003
|
||||
ClusterIdHeader = 2004
|
||||
PreferredLeaderHeader = 2005
|
||||
PeerAddrHeader = 2000
|
||||
SigningCertHeader = 2001
|
||||
ApiAddressesHeader = 2002
|
||||
RaftConnIdHeader = 2003
|
||||
ClusterIdHeader = 2004
|
||||
PreferredLeaderHeader = 2005
|
||||
SigningCertChainHeader = 2006 // full signing cert chain, leaf first, as concatenated DER
|
||||
|
||||
// Legacy header IDs, used as fallback when reading from older peers
|
||||
LegacyPeerAddrHeader = 11
|
||||
@@ -70,11 +72,12 @@ type Peer struct {
|
||||
Id raft.ServerID
|
||||
Address string
|
||||
Channel channel.Channel
|
||||
RaftConns concurrenz.CopyOnWriteMap[uint32, *raftPeerConn]
|
||||
ClusterId string
|
||||
Version *versions.VersionInfo
|
||||
SigningCerts []*x509.Certificate
|
||||
ApiAddresses map[string][]event.ApiAddress
|
||||
PreferredLeader bool
|
||||
RaftConns concurrenz.CopyOnWriteMap[uint32, *raftPeerConn]
|
||||
raftPeerIdGen uint32
|
||||
}
|
||||
|
||||
@@ -323,6 +326,10 @@ type Mesh interface {
|
||||
RegisterClusterStateHandler(f func(state ClusterState))
|
||||
Init(bindHandler channel.BindHandler)
|
||||
CleanupDialRecords()
|
||||
|
||||
// RevalidatePeerClusterIds drops connected peers whose cluster id no longer matches the local
|
||||
// cluster id. It is called when the local node acquires or changes its cluster id.
|
||||
RevalidatePeerClusterIds()
|
||||
}
|
||||
|
||||
func New(env Env, raftAddr raft.ServerAddress, helloHeaderProviders []HeaderProvider) Mesh {
|
||||
@@ -452,8 +459,10 @@ func (self *impl) GetOrConnectPeer(address string, timeout time.Duration) (*Peer
|
||||
|
||||
tlsCert := self.nodeId.ServerCert()
|
||||
var serverCert []byte
|
||||
var serverCertChain []byte
|
||||
if len(tlsCert) != 0 && len(tlsCert[0].Certificate) != 0 {
|
||||
serverCert = tlsCert[0].Certificate[0]
|
||||
serverCertChain = ConcatDer(tlsCert[0].Certificate)
|
||||
}
|
||||
|
||||
headers := map[int32][]byte{
|
||||
@@ -463,6 +472,7 @@ func (self *impl) GetOrConnectPeer(address string, timeout time.Duration) (*Peer
|
||||
LegacyPeerAddrHeader: []byte(self.raftAddr),
|
||||
SigningCertHeader: serverCert,
|
||||
LegacySigningCertHeader: serverCert,
|
||||
SigningCertChainHeader: serverCertChain,
|
||||
ClusterIdHeader: []byte(self.env.GetClusterId()),
|
||||
LegacyClusterIdHeader: []byte(self.env.GetClusterId()),
|
||||
}
|
||||
@@ -472,14 +482,16 @@ func (self *impl) GetOrConnectPeer(address string, timeout time.Duration) (*Peer
|
||||
}
|
||||
|
||||
// Check if a recent dial to this address failed (e.g., hello too large for an old peer).
|
||||
// If so, strip the new signing cert header so only the legacy header is sent, allowing
|
||||
// If so, strip the new signing cert headers so only the legacy header is sent, allowing
|
||||
// the connection to succeed with old controllers that enforce the smaller hello limit.
|
||||
self.lock.RLock()
|
||||
if rec := self.dialRecords[address]; rec != nil && time.Since(rec.lastAttempt) < RecentDialInterval {
|
||||
if rec.peerVersion == nil {
|
||||
delete(headers, SigningCertHeader)
|
||||
delete(headers, SigningCertChainHeader)
|
||||
} else if hasMin, _ := rec.peerVersion.HasMinimumVersion("v2.0.0"); !hasMin {
|
||||
delete(headers, SigningCertHeader)
|
||||
delete(headers, SigningCertChainHeader)
|
||||
}
|
||||
}
|
||||
self.lock.RUnlock()
|
||||
@@ -514,6 +526,7 @@ func (self *impl) GetOrConnectPeer(address string, timeout time.Duration) (*Peer
|
||||
if err = self.validateConnection(peer.Channel); err != nil {
|
||||
return err
|
||||
}
|
||||
peer.ClusterId = getPeerClusterId(peer.Channel)
|
||||
|
||||
underlay := binding.GetChannel().Underlay()
|
||||
id, err := self.extractPeerId(underlay.GetRemoteAddr().String(), underlay.Certificates())
|
||||
@@ -544,7 +557,10 @@ func (self *impl) GetOrConnectPeer(address string, timeout time.Duration) (*Peer
|
||||
}
|
||||
|
||||
peer.Version = versionInfo
|
||||
peer.SigningCerts = []*x509.Certificate{underlay.Certificates()[0]}
|
||||
peer.SigningCerts = signingCertsFromHeaders(peer.Channel.Underlay().Headers())
|
||||
if len(peer.SigningCerts) == 0 {
|
||||
peer.SigningCerts = underlay.Certificates()
|
||||
}
|
||||
|
||||
self.lock.Lock()
|
||||
if rec := self.dialRecords[address]; rec != nil {
|
||||
@@ -589,27 +605,63 @@ func (self *impl) validateConnection(ch channel.Channel) error {
|
||||
}
|
||||
|
||||
func (self *impl) checkClusterIds(ch channel.Channel) error {
|
||||
clusterIdBytes, _ := headerWithFallback(ch.Underlay().Headers(), ClusterIdHeader, LegacyClusterIdHeader)
|
||||
clusterId := string(clusterIdBytes)
|
||||
clusterId := getPeerClusterId(ch)
|
||||
if clusterId != "" && self.env.GetClusterId() != "" && clusterId != self.env.GetClusterId() {
|
||||
return fmt.Errorf("local cluster id %s doesn't match peer cluster id %s", self.env.GetClusterId(), clusterId)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *impl) checkCerts(ch channel.Channel) error {
|
||||
certs := ch.Underlay().Certificates()
|
||||
if len(certs) == 0 {
|
||||
return errors.New("unable to validate peer connection, no certs presented")
|
||||
}
|
||||
// getPeerClusterId returns the cluster id the peer advertised, or "" if none (a blank node not yet
|
||||
// joined or bootstrapped).
|
||||
func getPeerClusterId(ch channel.Channel) string {
|
||||
clusterIdBytes, _ := headerWithFallback(ch.Underlay().Headers(), ClusterIdHeader, LegacyClusterIdHeader)
|
||||
return string(clusterIdBytes)
|
||||
}
|
||||
|
||||
for _, cert := range ch.Underlay().Certificates() {
|
||||
if _, err := self.env.GetNodeId().CaPool().VerifyToRoot(cert); err == nil {
|
||||
return nil
|
||||
// RevalidatePeerClusterIds drops connected peers whose known cluster id differs from the local one.
|
||||
// The bind-time check runs once and skips empty ids, so a node that connected while blank and later
|
||||
// acquired a different id would otherwise stay cross-connected. Called when the local id is set.
|
||||
func (self *impl) RevalidatePeerClusterIds() {
|
||||
localId := self.env.GetClusterId()
|
||||
for _, peer := range peersWithMismatchedClusterId(localId, self.GetPeers()) {
|
||||
pfxlog.Logger().
|
||||
WithField("peerId", string(peer.Id)).
|
||||
WithField("peerAddress", peer.Address).
|
||||
WithField("peerClusterId", peer.ClusterId).
|
||||
WithField("localClusterId", localId).
|
||||
Error("dropping peer connection with mismatched cluster id")
|
||||
if err := peer.Channel.Close(); err != nil {
|
||||
pfxlog.Logger().WithError(err).
|
||||
WithField("peerId", string(peer.Id)).
|
||||
Error("error closing peer channel with mismatched cluster id")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors.New("unable to validate peer connection, no certs presented matched the CA for this node")
|
||||
// peersWithMismatchedClusterId returns peers whose known cluster id differs from localId. Peers with
|
||||
// an empty id (legitimate blank joiners) are never returned, nor is anything when localId is empty.
|
||||
func peersWithMismatchedClusterId(localId string, peers map[string]*Peer) []*Peer {
|
||||
if localId == "" {
|
||||
return nil
|
||||
}
|
||||
var mismatched []*Peer
|
||||
for _, peer := range peers {
|
||||
if peer.ClusterId != "" && peer.ClusterId != localId {
|
||||
mismatched = append(mismatched, peer)
|
||||
}
|
||||
}
|
||||
return mismatched
|
||||
}
|
||||
|
||||
func (self *impl) checkCerts(ch channel.Channel) error {
|
||||
// Peer identity is taken from certs[0] via ExtractSpiffeId, so certs[0] is the certificate that must
|
||||
// chain to a trusted CA; VerifyLeafCertChain verifies that leaf specifically against the node's full
|
||||
// trusted-CA pool.
|
||||
if _, err := cert.VerifyLeafCertChain(self.env.GetNodeId().CA(), ch.Underlay().Certificates()); err != nil {
|
||||
return fmt.Errorf("unable to validate peer connection: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *impl) GetPeerInfo(address string, timeout time.Duration) (raft.ServerID, raft.ServerAddress, error) {
|
||||
@@ -701,6 +753,12 @@ func ExtractSpiffeId(certs []*x509.Certificate) (string, error) {
|
||||
|
||||
func (self *impl) PeerConnected(peer *Peer, dial bool) error {
|
||||
self.lock.Lock()
|
||||
// Re-check the cluster id under the lock: the bind-time check can race a local-id change that
|
||||
// lands before the peer is registered, which RevalidatePeerClusterIds would then miss.
|
||||
if localId := self.env.GetClusterId(); localId != "" && peer.ClusterId != "" && peer.ClusterId != localId {
|
||||
self.lock.Unlock()
|
||||
return fmt.Errorf("peer %v cluster id %s does not match local cluster id %s", peer.Id, peer.ClusterId, localId)
|
||||
}
|
||||
if self.Peers[peer.Address] != nil {
|
||||
defer self.lock.Unlock()
|
||||
return fmt.Errorf("connection from peer %v @ %v already present", peer.Id, peer.Address)
|
||||
@@ -879,15 +937,12 @@ func (self *impl) AcceptUnderlay(underlay channel.Underlay) error {
|
||||
if err = self.validateConnection(peer.Channel); err != nil {
|
||||
return err
|
||||
}
|
||||
peer.ClusterId = getPeerClusterId(peer.Channel)
|
||||
|
||||
peer.Version = versionInfo
|
||||
if certHeader, found := headerWithFallback(ch.Underlay().Headers(), SigningCertHeader, LegacySigningCertHeader); found {
|
||||
if cert, err := x509.ParseCertificate(certHeader); err == nil {
|
||||
peer.SigningCerts = []*x509.Certificate{cert}
|
||||
}
|
||||
}
|
||||
peer.SigningCerts = signingCertsFromHeaders(ch.Underlay().Headers())
|
||||
if len(peer.SigningCerts) == 0 {
|
||||
peer.SigningCerts = []*x509.Certificate{underlay.Certificates()[0]}
|
||||
peer.SigningCerts = underlay.Certificates()
|
||||
}
|
||||
|
||||
binding.AddReceiveHandlerF(RaftDataType, peer.handleReceiveData)
|
||||
@@ -974,6 +1029,33 @@ func headerWithFallback(headers map[int32][]byte, key int32, legacyKey int32) ([
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// ConcatDer concatenates DER-encoded certificates into a single byte slice, parseable
|
||||
// with x509.ParseCertificates.
|
||||
func ConcatDer(certs [][]byte) []byte {
|
||||
var result []byte
|
||||
for _, der := range certs {
|
||||
result = append(result, der...)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// signingCertsFromHeaders extracts a peer's signing certificates from hello headers,
|
||||
// preferring the full chain header (leaf first) over the older single-cert headers.
|
||||
// Returns nil when no header yields a certificate.
|
||||
func signingCertsFromHeaders(headers map[int32][]byte) []*x509.Certificate {
|
||||
if chainHeader, found := headers[SigningCertChainHeader]; found {
|
||||
if certs, err := x509.ParseCertificates(chainHeader); err == nil && len(certs) > 0 {
|
||||
return certs
|
||||
}
|
||||
}
|
||||
if certHeader, found := headerWithFallback(headers, SigningCertHeader, LegacySigningCertHeader); found {
|
||||
if signingCert, err := x509.ParseCertificate(certHeader); err == nil {
|
||||
return []*x509.Certificate{signingCert}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getUint32HeaderWithFallback(m *channel.Message, key int32, legacyKey int32) (uint32, bool) {
|
||||
if val, ok := m.GetUint32Header(key); ok {
|
||||
return val, true
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
/*
|
||||
Copyright NetFoundry Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package mesh
|
||||
|
||||
// End-to-end validation of the mesh peer certificate check over a real TCP+TLS socket, using the
|
||||
// production controller TLS config (identity.ServerTLSConfig). The ctrl listener uses
|
||||
// RequireAnyClientCert and does not verify the client chain, so the handshake completes for any
|
||||
// presented client leaf; peer admission is enforced afterward by the direction-aware cert-chain check
|
||||
// against the node CA pool. Unit-level coverage of the shared check lives in common/cert.
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/openziti/identity"
|
||||
"github.com/openziti/ziti/v2/common/cert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const testTrustDomain = "spiffe://mesh-cert-test"
|
||||
|
||||
type certAndKey struct {
|
||||
cert *x509.Certificate
|
||||
key *ecdsa.PrivateKey
|
||||
}
|
||||
|
||||
var testSerial int64
|
||||
|
||||
func nextTestSerial() *big.Int {
|
||||
testSerial++
|
||||
return big.NewInt(testSerial)
|
||||
}
|
||||
|
||||
func mkTestKey(t *testing.T) *ecdsa.PrivateKey {
|
||||
t.Helper()
|
||||
k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
return k
|
||||
}
|
||||
|
||||
func signTestCert(t *testing.T, tmpl, parent *x509.Certificate, pub *ecdsa.PublicKey, signerKey *ecdsa.PrivateKey) *x509.Certificate {
|
||||
t.Helper()
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, parent, pub, signerKey)
|
||||
require.NoError(t, err)
|
||||
c, err := x509.ParseCertificate(der)
|
||||
require.NoError(t, err)
|
||||
return c
|
||||
}
|
||||
|
||||
func mkCA(t *testing.T, cn string, parent *certAndKey) *certAndKey {
|
||||
t.Helper()
|
||||
key := mkTestKey(t)
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: nextTestSerial(),
|
||||
Subject: pkix.Name{CommonName: cn},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
IsCA: true,
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
signParent, signKey := tmpl, key
|
||||
if parent != nil {
|
||||
signParent, signKey = parent.cert, parent.key
|
||||
}
|
||||
return &certAndKey{cert: signTestCert(t, tmpl, signParent, &key.PublicKey, signKey), key: key}
|
||||
}
|
||||
|
||||
func mkLeafWithKey(t *testing.T, cn, spiffePath string, ekus []x509.ExtKeyUsage, signer *certAndKey, key *ecdsa.PrivateKey) *certAndKey {
|
||||
t.Helper()
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: nextTestSerial(),
|
||||
Subject: pkix.Name{CommonName: cn},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: ekus,
|
||||
}
|
||||
if spiffePath != "" {
|
||||
u, err := url.Parse(testTrustDomain + spiffePath)
|
||||
require.NoError(t, err)
|
||||
tmpl.URIs = []*url.URL{u}
|
||||
}
|
||||
signParent, signKey := tmpl, key
|
||||
if signer != nil {
|
||||
signParent, signKey = signer.cert, signer.key
|
||||
}
|
||||
return &certAndKey{cert: signTestCert(t, tmpl, signParent, &key.PublicKey, signKey), key: key}
|
||||
}
|
||||
|
||||
func mkLeaf(t *testing.T, cn, spiffePath string, ekus []x509.ExtKeyUsage, signer *certAndKey) *certAndKey {
|
||||
return mkLeafWithKey(t, cn, spiffePath, ekus, signer, mkTestKey(t))
|
||||
}
|
||||
|
||||
func pemOfCerts(t *testing.T, certs ...*x509.Certificate) string {
|
||||
t.Helper()
|
||||
var b strings.Builder
|
||||
for _, c := range certs {
|
||||
require.NoError(t, pem.Encode(&b, &pem.Block{Type: "CERTIFICATE", Bytes: c.Raw}))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func pemOfKey(t *testing.T, key *ecdsa.PrivateKey) string {
|
||||
t.Helper()
|
||||
der, err := x509.MarshalPKCS8PrivateKey(key)
|
||||
require.NoError(t, err)
|
||||
var b strings.Builder
|
||||
require.NoError(t, pem.Encode(&b, &pem.Block{Type: "PRIVATE KEY", Bytes: der}))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Test_MeshPeerCert_LiveHandshake stands up a real TLS listener with the production node identity,
|
||||
// then connects as both a rogue and a legitimate peer. The rogue presents a self-signed identity leaf
|
||||
// plus the node's own (scraped) server cert as an extra certificate; the handshake completes, but the
|
||||
// mesh check must reject it. The legitimate peer presents a CA-signed client leaf and must be accepted.
|
||||
func Test_MeshPeerCert_LiveHandshake(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
root := mkCA(t, "root", nil)
|
||||
inter := mkCA(t, "int", root)
|
||||
|
||||
// Real node identity: one key backs the client and server certs (LoadIdentity uses the default
|
||||
// key for the server cert when no server_key is configured).
|
||||
nodeKey := mkTestKey(t)
|
||||
clientCert := mkLeafWithKey(t, "node-client", "/controller/real-id",
|
||||
[]x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth}, inter, nodeKey)
|
||||
serverCert := mkLeafWithKey(t, "node-server", "",
|
||||
[]x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, inter, nodeKey)
|
||||
|
||||
id, err := identity.LoadIdentity(identity.Config{
|
||||
Key: "pem:" + pemOfKey(t, nodeKey),
|
||||
Cert: "pem:" + pemOfCerts(t, clientCert.cert, inter.cert),
|
||||
ServerCert: "pem:" + pemOfCerts(t, serverCert.cert, inter.cert),
|
||||
CA: "pem:" + pemOfCerts(t, root.cert, inter.cert),
|
||||
})
|
||||
req.NoError(err)
|
||||
|
||||
serverCfg := id.ServerTLSConfig()
|
||||
req.NotNil(serverCfg)
|
||||
req.Equal(tls.RequireAnyClientCert, serverCfg.ClientAuth)
|
||||
|
||||
rawLn, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
req.NoError(err)
|
||||
defer func() { _ = rawLn.Close() }()
|
||||
ln := tls.NewListener(rawLn, serverCfg)
|
||||
addr := ln.Addr().String()
|
||||
|
||||
type acceptResult struct {
|
||||
peer []*x509.Certificate
|
||||
err error
|
||||
}
|
||||
results := make(chan acceptResult, 3)
|
||||
go func() {
|
||||
for i := 0; i < 3; i++ {
|
||||
c, aerr := ln.Accept()
|
||||
if aerr != nil {
|
||||
results <- acceptResult{err: aerr}
|
||||
continue
|
||||
}
|
||||
tc := c.(*tls.Conn)
|
||||
_ = tc.SetDeadline(time.Now().Add(10 * time.Second))
|
||||
if herr := tc.Handshake(); herr != nil {
|
||||
results <- acceptResult{err: herr}
|
||||
_ = tc.Close()
|
||||
continue
|
||||
}
|
||||
results <- acceptResult{peer: tc.ConnectionState().PeerCertificates}
|
||||
_ = tc.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
ca := id.CA()
|
||||
|
||||
// Scrape the node's own server certificate from the listener (a throwaway client cert satisfies
|
||||
// RequireAnyClientCert). This is the "extra" certificate the rogue peer will present.
|
||||
throwaway := mkLeaf(t, "throwaway", "", []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, nil)
|
||||
scrapeConn, err := tls.Dial("tcp", addr, &tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
Certificates: []tls.Certificate{{Certificate: [][]byte{throwaway.cert.Raw}, PrivateKey: throwaway.key, Leaf: throwaway.cert}},
|
||||
})
|
||||
req.NoError(err)
|
||||
serverPresented := scrapeConn.ConnectionState().PeerCertificates
|
||||
_ = scrapeConn.Close()
|
||||
req.NotEmpty(serverPresented)
|
||||
extra := serverPresented[0]
|
||||
req.Equal("node-server", extra.Subject.CommonName)
|
||||
<-results
|
||||
|
||||
// The scraped server certificate is server-auth-only, yet it chains to the node CA. Verification is
|
||||
// EKU-agnostic and anchors on the node's full trusted-CA pool, so it is accepted - this is the
|
||||
// certificate an outbound dial would present as its leaf.
|
||||
_, err = cert.VerifyLeafCertChain(ca, serverPresented)
|
||||
req.NoError(err, "a server-auth leaf that chains to the node CA is accepted")
|
||||
|
||||
// Rogue peer: self-signed identity leaf + the scraped extra cert. Handshake completes; the mesh
|
||||
// check must reject it because the identity leaf (certs[0]) does not chain to the CA.
|
||||
rogue := mkLeaf(t, "rogue", "/controller/victim-id", []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, nil)
|
||||
rogueConn, err := tls.Dial("tcp", addr, &tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
Certificates: []tls.Certificate{{Certificate: [][]byte{rogue.cert.Raw, extra.Raw}, PrivateKey: rogue.key, Leaf: rogue.cert}},
|
||||
})
|
||||
req.NoError(err, "handshake completes under RequireAnyClientCert even for a self-signed leaf")
|
||||
_ = rogueConn.Close()
|
||||
rogueRes := <-results
|
||||
req.NoError(rogueRes.err)
|
||||
_, err = cert.VerifyLeafCertChain(ca, rogueRes.peer)
|
||||
req.Error(err, "mesh check rejects a self-signed identity leaf backed by a scraped extra cert")
|
||||
|
||||
// Legitimate peer: CA-signed client leaf. Handshake completes and the mesh check accepts it.
|
||||
legitConn, err := tls.Dial("tcp", addr, &tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
Certificates: []tls.Certificate{{Certificate: [][]byte{clientCert.cert.Raw, inter.cert.Raw}, PrivateKey: nodeKey, Leaf: clientCert.cert}},
|
||||
})
|
||||
req.NoError(err)
|
||||
_ = legitConn.Close()
|
||||
legitRes := <-results
|
||||
req.NoError(legitRes.err)
|
||||
_, err = cert.VerifyLeafCertChain(ca, legitRes.peer)
|
||||
req.NoError(err, "mesh check accepts a legitimate CA-signed peer leaf")
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/openziti/channel/v4"
|
||||
"github.com/openziti/foundation/v2/versions"
|
||||
"github.com/openziti/ziti/v2/controller/event"
|
||||
|
||||
@@ -56,6 +57,7 @@ func Test_AddPeer_PassesReadonlyWhenVersionsMatch(t *testing.T) {
|
||||
Peers: map[string]*Peer{},
|
||||
version: NewVersionProviderTest(),
|
||||
eventDispatcher: event.DispatcherMock{},
|
||||
env: &clusterIdEnv{},
|
||||
}
|
||||
|
||||
p := &Peer{Version: testVersion("1")}
|
||||
@@ -69,6 +71,7 @@ func Test_AddPeer_TurnsReadonlyWhenVersionsDoNotMatch(t *testing.T) {
|
||||
Peers: map[string]*Peer{},
|
||||
version: NewVersionProviderTest(),
|
||||
eventDispatcher: event.DispatcherMock{},
|
||||
env: &clusterIdEnv{},
|
||||
}
|
||||
|
||||
p := &Peer{Version: testVersion("dne")}
|
||||
@@ -125,6 +128,85 @@ func testVersion(v string) *versions.VersionInfo {
|
||||
return &versions.VersionInfo{Version: v}
|
||||
}
|
||||
|
||||
func Test_peersWithMismatchedClusterId(t *testing.T) {
|
||||
t.Run("returns nothing when local cluster id is empty", func(t *testing.T) {
|
||||
peers := map[string]*Peer{
|
||||
"a": {Id: "a", ClusterId: "cluster-1"},
|
||||
}
|
||||
assert.Empty(t, peersWithMismatchedClusterId("", peers))
|
||||
})
|
||||
|
||||
t.Run("returns nothing when all peers match", func(t *testing.T) {
|
||||
peers := map[string]*Peer{
|
||||
"a": {Id: "a", ClusterId: "cluster-1"},
|
||||
"b": {Id: "b", ClusterId: "cluster-1"},
|
||||
}
|
||||
assert.Empty(t, peersWithMismatchedClusterId("cluster-1", peers))
|
||||
})
|
||||
|
||||
t.Run("ignores a peer with an empty cluster id", func(t *testing.T) {
|
||||
// A blank peer is a legitimate joiner that has not yet adopted a cluster id.
|
||||
peers := map[string]*Peer{
|
||||
"a": {Id: "a", ClusterId: ""},
|
||||
}
|
||||
assert.Empty(t, peersWithMismatchedClusterId("cluster-1", peers))
|
||||
})
|
||||
|
||||
t.Run("returns only the peers whose cluster id differs", func(t *testing.T) {
|
||||
mismatch := &Peer{Id: "mismatch", ClusterId: "cluster-2"}
|
||||
peers := map[string]*Peer{
|
||||
"match": {Id: "match", ClusterId: "cluster-1"},
|
||||
"blank": {Id: "blank", ClusterId: ""},
|
||||
"mismatch": mismatch,
|
||||
}
|
||||
assert.Equal(t, []*Peer{mismatch}, peersWithMismatchedClusterId("cluster-1", peers))
|
||||
})
|
||||
}
|
||||
|
||||
// closeRecordingChannel is a channel.Channel that records whether Close was called. Only Close is
|
||||
// exercised by RevalidatePeerClusterIds; the embedded nil interface satisfies the rest.
|
||||
type closeRecordingChannel struct {
|
||||
channel.Channel
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (self *closeRecordingChannel) Close() error {
|
||||
self.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// clusterIdEnv is a mesh Env that reports a fixed cluster id. Only GetClusterId is exercised by
|
||||
// RevalidatePeerClusterIds; the embedded nil interface satisfies the rest.
|
||||
type clusterIdEnv struct {
|
||||
Env
|
||||
clusterId string
|
||||
}
|
||||
|
||||
func (self *clusterIdEnv) GetClusterId() string {
|
||||
return self.clusterId
|
||||
}
|
||||
|
||||
func Test_RevalidatePeerClusterIds_ClosesOnlyMismatchedPeers(t *testing.T) {
|
||||
matchCh := &closeRecordingChannel{}
|
||||
blankCh := &closeRecordingChannel{}
|
||||
mismatchCh := &closeRecordingChannel{}
|
||||
|
||||
m := &impl{
|
||||
env: &clusterIdEnv{clusterId: "cluster-1"},
|
||||
Peers: map[string]*Peer{
|
||||
"match": {Id: "match", ClusterId: "cluster-1", Channel: matchCh},
|
||||
"blank": {Id: "blank", ClusterId: "", Channel: blankCh},
|
||||
"mismatch": {Id: "mismatch", ClusterId: "cluster-2", Channel: mismatchCh},
|
||||
},
|
||||
}
|
||||
|
||||
m.RevalidatePeerClusterIds()
|
||||
|
||||
assert.False(t, matchCh.closed, "peer with matching cluster id should not be closed")
|
||||
assert.False(t, blankCh.closed, "peer with an empty cluster id should not be closed")
|
||||
assert.True(t, mismatchCh.closed, "peer with a mismatched cluster id should be closed")
|
||||
}
|
||||
|
||||
type VersionProviderTest struct {
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package mesh
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"math/big"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// newTestCertChain creates a self-signed root and a leaf issued by it, returning [leaf, root].
|
||||
func newTestCertChain(name string) ([]*x509.Certificate, error) {
|
||||
rootKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rootTemplate := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: name + "-root"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().AddDate(1, 0, 0),
|
||||
IsCA: true,
|
||||
BasicConstraintsValid: true,
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
|
||||
}
|
||||
|
||||
rootDer, err := x509.CreateCertificate(rand.Reader, rootTemplate, rootTemplate, &rootKey.PublicKey, rootKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rootCert, err := x509.ParseCertificate(rootDer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
leafKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
leafTemplate := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(2),
|
||||
Subject: pkix.Name{CommonName: name + "-leaf"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().AddDate(1, 0, 0),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
}
|
||||
|
||||
leafDer, err := x509.CreateCertificate(rand.Reader, leafTemplate, rootCert, &leafKey.PublicKey, rootKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
leafCert, err := x509.ParseCertificate(leafDer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []*x509.Certificate{leafCert, rootCert}, nil
|
||||
}
|
||||
|
||||
func Test_signingCertsFromHeaders(t *testing.T) {
|
||||
chain, err := newTestCertChain("mesh-test")
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("chain header yields the full chain", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
headers := map[int32][]byte{
|
||||
SigningCertChainHeader: ConcatDer([][]byte{chain[0].Raw, chain[1].Raw}),
|
||||
SigningCertHeader: chain[0].Raw,
|
||||
}
|
||||
|
||||
certs := signingCertsFromHeaders(headers)
|
||||
req.Len(certs, 2)
|
||||
req.True(certs[0].Equal(chain[0]))
|
||||
req.True(certs[1].Equal(chain[1]))
|
||||
})
|
||||
|
||||
t.Run("falls back to the single-cert header when no chain header present", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
headers := map[int32][]byte{
|
||||
SigningCertHeader: chain[0].Raw,
|
||||
}
|
||||
|
||||
certs := signingCertsFromHeaders(headers)
|
||||
req.Len(certs, 1)
|
||||
req.True(certs[0].Equal(chain[0]))
|
||||
})
|
||||
|
||||
t.Run("falls back to the legacy single-cert header", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
headers := map[int32][]byte{
|
||||
LegacySigningCertHeader: chain[0].Raw,
|
||||
}
|
||||
|
||||
certs := signingCertsFromHeaders(headers)
|
||||
req.Len(certs, 1)
|
||||
req.True(certs[0].Equal(chain[0]))
|
||||
})
|
||||
|
||||
t.Run("unparsable chain header falls back to the single-cert header", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
headers := map[int32][]byte{
|
||||
SigningCertChainHeader: []byte("not a certificate"),
|
||||
SigningCertHeader: chain[0].Raw,
|
||||
}
|
||||
|
||||
certs := signingCertsFromHeaders(headers)
|
||||
req.Len(certs, 1)
|
||||
req.True(certs[0].Equal(chain[0]))
|
||||
})
|
||||
|
||||
t.Run("no cert headers yields nil", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
certs := signingCertsFromHeaders(map[int32][]byte{})
|
||||
req.Nil(certs)
|
||||
})
|
||||
}
|
||||
+96
-9
@@ -41,7 +41,6 @@ import (
|
||||
"github.com/openziti/foundation/v2/versions"
|
||||
"github.com/openziti/identity"
|
||||
"github.com/openziti/metrics"
|
||||
"github.com/openziti/ziti/v2/controller/storage/boltz"
|
||||
"github.com/openziti/ziti/v2/common/pb/cmd_pb"
|
||||
"github.com/openziti/ziti/v2/common/pb/ctrl_pb"
|
||||
"github.com/openziti/ziti/v2/controller/apierror"
|
||||
@@ -53,6 +52,7 @@ import (
|
||||
"github.com/openziti/ziti/v2/controller/model"
|
||||
"github.com/openziti/ziti/v2/controller/peermsg"
|
||||
"github.com/openziti/ziti/v2/controller/raft/mesh"
|
||||
"github.com/openziti/ziti/v2/controller/storage/boltz"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/teris-io/shortid"
|
||||
)
|
||||
@@ -138,6 +138,7 @@ func NewController(env Env, migrationMgr MigrationManager) *Controller {
|
||||
clusterEvents: make(chan raft.Observation, 16),
|
||||
raftRateLimiter: command.NewAdaptiveRateLimitTracker(env.GetRaftRateLimiterConfig(), env.GetMetricsRegistry(), env.GetCloseNotify()),
|
||||
errorMappers: map[string]func(map[string]any) error{},
|
||||
decoders: command.NewDecoders(),
|
||||
}
|
||||
result.initErrorMappers()
|
||||
return result
|
||||
@@ -157,10 +158,18 @@ type Controller struct {
|
||||
indexTracker IndexTracker
|
||||
migrationMgr MigrationManager
|
||||
clusterStateChangeHandlers concurrenz.CopyOnWriteSlice[func(event ClusterEvent, state ClusterState, leaderId string)]
|
||||
clusterStateChangeLock sync.Mutex
|
||||
isLeader atomic.Bool
|
||||
clusterEvents chan raft.Observation
|
||||
raftRateLimiter rate.AdaptiveRateLimitTracker
|
||||
errorMappers map[string]func(map[string]any) error
|
||||
decoders command.Decoders
|
||||
}
|
||||
|
||||
// GetDecoders returns the command decoder registry this controller's raft FSM uses to decode
|
||||
// replicated log entries. It is per-controller so multiple in-process controllers stay isolated.
|
||||
func (self *Controller) GetDecoders() command.Decoders {
|
||||
return self.decoders
|
||||
}
|
||||
|
||||
func (self *Controller) GetNodeId() *identity.TokenId {
|
||||
@@ -199,6 +208,9 @@ func (self *Controller) GetListenerHeaders() map[int32][]byte {
|
||||
if self.Config.PreferredLeader {
|
||||
headers[mesh.PreferredLeaderHeader] = []byte{1}
|
||||
}
|
||||
if serverCerts := self.env.GetId().ServerCert(); len(serverCerts) > 0 && len(serverCerts[0].Certificate) > 0 {
|
||||
headers[mesh.SigningCertChainHeader] = mesh.ConcatDer(serverCerts[0].Certificate)
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
@@ -208,6 +220,10 @@ func (self *Controller) initErrorMappers() {
|
||||
}
|
||||
|
||||
func (self *Controller) RegisterClusterEventHandler(f func(event ClusterEvent, state ClusterState, leaderId string)) {
|
||||
// Hold the lock across the leader check and append so a leadership transition cannot slip
|
||||
// between them, which would leave the handler seeing neither the immediate call nor the event.
|
||||
self.clusterStateChangeLock.Lock()
|
||||
defer self.clusterStateChangeLock.Unlock()
|
||||
if self.isLeader.Load() {
|
||||
f(ClusterEventLeadershipGained, newClusterState(true, !self.Mesh.IsReadOnly()), self.env.GetId().Token)
|
||||
}
|
||||
@@ -215,12 +231,8 @@ func (self *Controller) RegisterClusterEventHandler(f func(event ClusterEvent, s
|
||||
}
|
||||
|
||||
func (self *Controller) InitEnv(env model.Env) error {
|
||||
// The cluster id is loaded earlier, in Init, so it is set before raft and the mesh start.
|
||||
model.RegisterCommand(env, &InitClusterIdCmd{}, &cmd_pb.InitClusterIdCommand{})
|
||||
clusterId, err := db.LoadClusterId(env.GetDb())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
self.clusterId.Store(clusterId)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -634,12 +646,20 @@ func (self *Controller) Init() error {
|
||||
self.clusterEvents <- obs
|
||||
})
|
||||
|
||||
self.Fsm = NewFsm(raftConfig.DataDir, raftConfig.RestartSelf, command.GetDefaultDecoders(), self.indexTracker, self.env.GetEventDispatcher())
|
||||
self.Fsm = NewFsm(raftConfig.DataDir, raftConfig.RestartSelf, self.decoders, self.indexTracker, self.env.GetEventDispatcher())
|
||||
|
||||
if err = self.Fsm.Init(); err != nil {
|
||||
return fmt.Errorf("failed to init FSM (%w)", err)
|
||||
}
|
||||
|
||||
// Load the cluster id before raft (and the mesh) start, so this node never presents an empty id
|
||||
// that the mesh empty-id bypass would let pair with a different cluster.
|
||||
clusterId, err := db.LoadClusterId(self.Fsm.GetDb())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load cluster id (%w)", err)
|
||||
}
|
||||
self.clusterId.Store(clusterId)
|
||||
|
||||
raftTransport := raft.NewNetworkTransportWithLogger(self.Mesh, 3, 10*time.Second, raftConfig.Logger)
|
||||
|
||||
if raftConfig.Recover {
|
||||
@@ -682,6 +702,7 @@ func (self *Controller) StartEventGeneration() {
|
||||
self.addEventsHandlers()
|
||||
go self.eventLoop()
|
||||
self.setupPreferredLeaderTransfer()
|
||||
self.setupClusterIdBackfill()
|
||||
}
|
||||
|
||||
func (self *Controller) setupPreferredLeaderTransfer() {
|
||||
@@ -762,6 +783,38 @@ func (self *Controller) transferToPreferredLeader() {
|
||||
log.Warn("no preferred leader peers are connected, retaining leadership")
|
||||
}
|
||||
|
||||
// setupClusterIdBackfill backfills a cluster id on leadership when the cluster has none, letting a
|
||||
// cluster migrated on an older build (which came up with no cluster id) self-heal. If a cluster
|
||||
// hasn't been bootstrapped yet, this isn't needed
|
||||
func (self *Controller) setupClusterIdBackfill() {
|
||||
if self.Raft.LastIndex() == 0 {
|
||||
return
|
||||
}
|
||||
self.RegisterClusterEventHandler(func(evt ClusterEvent, state ClusterState, leaderId string) {
|
||||
if evt == ClusterEventLeadershipGained {
|
||||
go self.backfillClusterId()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// backfillClusterId sets a cluster id if this leader finds none; a no-op otherwise, so it is safe to
|
||||
// run on every leadership change. The current timeline id is passed through unchanged.
|
||||
func (self *Controller) backfillClusterId() {
|
||||
if !self.IsLeader() || self.GetClusterId() != "" {
|
||||
return
|
||||
}
|
||||
clusterId := uuid.NewString()
|
||||
log := pfxlog.Logger().WithField("clusterId", clusterId)
|
||||
log.Info("cluster has no cluster id; backfilling one on leadership acquisition")
|
||||
if err := self.Dispatch(&InitClusterIdCmd{
|
||||
ClusterId: clusterId,
|
||||
TimelineId: self.env.TimelineId(),
|
||||
raftController: self,
|
||||
}); err != nil {
|
||||
log.WithError(err).Error("failed to backfill cluster id")
|
||||
}
|
||||
}
|
||||
|
||||
func (self *Controller) Configure(ctrlConfig *config.RaftConfig, conf *raft.Config) {
|
||||
conf.SnapshotThreshold = uint64(ctrlConfig.SnapshotThreshold)
|
||||
conf.SnapshotInterval = ctrlConfig.SnapshotInterval
|
||||
@@ -855,6 +908,9 @@ func (self *Controller) processRaftObservation(observation raft.Observation, eve
|
||||
pfxlog.Logger().Tracef("raft observation received: isLeader: %v, isReadWrite: %v", self.isLeader.Load(), eventState.isReadWrite)
|
||||
|
||||
if raftState, ok := observation.Data.(raft.RaftState); ok {
|
||||
// Serialize the leadership swap and dispatch with RegisterClusterEventHandler so a handler
|
||||
// registered during a transition is not missed by both the immediate call and the event.
|
||||
self.clusterStateChangeLock.Lock()
|
||||
if raftState == raft.Leader {
|
||||
if wasLeader := self.isLeader.Swap(true); !wasLeader {
|
||||
self.handleClusterStateChange(ClusterEventLeadershipGained, eventState)
|
||||
@@ -862,6 +918,7 @@ func (self *Controller) processRaftObservation(observation raft.Observation, eve
|
||||
} else if wasLeader := self.isLeader.Swap(false); wasLeader {
|
||||
self.handleClusterStateChange(ClusterEventLeadershipLost, eventState)
|
||||
}
|
||||
self.clusterStateChangeLock.Unlock()
|
||||
}
|
||||
|
||||
if state, ok := observation.Data.(mesh.ClusterState); ok {
|
||||
@@ -904,6 +961,17 @@ func (self *Controller) Bootstrap() error {
|
||||
logrus.Info("raft already bootstrapped")
|
||||
self.bootstrapped.Store(true)
|
||||
} else {
|
||||
// Already connected to peers means this node belongs to an existing cluster; founding a new
|
||||
// one here would fork a divergent cluster.
|
||||
if peers := self.Mesh.GetPeers(); len(peers) > 0 {
|
||||
addrs := make([]string, 0, len(peers))
|
||||
for addr := range peers {
|
||||
addrs = append(addrs, addr)
|
||||
}
|
||||
return fmt.Errorf("refusing to bootstrap a new cluster: node is already connected to %d cluster peer(s) %v; "+
|
||||
"this node should join the existing cluster (e.g. 'ziti agent cluster add' from a current member), not initialize a new one", len(peers), addrs)
|
||||
}
|
||||
|
||||
if err := self.migrationMgr.ValidateMigrationEnvironment(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1097,14 +1165,19 @@ type MigrationManager interface {
|
||||
InitializeRaftFromBoltDb(srcDb string) error
|
||||
}
|
||||
|
||||
var _ command.CriticalCommand = (*InitClusterIdCmd)(nil)
|
||||
|
||||
type InitClusterIdCmd struct {
|
||||
ClusterId string `json:"clusterId"`
|
||||
TimelineId string `json:"timelineId"`
|
||||
raftController *Controller
|
||||
}
|
||||
|
||||
// IsCriticalCommand marks InitClusterIdCmd as base state: it establishes the cluster id, which is
|
||||
// not otherwise replayed, so a failed apply must halt rather than advance. Its writes are idempotent.
|
||||
func (self *InitClusterIdCmd) IsCriticalCommand() {}
|
||||
|
||||
func (self *InitClusterIdCmd) Apply(ctx boltz.MutateContext) error {
|
||||
self.raftController.clusterId.Store(self.ClusterId)
|
||||
_, err := self.raftController.Fsm.GetDb().GetTimelineId(boltz.TimelineModeForceReset, func() (string, error) {
|
||||
return self.TimelineId, nil
|
||||
})
|
||||
@@ -1115,7 +1188,21 @@ func (self *InitClusterIdCmd) Apply(ctx boltz.MutateContext) error {
|
||||
if self.raftController.env.TimelineId() != self.TimelineId {
|
||||
self.raftController.env.InitTimelineId(self.TimelineId)
|
||||
}
|
||||
return db.InitClusterId(self.raftController.Fsm.GetDb(), ctx, self.ClusterId)
|
||||
|
||||
// Persist before publishing in memory. InitClusterId returns the effective id (an existing id
|
||||
// wins), so a redundant command cannot diverge memory from disk.
|
||||
effectiveClusterId, err := db.InitClusterId(self.raftController.Fsm.GetDb(), ctx, self.ClusterId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Publish and revalidate only after persisting. Revalidation drops peers from a different
|
||||
// cluster that connected while this node was blank; off the apply path so it does not stall raft.
|
||||
self.raftController.clusterId.Store(effectiveClusterId)
|
||||
if mesh := self.raftController.Mesh; mesh != nil {
|
||||
go mesh.RevalidatePeerClusterIds()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *InitClusterIdCmd) Encode() ([]byte, error) {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package sync_strats
|
||||
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/openziti/ziti/v2/common/pb/edge_ctrl_pb"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_newPublicKey(t *testing.T) {
|
||||
data := []byte("anchor-cert-der")
|
||||
intermediate1 := []byte("intermediate-1-der")
|
||||
intermediate2 := []byte("intermediate-2-der")
|
||||
|
||||
t.Run("sets kid from data fingerprint and carries usages and intermediates", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
publicKey := newPublicKey(data, edge_ctrl_pb.DataState_PublicKey_X509CertDer, firstPartyCaUsages, intermediate1, intermediate2)
|
||||
|
||||
req.Equal(data, publicKey.Data)
|
||||
req.Equal(fmt.Sprintf("%x", sha1.Sum(data)), publicKey.Kid)
|
||||
req.Equal(firstPartyCaUsages, publicKey.Usages)
|
||||
req.Equal(edge_ctrl_pb.DataState_PublicKey_X509CertDer, publicKey.Format)
|
||||
req.Equal([][]byte{intermediate1, intermediate2}, publicKey.Intermediates)
|
||||
})
|
||||
|
||||
t.Run("no intermediates yields empty intermediates", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
publicKey := newPublicKey(data, edge_ctrl_pb.DataState_PublicKey_X509CertDer, thirdPartyCaUsages)
|
||||
|
||||
req.Empty(publicKey.Intermediates)
|
||||
})
|
||||
|
||||
t.Run("usage sets pair the deprecated usage with the party-specific usage", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
req.Equal([]edge_ctrl_pb.DataState_PublicKey_Usage{edge_ctrl_pb.DataState_PublicKey_JWTValidation}, controllerCertUsages)
|
||||
|
||||
req.Contains(firstPartyCaUsages, edge_ctrl_pb.DataState_PublicKey_ClientX509CertValidation)
|
||||
req.Contains(firstPartyCaUsages, edge_ctrl_pb.DataState_PublicKey_FirstPartyX509CertValidation)
|
||||
req.NotContains(firstPartyCaUsages, edge_ctrl_pb.DataState_PublicKey_ThirdPartyX509CertValidation)
|
||||
|
||||
req.Contains(thirdPartyCaUsages, edge_ctrl_pb.DataState_PublicKey_ClientX509CertValidation)
|
||||
req.Contains(thirdPartyCaUsages, edge_ctrl_pb.DataState_PublicKey_ThirdPartyX509CertValidation)
|
||||
req.NotContains(thirdPartyCaUsages, edge_ctrl_pb.DataState_PublicKey_FirstPartyX509CertValidation)
|
||||
})
|
||||
}
|
||||
@@ -335,8 +335,18 @@ type routerTxMap struct {
|
||||
internalMap cmap.ConcurrentMap[string, *RouterSender] //id -> RouterSender
|
||||
}
|
||||
|
||||
// Add installs routerMessageTxer as the sender for id, stopping any sender it replaces. Stopping the
|
||||
// replaced sender here (rather than relying on RouterDisconnected) is required because the broker
|
||||
// dispatches the old connection's RouterDisconnected asynchronously: on a reconnect/takeover the new
|
||||
// connection's RouterConnected can install its sender before that async cleanup runs, which would
|
||||
// otherwise orphan the old sender's goroutine.
|
||||
func (m *routerTxMap) Add(id string, routerMessageTxer *RouterSender) {
|
||||
m.internalMap.Set(id, routerMessageTxer)
|
||||
m.internalMap.Upsert(id, routerMessageTxer, func(exists bool, old *RouterSender, newValue *RouterSender) *RouterSender {
|
||||
if exists && old != nil && old != newValue {
|
||||
old.Stop()
|
||||
}
|
||||
return newValue
|
||||
})
|
||||
}
|
||||
|
||||
func (m *routerTxMap) Get(id string) *RouterSender {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
Copyright NetFoundry Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package sync_strats
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
cmap "github.com/orcaman/concurrent-map/v2"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestRouterTxMap_AddStopsReplaced verifies routerTxMap.Add stops the RouterSender it replaces. This is
|
||||
// required under reject-if-busy: on a reconnect/takeover the new connection's RouterConnected can Add its
|
||||
// sender before the old connection's RouterDisconnected runs (the broker dispatches it asynchronously),
|
||||
// so without this the replaced sender's goroutine would be orphaned.
|
||||
func TestRouterTxMap_AddStopsReplaced(t *testing.T) {
|
||||
m := &routerTxMap{internalMap: cmap.New[*RouterSender]()}
|
||||
|
||||
old := &RouterSender{closeNotify: make(chan struct{})}
|
||||
old.running.Store(true)
|
||||
m.Add("r1", old)
|
||||
|
||||
newRtx := &RouterSender{closeNotify: make(chan struct{})}
|
||||
newRtx.running.Store(true)
|
||||
m.Add("r1", newRtx)
|
||||
|
||||
require.False(t, old.running.Load(), "replaced sender should be stopped")
|
||||
select {
|
||||
case <-old.closeNotify:
|
||||
default:
|
||||
t.Fatal("replaced sender's closeNotify should be closed")
|
||||
}
|
||||
require.True(t, newRtx.running.Load(), "installed sender should still be running")
|
||||
require.Equal(t, newRtx, m.Get("r1"))
|
||||
|
||||
// Re-adding the same instance must not stop it.
|
||||
m.Add("r1", newRtx)
|
||||
require.True(t, newRtx.running.Load(), "re-adding the same sender must not stop it")
|
||||
}
|
||||
@@ -19,7 +19,7 @@ package sync_strats
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -123,18 +123,6 @@ func (strategy *InstantStrategy) NextIndex(ctx boltz.MutateContext) (uint64, err
|
||||
return strategy.indexProvider.NextIndex(ctx)
|
||||
}
|
||||
|
||||
func (strategy *InstantStrategy) AddPublicKey(cert *tls.Certificate) {
|
||||
publicKey := newPublicKey(cert.Certificate[0], edge_ctrl_pb.DataState_PublicKey_X509CertDer, []edge_ctrl_pb.DataState_PublicKey_Usage{edge_ctrl_pb.DataState_PublicKey_ClientX509CertValidation, edge_ctrl_pb.DataState_PublicKey_JWTValidation})
|
||||
newModel := &edge_ctrl_pb.DataState_Event_PublicKey{PublicKey: publicKey}
|
||||
newEvent := &edge_ctrl_pb.DataState_Event{
|
||||
Action: edge_ctrl_pb.DataState_Create,
|
||||
Model: newModel,
|
||||
IsSynthetic: true,
|
||||
}
|
||||
|
||||
strategy.HandlePublicKeyEvent(newEvent, newModel)
|
||||
}
|
||||
|
||||
// Initialize implements RouterDataModelCache
|
||||
func (strategy *InstantStrategy) Initialize(logSize uint64, bufferSize uint) error {
|
||||
strategy.RouterDataModelSender = common.NewRouterDataModelSender(strategy.ae, logSize, bufferSize)
|
||||
@@ -779,10 +767,12 @@ func (strategy *InstantStrategy) synchronize(rtx *RouterSender) {
|
||||
}
|
||||
|
||||
for _, pk := range pks {
|
||||
// Send the stored key as-is: rebuilding it field-by-field silently drops fields
|
||||
// (this synthetic set bypasses router index checks and overwrites the full-sync copy).
|
||||
peerEvent := &edge_ctrl_pb.DataState_Event{
|
||||
Action: edge_ctrl_pb.DataState_Create,
|
||||
Model: &edge_ctrl_pb.DataState_Event_PublicKey{
|
||||
PublicKey: newPublicKey(pk.Data, pk.Format, pk.Usages),
|
||||
PublicKey: pk,
|
||||
},
|
||||
IsSynthetic: true,
|
||||
}
|
||||
@@ -904,7 +894,11 @@ func (strategy *InstantStrategy) BuildServicePolicies(tx *bbolt.Tx, rdm *common.
|
||||
func (strategy *InstantStrategy) BuildPublicKeys(tx *bbolt.Tx, rdm *common.RouterDataModelSender) error {
|
||||
serverTls := strategy.ae.HostController.Identity().ServerCert()
|
||||
|
||||
newModel := &edge_ctrl_pb.DataState_Event_PublicKey{PublicKey: newPublicKey(serverTls[0].Certificate[0], edge_ctrl_pb.DataState_PublicKey_X509CertDer, []edge_ctrl_pb.DataState_PublicKey_Usage{edge_ctrl_pb.DataState_PublicKey_JWTValidation, edge_ctrl_pb.DataState_PublicKey_ClientX509CertValidation})}
|
||||
// Controller certs are published leaf-only: routers use them solely for JWT validation, which
|
||||
// needs no chain, and a controller cert's kid is emitted by several paths (identity TLS chain
|
||||
// here, controller store records below and on create/update events). Identical content keeps
|
||||
// the sender and router models convergent under last-writer-wins by kid.
|
||||
newModel := &edge_ctrl_pb.DataState_Event_PublicKey{PublicKey: newPublicKey(serverTls[0].Certificate[0], edge_ctrl_pb.DataState_PublicKey_X509CertDer, controllerCertUsages)}
|
||||
newEvent := &edge_ctrl_pb.DataState_Event{
|
||||
Action: edge_ctrl_pb.DataState_Create,
|
||||
Model: newModel,
|
||||
@@ -923,7 +917,7 @@ func (strategy *InstantStrategy) BuildPublicKeys(tx *bbolt.Tx, rdm *common.Route
|
||||
}
|
||||
certs := nfPem.PemStringToCertificates(storeModel.CertPem)
|
||||
|
||||
newModel := &edge_ctrl_pb.DataState_Event_PublicKey{PublicKey: newPublicKey(certs[0].Raw, edge_ctrl_pb.DataState_PublicKey_X509CertDer, []edge_ctrl_pb.DataState_PublicKey_Usage{edge_ctrl_pb.DataState_PublicKey_JWTValidation, edge_ctrl_pb.DataState_PublicKey_ClientX509CertValidation})}
|
||||
newModel := &edge_ctrl_pb.DataState_Event_PublicKey{PublicKey: newPublicKey(certs[0].Raw, edge_ctrl_pb.DataState_PublicKey_X509CertDer, controllerCertUsages)}
|
||||
newEvent := &edge_ctrl_pb.DataState_Event{
|
||||
Action: edge_ctrl_pb.DataState_Create,
|
||||
Model: newModel,
|
||||
@@ -935,9 +929,18 @@ func (strategy *InstantStrategy) BuildPublicKeys(tx *bbolt.Tx, rdm *common.Route
|
||||
caPEMs := strategy.ae.GetConfig().Edge.CaPems()
|
||||
caCerts := nfPem.PemBytesToCertificates(caPEMs)
|
||||
|
||||
// Non-root CA certs in the bundle are intermediates; publish them on every root from
|
||||
// the same bundle. Verification treats intermediates as a pool, so extras are harmless.
|
||||
var caIntermediates [][]byte
|
||||
for _, caCert := range caCerts {
|
||||
if caCert.IsCA && !identity.IsRootCa(caCert) {
|
||||
caIntermediates = append(caIntermediates, caCert.Raw)
|
||||
}
|
||||
}
|
||||
|
||||
for _, caCert := range caCerts {
|
||||
if identity.IsRootCa(caCert) {
|
||||
publicKey := newPublicKey(caCert.Raw, edge_ctrl_pb.DataState_PublicKey_X509CertDer, []edge_ctrl_pb.DataState_PublicKey_Usage{edge_ctrl_pb.DataState_PublicKey_ClientX509CertValidation})
|
||||
publicKey := newPublicKey(caCert.Raw, edge_ctrl_pb.DataState_PublicKey_X509CertDer, firstPartyCaUsages, caIntermediates...)
|
||||
newModel := &edge_ctrl_pb.DataState_Event_PublicKey{PublicKey: publicKey}
|
||||
newEvent := &edge_ctrl_pb.DataState_Event{
|
||||
Action: edge_ctrl_pb.DataState_Create,
|
||||
@@ -965,7 +968,7 @@ func (strategy *InstantStrategy) BuildPublicKeys(tx *bbolt.Tx, rdm *common.Route
|
||||
continue
|
||||
}
|
||||
|
||||
publicKey := newPublicKey(certs[0].Raw, edge_ctrl_pb.DataState_PublicKey_X509CertDer, []edge_ctrl_pb.DataState_PublicKey_Usage{edge_ctrl_pb.DataState_PublicKey_ClientX509CertValidation})
|
||||
publicKey := newPublicKey(certs[0].Raw, edge_ctrl_pb.DataState_PublicKey_X509CertDer, thirdPartyCaUsages, certsToRaw(certs[1:])...)
|
||||
|
||||
newModel := &edge_ctrl_pb.DataState_Event_PublicKey{PublicKey: publicKey}
|
||||
newEvent := &edge_ctrl_pb.DataState_Event{
|
||||
@@ -1605,13 +1608,44 @@ func newService(storeModel *db.EdgeService) *edge_ctrl_pb.DataState_Service {
|
||||
}
|
||||
}
|
||||
|
||||
func newPublicKey(data []byte, format edge_ctrl_pb.DataState_PublicKey_Format, usages []edge_ctrl_pb.DataState_PublicKey_Usage) *edge_ctrl_pb.DataState_PublicKey {
|
||||
return &edge_ctrl_pb.DataState_PublicKey{
|
||||
Data: data,
|
||||
Kid: fmt.Sprintf("%x", sha1.Sum(data)),
|
||||
Usages: usages,
|
||||
Format: format,
|
||||
// Usage sets for published public keys. ClientX509CertValidation is deprecated but still
|
||||
// emitted on CA anchors so routers predating the first/third-party usages keep validating
|
||||
// client certs. Controller certs carry only JWTValidation: a controller identity is never a
|
||||
// CA, so its certs anchor no client cert chains — trust anchors come from the CA bundles.
|
||||
var (
|
||||
controllerCertUsages = []edge_ctrl_pb.DataState_PublicKey_Usage{
|
||||
edge_ctrl_pb.DataState_PublicKey_JWTValidation,
|
||||
}
|
||||
firstPartyCaUsages = []edge_ctrl_pb.DataState_PublicKey_Usage{
|
||||
edge_ctrl_pb.DataState_PublicKey_ClientX509CertValidation,
|
||||
edge_ctrl_pb.DataState_PublicKey_FirstPartyX509CertValidation,
|
||||
}
|
||||
thirdPartyCaUsages = []edge_ctrl_pb.DataState_PublicKey_Usage{
|
||||
edge_ctrl_pb.DataState_PublicKey_ClientX509CertValidation,
|
||||
edge_ctrl_pb.DataState_PublicKey_ThirdPartyX509CertValidation,
|
||||
}
|
||||
)
|
||||
|
||||
// newPublicKey builds a DataState_PublicKey for data, deriving the kid from its SHA-1
|
||||
// fingerprint. Any intermediates are CA certs, in the same format, chaining data's issued
|
||||
// certs to data.
|
||||
func newPublicKey(data []byte, format edge_ctrl_pb.DataState_PublicKey_Format, usages []edge_ctrl_pb.DataState_PublicKey_Usage, intermediates ...[]byte) *edge_ctrl_pb.DataState_PublicKey {
|
||||
return &edge_ctrl_pb.DataState_PublicKey{
|
||||
Data: data,
|
||||
Kid: fmt.Sprintf("%x", sha1.Sum(data)),
|
||||
Usages: usages,
|
||||
Format: format,
|
||||
Intermediates: intermediates,
|
||||
}
|
||||
}
|
||||
|
||||
// certsToRaw returns the raw DER bytes of each certificate.
|
||||
func certsToRaw(certs []*x509.Certificate) [][]byte {
|
||||
var result [][]byte
|
||||
for _, cert := range certs {
|
||||
result = append(result, cert.Raw)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func newPostureCheckById(tx *bbolt.Tx, ae *env.AppEnv, id string) (*edge_ctrl_pb.DataState_PostureCheck, error) {
|
||||
@@ -1847,21 +1881,19 @@ func (strategy *InstantStrategy) PostureCheckDelete(index uint64, postureCheck *
|
||||
|
||||
func (strategy *InstantStrategy) ControllerCreate(index uint64, controller *db.Controller) {
|
||||
certs := nfPem.PemStringToCertificates(controller.CertPem)
|
||||
cert := certs[0]
|
||||
strategy.handlePublicKey(index, edge_ctrl_pb.DataState_Create, newPublicKey(cert.Raw, edge_ctrl_pb.DataState_PublicKey_X509CertDer, []edge_ctrl_pb.DataState_PublicKey_Usage{edge_ctrl_pb.DataState_PublicKey_ClientX509CertValidation, edge_ctrl_pb.DataState_PublicKey_JWTValidation}))
|
||||
strategy.handlePublicKey(index, edge_ctrl_pb.DataState_Create, newPublicKey(certs[0].Raw, edge_ctrl_pb.DataState_PublicKey_X509CertDer, controllerCertUsages))
|
||||
}
|
||||
|
||||
func (strategy *InstantStrategy) ControllerUpdate(index uint64, controller *db.Controller) {
|
||||
certs := nfPem.PemStringToCertificates(controller.CertPem)
|
||||
cert := certs[0]
|
||||
strategy.handlePublicKey(index, edge_ctrl_pb.DataState_Create, newPublicKey(cert.Raw, edge_ctrl_pb.DataState_PublicKey_X509CertDer, []edge_ctrl_pb.DataState_PublicKey_Usage{edge_ctrl_pb.DataState_PublicKey_ClientX509CertValidation, edge_ctrl_pb.DataState_PublicKey_JWTValidation}))
|
||||
strategy.handlePublicKey(index, edge_ctrl_pb.DataState_Create, newPublicKey(certs[0].Raw, edge_ctrl_pb.DataState_PublicKey_X509CertDer, controllerCertUsages))
|
||||
}
|
||||
|
||||
func (strategy *InstantStrategy) CaCreate(index uint64, ca *db.Ca) {
|
||||
certs := nfPem.PemBytesToCertificates([]byte(ca.CertPem))
|
||||
|
||||
if len(certs) > 0 {
|
||||
strategy.handlePublicKey(index, edge_ctrl_pb.DataState_Create, newPublicKey(certs[0].Raw, edge_ctrl_pb.DataState_PublicKey_X509CertDer, []edge_ctrl_pb.DataState_PublicKey_Usage{edge_ctrl_pb.DataState_PublicKey_ClientX509CertValidation}))
|
||||
strategy.handlePublicKey(index, edge_ctrl_pb.DataState_Create, newPublicKey(certs[0].Raw, edge_ctrl_pb.DataState_PublicKey_X509CertDer, thirdPartyCaUsages, certsToRaw(certs[1:])...))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1869,7 +1901,7 @@ func (strategy *InstantStrategy) CaUpdate(index uint64, ca *db.Ca) {
|
||||
certs := nfPem.PemBytesToCertificates([]byte(ca.CertPem))
|
||||
|
||||
if len(certs) > 0 {
|
||||
strategy.handlePublicKey(index, edge_ctrl_pb.DataState_Update, newPublicKey(certs[0].Raw, edge_ctrl_pb.DataState_PublicKey_X509CertDer, []edge_ctrl_pb.DataState_PublicKey_Usage{edge_ctrl_pb.DataState_PublicKey_ClientX509CertValidation}))
|
||||
strategy.handlePublicKey(index, edge_ctrl_pb.DataState_Update, newPublicKey(certs[0].Raw, edge_ctrl_pb.DataState_PublicKey_X509CertDer, thirdPartyCaUsages, certsToRaw(certs[1:])...))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1877,7 +1909,7 @@ func (strategy *InstantStrategy) CaDelete(index uint64, ca *db.Ca) {
|
||||
certs := nfPem.PemBytesToCertificates([]byte(ca.CertPem))
|
||||
|
||||
if len(certs) > 0 {
|
||||
strategy.handlePublicKey(index, edge_ctrl_pb.DataState_Delete, newPublicKey(certs[0].Raw, edge_ctrl_pb.DataState_PublicKey_X509CertDer, []edge_ctrl_pb.DataState_PublicKey_Usage{edge_ctrl_pb.DataState_PublicKey_ClientX509CertValidation}))
|
||||
strategy.handlePublicKey(index, edge_ctrl_pb.DataState_Delete, newPublicKey(certs[0].Raw, edge_ctrl_pb.DataState_PublicKey_X509CertDer, thirdPartyCaUsages))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ import (
|
||||
"github.com/openziti/edge-api/rest_client_api_client"
|
||||
"github.com/openziti/edge-api/rest_client_api_server"
|
||||
"github.com/openziti/edge-api/rest_management_api_server"
|
||||
"github.com/openziti/foundation/v2/errorz"
|
||||
"github.com/openziti/xweb/v3"
|
||||
"github.com/openziti/ziti/v2/controller/api"
|
||||
"github.com/openziti/ziti/v2/controller/apierror"
|
||||
@@ -195,7 +194,8 @@ func (clientApi ClientApiHandler) newHandler(ae *env.AppEnv) http.Handler {
|
||||
rc, err := ae.CreateRequestContext(rw, r)
|
||||
|
||||
if err != nil {
|
||||
env.WriteHttpApiError(rw, errorz.NewUnhandled(err))
|
||||
env.WriteHttpError(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
api.AddRequestContextToHttpContext(r, rc)
|
||||
|
||||
@@ -187,7 +187,7 @@ func (self *FabricManagementApiHandler) WrapHttpHandler(handler http.Handler) ht
|
||||
rc, err := self.ae.CreateRequestContext(rw, r)
|
||||
|
||||
if err != nil {
|
||||
env.WriteHttpApiError(rw, errorz.NewUnhandled(err))
|
||||
env.WriteHttpError(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -208,7 +208,7 @@ func (self *FabricManagementApiHandler) WrapWsHandler(handler http.Handler) http
|
||||
rc, err := self.ae.CreateRequestContext(rw, r)
|
||||
|
||||
if err != nil {
|
||||
env.WriteHttpApiError(rw, errorz.NewUnhandled(err))
|
||||
env.WriteHttpError(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ import (
|
||||
|
||||
"github.com/openziti/edge-api/rest_management_api_client"
|
||||
"github.com/openziti/edge-api/rest_management_api_server"
|
||||
"github.com/openziti/foundation/v2/errorz"
|
||||
"github.com/openziti/xweb/v3"
|
||||
"github.com/openziti/ziti/v2/controller/api"
|
||||
"github.com/openziti/ziti/v2/controller/apierror"
|
||||
@@ -138,7 +137,7 @@ func (managementApi ManagementApiHandler) newHandler(ae *env.AppEnv) http.Handle
|
||||
rc, err := ae.CreateRequestContext(rw, r)
|
||||
|
||||
if err != nil {
|
||||
env.WriteHttpApiError(rw, errorz.NewUnhandled(err))
|
||||
env.WriteHttpError(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/michaelquigley/pfxlog"
|
||||
"github.com/openziti/identity"
|
||||
@@ -147,8 +148,15 @@ func (metricsApi *MetricsApiHandler) newHandler() http.Handler {
|
||||
|
||||
if nil != metricsApi.scrapeCert {
|
||||
certOk := false
|
||||
for _, r := range r.TLS.PeerCertificates {
|
||||
if bytes.Equal(metricsApi.scrapeCert.Signature, r.Signature) {
|
||||
// Match the pinned scrape cert against the presented LEAF only (PeerCertificates[0], the
|
||||
// cert whose private key the TLS handshake proved) - iterating the whole chain would let a
|
||||
// client present its own leaf plus the public scrape cert as an extra cert and pass. Compare
|
||||
// full DER, not just the signature, and reject a leaf outside its validity window.
|
||||
if r.TLS != nil && len(r.TLS.PeerCertificates) > 0 {
|
||||
leaf := r.TLS.PeerCertificates[0]
|
||||
now := time.Now()
|
||||
if bytes.Equal(metricsApi.scrapeCert.Raw, leaf.Raw) &&
|
||||
!now.Before(leaf.NotBefore) && !now.After(leaf.NotAfter) {
|
||||
certOk = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
Copyright NetFoundry Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package webapis
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var mSerial int64
|
||||
|
||||
func mMkCert(t *testing.T, cn string, notBefore, notAfter time.Time) *x509.Certificate {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
mSerial++
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(mSerial),
|
||||
Subject: pkix.Name{CommonName: cn},
|
||||
NotBefore: notBefore,
|
||||
NotAfter: notAfter,
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
require.NoError(t, err)
|
||||
c, err := x509.ParseCertificate(der)
|
||||
require.NoError(t, err)
|
||||
return c
|
||||
}
|
||||
|
||||
func mScrapeRequest(peerCerts []*x509.Certificate) *http.Request {
|
||||
r := httptest.NewRequest(http.MethodGet, "/metrics", nil)
|
||||
if peerCerts != nil {
|
||||
r.TLS = &tls.ConnectionState{PeerCertificates: peerCerts}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// Test_MetricsApi_ScrapeCertAuthorization covers the scrape-cert gate on the metrics endpoint. When a
|
||||
// scrape cert is pinned, authorization must match the pinned cert against the presented LEAF only
|
||||
// (PeerCertificates[0]), compare the full certificate, honor the validity window, and reject requests
|
||||
// with no client certificate. Only the rejecting (401) paths are exercised here; they return before the
|
||||
// handler consults the network.
|
||||
func Test_MetricsApi_ScrapeCertAuthorization(t *testing.T) {
|
||||
now := time.Now()
|
||||
scrapeCert := mMkCert(t, "scrape", now.Add(-time.Hour), now.Add(time.Hour))
|
||||
|
||||
handler := (&MetricsApiHandler{scrapeCert: scrapeCert}).newHandler()
|
||||
|
||||
assertUnauthorized := func(t *testing.T, peerCerts []*x509.Certificate) {
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, mScrapeRequest(peerCerts))
|
||||
require.Equal(t, http.StatusUnauthorized, rec.Code)
|
||||
}
|
||||
|
||||
t.Run("no client certificate", func(t *testing.T) {
|
||||
assertUnauthorized(t, nil)
|
||||
})
|
||||
|
||||
t.Run("presented leaf is not the scrape cert", func(t *testing.T) {
|
||||
other := mMkCert(t, "other", now.Add(-time.Hour), now.Add(time.Hour))
|
||||
assertUnauthorized(t, []*x509.Certificate{other})
|
||||
})
|
||||
|
||||
t.Run("scrape cert presented only as a filler cert, not the leaf", func(t *testing.T) {
|
||||
attacker := mMkCert(t, "attacker", now.Add(-time.Hour), now.Add(time.Hour))
|
||||
// The pinned scrape cert is present in the chain, but the leaf (index 0) is the attacker's.
|
||||
assertUnauthorized(t, []*x509.Certificate{attacker, scrapeCert})
|
||||
})
|
||||
|
||||
t.Run("expired scrape cert presented as the leaf", func(t *testing.T) {
|
||||
expired := mMkCert(t, "expired", now.Add(-2*time.Hour), now.Add(-time.Hour))
|
||||
expiredHandler := (&MetricsApiHandler{scrapeCert: expired}).newHandler()
|
||||
rec := httptest.NewRecorder()
|
||||
expiredHandler.ServeHTTP(rec, mScrapeRequest([]*x509.Certificate{expired}))
|
||||
require.Equal(t, http.StatusUnauthorized, rec.Code, "a leaf outside its validity window must be rejected")
|
||||
})
|
||||
}
|
||||
@@ -262,6 +262,141 @@ edge:
|
||||
# database. Only runs on the raft leader.
|
||||
# revocationEnforcerFrequency: 1m
|
||||
|
||||
# externalJwtSigners - optional
|
||||
# Settings that govern how the controller interacts with external JWT signers.
|
||||
externalJwtSigners:
|
||||
# jwksFetch - optional
|
||||
# Controls the controller's fetch of an external JWT signer's jwksEndpoint. That fetch is
|
||||
# made by the controller, from the controller's own network position, to a URL supplied by
|
||||
# whoever can write an external JWT signer. These settings constrain where it may go.
|
||||
#
|
||||
# A fetch is allowed only if it passes TWO GATES, and both gates are applied to the first
|
||||
# request AND to every redirect that is followed:
|
||||
#
|
||||
# the HOSTNAME gate - checked against the hostname in the URL
|
||||
# the ADDRESS gate - checked against the resolved address at connection time, so a
|
||||
# hostname that resolves to a blocked address is still blocked
|
||||
#
|
||||
# NEITHER GATE CAN AUTHORIZE WHAT THE OTHER REFUSES. Hostname matching only ever narrows what
|
||||
# may be fetched: allowedHostnames does not make a blocked address reachable, and
|
||||
# allowedIPs does not make a blocked hostname reachable.
|
||||
#
|
||||
# HOSTNAME gate, first-match-wins. DENY WINS OVER ALLOW:
|
||||
#
|
||||
# 1. deniedHostnames - blocked
|
||||
# 2. allowedHostnames - when set, ONLY these hostnames may be fetched; anything else is
|
||||
# blocked
|
||||
# 3. anything else - passes to the address gate
|
||||
#
|
||||
# ADDRESS gate, first-match-wins. DENY WINS OVER ALLOW:
|
||||
#
|
||||
# 1. built-in blocked addresses - always blocked, allowedIPs CANNOT override:
|
||||
# cloud instance metadata (169.254.169.254, 169.254.170.2, fd00:ec2::254)
|
||||
# link-local (169.254.0.0/16, fe80::/10)
|
||||
# link-local multicast (224.0.0.0/24, ff02::/16)
|
||||
# unspecified (0.0.0.0, ::)
|
||||
# 2. deniedIPs - blocked, allowedIPs CANNOT override
|
||||
# 3. allowedIPs - allowed; this is a carve-out of tier 4 ONLY
|
||||
# 4. blockPrivateAddresses, and the address is private or loopback - blocked
|
||||
# 5. anything else - allowed
|
||||
#
|
||||
# ENTRY FORMS. The two kinds of list do not accept each other's values.
|
||||
#
|
||||
# deniedIPs and allowedIPs take IP addresses only, in either of two forms:
|
||||
#
|
||||
# a flat IP address - 192.168.5.5 or fd00:1234::1
|
||||
# matches that one address only (the same as /32 or /128)
|
||||
# a CIDR block - 10.10.0.0/16 or fd00:1234::/64
|
||||
# matches every address in the block
|
||||
#
|
||||
# Both IPv4 and IPv6 are accepted in either form. A hostname in one of these lists is a
|
||||
# configuration error and the controller will not start: the address gate runs against the
|
||||
# address actually being connected to, so there is no hostname there to compare against.
|
||||
#
|
||||
# deniedHostnames and allowedHostnames take hostnames only, in either of two forms:
|
||||
#
|
||||
# an exact hostname - idp.example.com
|
||||
# matches that hostname only
|
||||
# a wildcard suffix - '*.example.com'
|
||||
# matches any subdomain of example.com, AT ANY DEPTH, but NEVER
|
||||
# example.com itself
|
||||
#
|
||||
# Wildcard matching, spelled out, because the last part surprises people:
|
||||
#
|
||||
# '*.sub.host.com' MATCHES idp.sub.host.com
|
||||
# MATCHES a.b.sub.host.com (any depth below the suffix)
|
||||
# DOES NOT sub.host.com (the suffix itself is NOT matched)
|
||||
# DOES NOT other.host.com
|
||||
# DOES NOT xsub.host.com (a whole label must be replaced)
|
||||
#
|
||||
# To cover both a domain and its subdomains, list them both:
|
||||
#
|
||||
# - sub.host.com
|
||||
# - '*.sub.host.com'
|
||||
#
|
||||
# The '*' is only supported as the entire leading label, written exactly as '*.'. Anything
|
||||
# else (idp.*.example.com, *idp.example.com, a bare *) is a configuration error. Quote any
|
||||
# entry that starts with '*', or YAML will reject it as an alias. Matching ignores case and
|
||||
# a trailing dot, and a unicode hostname is compared in its punycode form. An IP address in
|
||||
# one of these lists is a configuration error - use deniedIPs or allowedIPs.
|
||||
jwksFetch:
|
||||
# deniedHostnames - optional, default empty
|
||||
# Tier 1 of the HOSTNAME gate: hostnames that may not be fetched. Takes exact hostnames
|
||||
# and '*.suffix' wildcards, per ENTRY FORMS above.
|
||||
# This narrows only, it is not a boundary on its own: the same target can be reached
|
||||
# under a different name or as a literal IP, which is what the address gate is for.
|
||||
# deniedHostnames:
|
||||
# - old-idp.example.com
|
||||
# - '*.internal.example.com'
|
||||
|
||||
# allowedHostnames - optional, default empty
|
||||
# Tier 2 of the HOSTNAME gate: when this list is non-empty, these are the ONLY hostnames that
|
||||
# may be fetched, on the first request and on every redirect hop. Same entry forms as
|
||||
# deniedHostnames, and deniedHostnames still wins. It cannot widen the address gate: a listed
|
||||
# hostname whose address is blocked stays blocked. Note that while this list is set, an
|
||||
# endpoint written as a literal IP address can never satisfy it.
|
||||
# allowedHostnames:
|
||||
# - idp.example.com
|
||||
# - '*.idp.example.org'
|
||||
|
||||
# blockPrivateAddresses - optional, default false
|
||||
# Tier 4 of the ADDRESS gate: blocks private and loopback addresses
|
||||
# (127.0.0.0/8, ::1, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7). Defaults to
|
||||
# false so a deployment whose IdP is on an internal address keeps working. Tier 1 is
|
||||
# blocked either way, so the metadata service is never reachable. For the strictest
|
||||
# posture set this to true, list any internal IdP address in allowedIPs, and list
|
||||
# the IdP hostnames in allowedHostnames.
|
||||
# blockPrivateAddresses: false
|
||||
|
||||
# deniedIPs - optional, default empty
|
||||
# Tier 2 of the ADDRESS gate: addresses that are always blocked. Takes flat IP addresses
|
||||
# and CIDR blocks, per ENTRY FORMS above. Nothing in allowedIPs or allowedHostnames can
|
||||
# re-enable an address listed here, so this is the right place for internal ranges that
|
||||
# must never be fetched.
|
||||
# deniedIPs:
|
||||
# - 10.10.0.0/16 # a CIDR block
|
||||
# - 192.168.5.5 # a flat IP address
|
||||
# - fd00:1234::/64 # IPv6 is accepted in either form
|
||||
|
||||
# allowedIPs - optional, default empty
|
||||
# Tier 3 of the ADDRESS gate: a carve-out of tier 4 ONLY. Same entry forms as deniedIPs.
|
||||
# Listing an address here allows it despite blockPrivateAddresses; it does NOT override
|
||||
# tier 1 or deniedIPs, and it does NOT satisfy the hostname gate. Use it to reach a
|
||||
# specific internal IdP while blockPrivateAddresses is true.
|
||||
# allowedIPs:
|
||||
# - 10.20.30.0/24
|
||||
# - 10.20.40.7
|
||||
|
||||
# timeout - optional, default 5s
|
||||
# The total time allowed for a single JWKS fetch, including any redirects. Must be
|
||||
# greater than zero.
|
||||
# timeout: 5s
|
||||
|
||||
# maxRedirects - optional, default 5
|
||||
# How many redirects a JWKS fetch will follow. Every hop passes through both gates. Set
|
||||
# to 0 to refuse to follow redirects at all.
|
||||
# maxRedirects: 5
|
||||
|
||||
# Set to true to disable posture check functionality
|
||||
disablePostureChecks: false
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ replace github.com/michaelquigley/pfxlog => github.com/michaelquigley/pfxlog v0.
|
||||
|
||||
require (
|
||||
github.com/AppsFlyer/go-sundheit v0.6.0
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.0
|
||||
github.com/Azure/azure-sdk-for-go/sdk/messaging/azservicebus v1.10.0
|
||||
github.com/Jeffail/gabs v1.4.0
|
||||
github.com/Jeffail/gabs/v2 v2.7.0
|
||||
@@ -24,18 +24,18 @@ require (
|
||||
github.com/ef-ds/deque v1.0.4
|
||||
github.com/fatih/color v1.19.0
|
||||
github.com/fullsailor/pkcs7 v0.0.0-20190404230743-d7302db945fa
|
||||
github.com/gaissmai/extnetip v1.3.1
|
||||
github.com/gaissmai/extnetip v1.3.2
|
||||
github.com/go-acme/lego/v4 v4.35.2
|
||||
github.com/go-jose/go-jose/v4 v4.1.4
|
||||
github.com/go-openapi/errors v0.22.8
|
||||
github.com/go-openapi/jsonpointer v0.24.0
|
||||
github.com/go-openapi/loads v0.24.0
|
||||
github.com/go-openapi/runtime v0.32.4
|
||||
github.com/go-openapi/spec v0.22.6
|
||||
github.com/go-openapi/strfmt v0.26.4
|
||||
github.com/go-openapi/swag v0.27.0
|
||||
github.com/go-openapi/swag/jsonutils v0.27.0
|
||||
github.com/go-openapi/validate v0.26.0
|
||||
github.com/go-openapi/jsonpointer v1.0.0
|
||||
github.com/go-openapi/loads v0.25.1
|
||||
github.com/go-openapi/runtime v0.33.0
|
||||
github.com/go-openapi/spec v0.22.9
|
||||
github.com/go-openapi/strfmt v0.27.0
|
||||
github.com/go-openapi/swag v0.29.0
|
||||
github.com/go-openapi/swag/jsonutils v0.29.0
|
||||
github.com/go-openapi/validate v0.26.3
|
||||
github.com/go-resty/resty/v2 v2.17.2
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
@@ -50,7 +50,7 @@ require (
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7
|
||||
github.com/hashicorp/raft v1.7.3
|
||||
github.com/hashicorp/raft-boltdb/v2 v2.3.1
|
||||
github.com/jedib0t/go-pretty/v6 v6.8.1
|
||||
github.com/jedib0t/go-pretty/v6 v6.8.3
|
||||
github.com/jellydator/ttlcache/v3 v3.4.1
|
||||
github.com/jessevdk/go-flags v1.6.1
|
||||
github.com/jinzhu/copier v0.4.0
|
||||
@@ -59,13 +59,13 @@ require (
|
||||
github.com/lucsky/cuid v1.2.1
|
||||
github.com/mdlayher/netlink v1.11.2
|
||||
github.com/michaelquigley/pfxlog v1.0.0
|
||||
github.com/miekg/dns v1.1.72
|
||||
github.com/miekg/dns v1.1.73
|
||||
github.com/mitchellh/mapstructure v1.5.0
|
||||
github.com/natefinch/lumberjack v2.0.0+incompatible
|
||||
github.com/openziti/agent v1.0.33
|
||||
github.com/openziti/channel/v4 v4.3.11
|
||||
github.com/openziti/channel/v4 v4.3.12
|
||||
github.com/openziti/cobra-to-md v1.0.1
|
||||
github.com/openziti/edge-api v0.31.0
|
||||
github.com/openziti/edge-api v0.36.0
|
||||
github.com/openziti/foundation/v2 v2.0.91
|
||||
github.com/openziti/identity v1.0.129
|
||||
github.com/openziti/jwks v1.0.6
|
||||
@@ -78,31 +78,31 @@ require (
|
||||
github.com/openziti/xweb/v3 v3.0.4
|
||||
github.com/orcaman/concurrent-map/v2 v2.0.1
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/rabbitmq/amqp091-go v1.12.0
|
||||
github.com/rabbitmq/amqp091-go v1.14.0
|
||||
github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9
|
||||
github.com/rodaine/table v1.3.1
|
||||
github.com/russross/blackfriday v1.6.0
|
||||
github.com/shirou/gopsutil/v3 v3.24.5
|
||||
github.com/sirupsen/logrus v1.9.4
|
||||
github.com/sirupsen/logrus v1.10.1
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/pflag v1.0.10
|
||||
github.com/spf13/viper v1.21.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/stretchr/testify v1.12.1
|
||||
github.com/teris-io/shortid v0.0.0-20220617161101-71ec9f2aa569
|
||||
github.com/xeipuuv/gojsonschema v1.2.0
|
||||
github.com/zitadel/oidc/v3 v3.47.5
|
||||
github.com/zitadel/oidc/v3 v3.49.2
|
||||
go.etcd.io/bbolt v1.5.0
|
||||
go.uber.org/atomic v1.11.0
|
||||
go4.org v0.0.0-20260112195520-a5071408f32f
|
||||
golang.org/x/crypto v0.53.0
|
||||
golang.org/x/net v0.56.0
|
||||
golang.org/x/crypto v0.55.0
|
||||
golang.org/x/net v0.58.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
golang.org/x/sync v0.21.0
|
||||
golang.org/x/sys v0.46.0
|
||||
golang.org/x/term v0.44.0
|
||||
golang.org/x/text v0.38.0
|
||||
google.golang.org/protobuf v1.36.11
|
||||
golang.org/x/sync v0.22.0
|
||||
golang.org/x/sys v0.47.0
|
||||
golang.org/x/term v0.45.0
|
||||
golang.org/x/text v0.41.0
|
||||
google.golang.org/protobuf v1.36.12
|
||||
gopkg.in/AlecAivazis/survey.v1 v1.8.8
|
||||
gopkg.in/resty.v1 v1.12.0
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
@@ -112,11 +112,11 @@ require (
|
||||
|
||||
require (
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect
|
||||
github.com/Azure/go-amqp v1.6.0 // indirect
|
||||
github.com/Azure/go-amqp v1.7.0 // indirect
|
||||
github.com/MichaelMure/go-term-text v0.3.1 // indirect
|
||||
github.com/alecthomas/chroma v0.10.0 // indirect
|
||||
github.com/andybalholm/brotli v1.2.1 // indirect
|
||||
github.com/antchfx/xpath v1.3.6 // indirect
|
||||
github.com/antchfx/xpath v1.3.8 // indirect
|
||||
github.com/armon/go-metrics v0.4.1 // indirect
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
|
||||
github.com/boltdb/bolt v1.3.1 // indirect
|
||||
@@ -125,33 +125,32 @@ require (
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
|
||||
github.com/creack/pty v1.1.11 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dlclark/regexp2 v1.12.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/emirpasic/gods v1.18.1 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/felixge/httpsnoop v1.1.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
||||
github.com/go-chi/chi/v5 v5.2.5 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-chi/chi/v5 v5.3.1 // indirect
|
||||
github.com/go-logr/logr v1.4.4 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/go-openapi/analysis v0.25.2 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.6 // indirect
|
||||
github.com/go-openapi/analysis v0.26.0 // indirect
|
||||
github.com/go-openapi/jsonreference v1.0.0 // indirect
|
||||
github.com/go-openapi/runtime/server-middleware v0.30.0 // indirect
|
||||
github.com/go-openapi/swag/cmdutils v0.27.0 // indirect
|
||||
github.com/go-openapi/swag/conv v0.27.0 // indirect
|
||||
github.com/go-openapi/swag/fileutils v0.27.0 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.26.1 // indirect
|
||||
github.com/go-openapi/swag/loading v0.27.0 // indirect
|
||||
github.com/go-openapi/swag/mangling v0.27.0 // indirect
|
||||
github.com/go-openapi/swag/netutils v0.27.0 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.27.0 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.27.0 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.27.0 // indirect
|
||||
github.com/go-openapi/swag/cmdutils v0.29.0 // indirect
|
||||
github.com/go-openapi/swag/conv v0.29.0 // indirect
|
||||
github.com/go-openapi/swag/fileutils v0.29.0 // indirect
|
||||
github.com/go-openapi/swag/loading v0.29.0 // indirect
|
||||
github.com/go-openapi/swag/mangling v0.29.0 // indirect
|
||||
github.com/go-openapi/swag/netutils v0.29.0 // indirect
|
||||
github.com/go-openapi/swag/pools v0.29.0 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.29.0 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.29.0 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.29.0 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
github.com/gomarkdown/markdown v0.0.0-20260417124207-7d523f7318df // indirect
|
||||
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
|
||||
github.com/hashicorp/go-metrics v0.5.4 // indirect
|
||||
github.com/hashicorp/go-metrics v0.6.1 // indirect
|
||||
github.com/hashicorp/go-msgpack/v2 v2.1.5 // indirect
|
||||
github.com/hashicorp/golang-lru v1.0.2 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
@@ -159,25 +158,24 @@ require (
|
||||
github.com/kr/pty v1.1.8 // indirect
|
||||
github.com/kyokomi/emoji/v2 v2.2.13 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.23 // indirect
|
||||
github.com/mattn/go-colorable v0.1.15 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.28 // indirect
|
||||
github.com/mattn/go-tty v0.0.8 // indirect
|
||||
github.com/mdlayher/socket v0.6.0 // indirect
|
||||
github.com/mdlayher/socket v0.6.1 // indirect
|
||||
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
|
||||
github.com/miekg/pkcs11 v1.1.2 // indirect
|
||||
github.com/mitchellh/go-ps v1.0.0 // indirect
|
||||
github.com/muhlemmer/gu v0.3.1 // indirect
|
||||
github.com/muhlemmer/httpforwarded v0.1.0 // indirect
|
||||
github.com/oklog/ulid/v2 v2.1.1 // indirect
|
||||
github.com/oklog/ulid/v2 v2.1.2 // indirect
|
||||
github.com/openziti/go-term-markdown v1.0.1 // indirect
|
||||
github.com/parallaxsecond/parsec-client-go v0.0.0-20221025095442-f0a77d263cf9 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.3.1 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.4.3 // indirect
|
||||
github.com/pion/dtls/v3 v3.1.2 // indirect
|
||||
github.com/pion/logging v0.2.4 // indirect
|
||||
github.com/pion/transport/v4 v4.0.1 // indirect
|
||||
github.com/pkg/term v1.2.0-beta.2 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/rs/cors v1.11.1 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
@@ -194,17 +192,14 @@ require (
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
|
||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
github.com/zitadel/logging v0.7.0 // indirect
|
||||
github.com/zitadel/schema v1.3.2 // indirect
|
||||
go.mozilla.org/pkcs7 v0.9.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/otel v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect
|
||||
golang.org/x/mod v0.37.0 // indirect
|
||||
golang.org/x/tools v0.46.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
golang.org/x/exp v0.0.0-20260813180055-c1d0aacb2297 // indirect
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
|
||||
nhooyr.io/websocket v1.8.17 // indirect
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user