merge release-v2.0.x: move changelog entry to 2.0.4, keep both testdata readme sections

This commit is contained in:
dovholuknf
2026-08-26 16:35:41 -04:00
273 changed files with 16502 additions and 1254 deletions
+2 -2
View File
@@ -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')
+3
View File
@@ -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]'
+23 -52
View File
@@ -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 }}
+1 -1
View File
@@ -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
View File
@@ -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
+63
View File
@@ -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
}
+87
View File
@@ -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")
}
+71
View File
@@ -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
}
+254
View File
@@ -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")
}
+66
View File
@@ -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")
}
}
+63
View File
@@ -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
}
+107
View File
@@ -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)
})
}
}
+3 -1
View File
@@ -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
+74
View File
@@ -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")
}
+20
View File
@@ -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
View File
@@ -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 (
+1
View File
@@ -75,6 +75,7 @@ message DeleteEntityCommand {
message SyncSnapshotCommand {
string snapshotId = 1;
bytes snapshot = 2;
string clusterId = 3;
}
message InitClusterIdCommand {
+28 -10
View File
@@ -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" +
+4 -1
View File
@@ -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)
}
+15 -1
View File
@@ -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 != "" {
+23
View File
@@ -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
+10
View File
@@ -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,
+4
View File
@@ -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
+26 -2
View File
@@ -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
}
+8 -1
View File
@@ -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,
})
}
+11
View File
@@ -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
+336
View File
@@ -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
+255
View File
@@ -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 {
+16 -8
View File
@@ -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
+72 -6
View File
@@ -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)
}
+66
View File
@@ -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
View File
@@ -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
}
+112 -55
View File
@@ -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{
+15 -1
View File
@@ -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 {
+29 -10
View File
@@ -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)
+5
View File
@@ -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))
+12 -13
View File
@@ -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
}
+54
View File
@@ -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
}
+92
View File
@@ -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")
})
}
+91 -48
View File
@@ -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")
+214
View File
@@ -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()).
+15 -8
View File
@@ -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, "")
+41 -2
View File
@@ -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
}
+156
View File
@@ -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
+4 -10
View File
@@ -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())
}
}
+2 -2
View File
@@ -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())
}
}
+13 -8
View File
@@ -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 {
+1 -2
View File
@@ -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"),
}
+214
View File
@@ -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())
}
+22 -12
View File
@@ -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)
}
+34 -9
View File
@@ -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()
+392
View File
@@ -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)
}
+848
View File
@@ -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
}
+90 -12
View File
@@ -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 {
+83
View File
@@ -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")
}
+3
View File
@@ -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
+99 -7
View File
@@ -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)")
}
+35
View File
@@ -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
View File
@@ -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())
+10
View File
@@ -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
}
-8
View File
@@ -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()
+383
View File
@@ -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)
}
}
}
+16 -9
View File
@@ -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")
}
+11
View File
@@ -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)
+7
View File
@@ -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,
-1
View File
@@ -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()
}
+9 -3
View File
@@ -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
View File
@@ -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")
}
+82
View File
@@ -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 {
}
+124
View File
@@ -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
View File
@@ -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) {
+50
View File
@@ -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)
})
}
+11 -1
View File
@@ -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 {
+53
View File
@@ -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")
}
+63 -31
View File
@@ -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))
}
}
+2 -2
View File
@@ -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)
+2 -2
View File
@@ -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
}
+1 -2
View File
@@ -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
}
+10 -2
View File
@@ -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
}
}
+103
View File
@@ -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")
})
}
+135
View File
@@ -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
+51 -56
View File
@@ -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