Compare commits

...

77 Commits

Author SHA1 Message Date
João "Pisco" Fernandes 8679787525 Release 2026.7.2 2026-07-15 12:01:07 +01:00
Rhys Rustad-Elliott 45c0f22e97 VULN-118896: MacOS service: use --token-file instead of --token
* Refactor directory-related functions in macos_service.go to pull out common logic
* Use --token-file instead of --token when installing cloudflared with a token on MacOS such that the secret token will not show up in the output of "ps aux" (or similar)
* Improve the "service install" help text on MacOS to distinguish between the "service install " and "service install \<token\>" cases
2026-07-15 10:52:25 +01:00
João "Pisco" Fernandes e8956c10ac Update gcr.io/distroless/base-debian13:nonroot-arm Docker digest to 7a4876f88 2026-07-14 19:31:56 +01:00
Renovate 541a608dde Update gcr.io/distroless/base-debian13:nonroot-amd64 Docker digest to ce2a20e 2026-07-14 18:36:36 +01:00
Miguel da Costa Martins Marcelino 6fd720a4a4 chore: Change internal image name
Changing image name. The image is currently under `/tun/cloudflared/cloudflared-daemon/master`. We want to instead have it under `/tun/cloudflared/cloudflared/master`. Adding the `daemon` suffix does not add much value.
2026-07-14 16:43:53 +00:00
Renovate 60a8c3da17 Update gcr.io/distroless/base-debian13:nonroot Docker digest to b78832f
This MR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| gcr.io/distroless/base-debian13 | final | digest | `ab7554b` → `b78832f` |

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At 12:00 AM through 04:59 AM and 10:00 PM through 11:59 PM, Monday through Friday (`* 0-4,22-23 * * 1-5`)
  - Only on Sunday and Saturday (`* * * * 0,6`)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever MR is behind base branch, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this MR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this MR, check this box

---

This MR has been generated by [Mend Renovate](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4yNTcuNSIsInVwZGF0ZWRJblZlciI6IjQzLjI1Ny41IiwidGFyZ2V0QnJhbmNoIjoibWFzdGVyIiwibGFiZWxzIjpbXX0=-->
2026-07-14 17:40:32 +01:00
João "Pisco" Fernandes 2601f87b57 chore: Remove bookworm internal register publish
Bookworm has been deprecated and therefore our internal
repository no longer accepts the publish of new packages.
We are then removing the publish to the internal repositories of bookworm.
2026-07-13 11:28:14 +01:00
Rhys Rustad-Elliott f70adda11c VULN-118896: Linux service: Use --token-file instead of --token
When installed with a token, e.g.,

$ cloudflared service install \<token\>

cloudflared will set itself up to be run by the init system using the --token argument. This results in tokens being visible in the output of ps aux by unprivileged users. Change this such that this installation method instead puts the token in a file with mode 600 in /etc/cloudflared and uses the --token-file flag instead.
2026-07-13 09:57:07 +00:00
João "Pisco" Fernandes ecb88678f1 Release 2026.7.1 2026-07-09 13:06:21 +01:00
Miguel da Costa Martins Marcelino 43bfec0bcd Revert "TUN-10621: Propagate max wait timeout"
This reverts merge request !1859
2026-07-09 13:00:02 +01:00
João "Pisco" Fernandes 86fccede6d Release 2026.7.0 2026-07-08 13:08:05 +01:00
João "Pisco" Fernandes a53d9e5d44 Revert "TUN-10557: Bump quic-go v0.59.1"
This reverts commit 02eb75b56d.
2026-07-08 11:48:16 +01:00
João "Pisco" Fernandes 9171757c66 chore: Bump go-chi to version 5.3.1 2026-07-08 11:36:00 +01:00
Miguel da Costa Martins Marcelino c06b2d43e9 TUN-10621: Test websocket path
Adding a test that tries to exercise the race condition for the websocket path. The sequence is as follows:

1. The test starts by writing the payload to the mock server
2. Then, it closes the write side of the connection.
3. It waits for 3 seconds to make the race window explicit: the bug in cloudflared would have closed the connection and we would not be able to read.
4. Read the message.

With the fix applied, we should be able to read the full message.
2026-07-01 18:27:12 +01:00
Renovate a9d023b7a2 Migrate config renovate.json 2026-06-30 17:19:15 +01:00
João "Pisco" Fernandes f76f75b449 ci: add Semgrep OSS scanning workflow
## Summary
Port from https://github.com/cloudflare/cloudflared/pull/1639 to GitLab
2026-06-30 09:36:55 +01:00
Miguel da Costa Martins Marcelino 5c66bd68ab TUN-10621: Propagate max wait timeout
This PR addresses an issue where cloudflared prematurely closes the origin connection before the upstream-to-downstream goroutine finishes reading, causing intermittent connection drops when a client immediately closes the write-side of a connection.

When a client finishes writing data, it immediately closes its side of the connection. Under the current implementation in cloudflared's downstream-to-upstream goroutine does not wait for the second stream to complete. It unblocks the moment the first stream writes to the channel. Once this happens, the pipe returns control to `proxyTCPStream`, which prematurely closes the origin connection. Consequently, when the upstream-to-downstream goroutine attempts to read the remaining data from the origin connection, the connection is already gone, leading to unexpected failures.

We started propagating the `TimeoutAfterFirstClose` configuration/parameter. This allows the proxy to wait for a designated period, giving the second stream sufficient time to finish processing and read all remaining data before `proxyTCPStream` tears down the origin connection.
2026-06-29 10:57:12 +00:00
Christopher Meng dba2d33a6b Consolidate init-system detection into shared inits package 2026-06-25 17:24:46 +00:00
Christopher Meng 6b0571b598 Fix lint issues 2026-06-25 17:24:46 +00:00
Christopher Meng d5e0c2bb89 Fix broken installed-service detection in systemd uninstall
systemctl list-units exits 0 whether or not any pattern matches, so the
filter was a no-op and every unit was always treated as installed.
Detect installed units by checking for the unit file on disk instead.
2026-06-25 17:24:46 +00:00
Christopher Meng 77e8965e28 Add OpenRC support
Generate a clean init script at /etc/init.d/cloudflared backed by
OpenRC's supervise-daemon supervisor, plus a companion
/etc/conf.d/cloudflared for operator overrides.
2026-06-25 17:24:46 +00:00
Renovate 8066821462 Pin dependencies
This MR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| gcr.io/distroless/base-debian13 | final | pinDigest |  → `ab7554b` |

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At 12:00 AM through 04:59 AM and 10:00 PM through 11:59 PM, Monday through Friday (`* 0-4,22-23 * * 1-5`)
  - Only on Sunday and Saturday (`* * * * 0,6`)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever MR is behind base branch, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this MR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this MR, check this box

---

This MR has been generated by [Mend Renovate](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4yMjQuMCIsInVwZGF0ZWRJblZlciI6IjQzLjIyNC4wIiwidGFyZ2V0QnJhbmNoIjoibWFzdGVyIiwibGFiZWxzIjpbXX0=-->
2026-06-25 17:12:51 +00:00
Miguel da Costa Martins Marcelino 1e4ebe5471 chore: Fix renovate
Fixing renovate in cloudflared.
2026-06-25 16:36:46 +00:00
Miguel da Costa Martins Marcelino 02eb75b56d TUN-10557: Bump quic-go v0.59.1
This adds back the quic-go bump.
2026-06-18 18:20:39 +00:00
MiguelMarcelino 81a53555aa Release 2026.6.1 2026-06-18 14:39:02 +01:00
Miguel da Costa Martins Marcelino 2bcaf09734 Revert "TUN-10557: Bump quic-go v0.59.1"
This reverts merge request !1850
2026-06-18 13:30:00 +00:00
Miguel da Costa Martins Marcelino 3315fa6e0f TUN-10630: Fix precheck protocol override
As it stands, cloudflared prechecks are not taking the `protocol` flag into consideration and is instead falling back to the default protocol, which is QUIC. Prechecks should report the protocol cloudflared will use, not the default protocol.
2026-06-18 10:56:53 +00:00
Miguel da Costa Martins Marcelino ad11e67340 chore: Fix warnings
Fixing warnings in cloudflared before making any further changes.
2026-06-16 16:53:56 +00:00
João "Pisco" Fernandes 3a60f8ac0f TUN-10612: Add renovate to cloudflared to update distroless images explicitely 2026-06-15 11:39:47 +01:00
lneto 68620efbce TUN-10557: Bump quic-go v0.59.1
Bumps quic-go to v0.59.1 (chungthuang fork rebased from upstream v0.45 onto
v0.59.1). Upstream removed the `logging` package and replaced its
callback-based ConnectionTracer with the structured `qlog`/`qlogwriter` event
API, which required migrating cloudflared's QUIC metrics collection.

Migrations:

- quic/tracing.go: connTracer no longer fills a logging.ConnectionTracer
  callback struct. It implements qlogwriter.Trace + qlogwriter.Recorder and
  dispatches qlog events (PacketSent, PacketReceived, MetricsUpdated, ...) to
  the collector through RecordEvent. NewClientTracer now returns a function
  compatible with quic.Config.Tracer.

- quic/metrics.go: collector methods take qlog types (qlog.Frame,
  qlog.PacketType, qlog.MetricsUpdated, ...) and plain int64 in place of the
  removed logging.ByteCount/Frame/RTTStats/TransportParameters.

- quic/conversion.go: PacketType, PacketDropReason and PacketLossReason are
  strings upstream rather than numeric iotas, so the converters become
  pass-through allowlists. CongestionState is also a string;
  congestionStateToFloat maps it back to the numeric gauge values cloudflared
  exports.

- quic.Connection/quic.Stream became *quic.Conn/*quic.Stream; updated
  ConnWithCloser, SafeStreamCloser and the connection package accordingly.
  Tests and generated mocks (mocks/mock_quic_connection.go) were adapted to
  the new pointer-based API.

Closes TUN-10557
2026-06-12 07:24:26 +01:00
Miguel da Costa Martins Marcelino 4d95ab73f5 TUN-9251: Publish internal image
Publishing internal image in cloudflared. This allows us to remove the dependency from cloudflare/plat/dockerfiles. In addition, our acceptance tests should now be able to use the latest image instead of relying on a fixed version for testing, which will allow us to detect potential failures earlier.
2026-06-11 12:31:50 +00:00
João "Pisco" Fernandes 57f7d693bb Release 2026.6.0 2026-06-08 19:16:09 +01:00
João "Pisco" Fernandes ccffef1179 TUN-10558: Bump go to v1.24.4, x/crypto to v0.52.0 and google.golang.org/grpc to v1.81.1
Closes TUN-10558
2026-06-08 19:15:35 +01:00
Luis Neto 52519f67e8 TUN-10563: introduce QUICConnection interface
The bump of the QUIC library introduces a cyclic dependency between the connection and quic modules hence it is necessary to break this coupling.

Right now, the connection module depends on the quic module for the datagram v2/v3 and to which a QUIC connection (currently an interface) is passed.

As it is there is no issue however, under the hood, interface is a wrapper around an UDP connection and a QUIC connection meaning this type must be exposed to the quic module since the QUIC Connection will no longer be a interface but a struct.

Given the above, these changes introduce an interface, QUICConnection, with the surface used today in cloudflared and a struct, ConnWithCloser, that implements said interface within the quic module.

Closes TUN-10563
2026-06-01 10:08:38 +01:00
João "Pisco" Fernandes 0e84636de9 Release 2026.5.2 2026-05-27 11:15:36 +01:00
Miguel da Costa Martins Marcelino 4177dd6936 TUN-10391: Avoid using fmt.Println
Avoid using fmt.Println and instead switch to logging pre-checks with the provided logger.
2026-05-26 22:04:54 +00:00
João "Pisco" Fernandes f6f60e1059 Release 2026.5.1 2026-05-25 10:32:09 +01:00
Miguel da Costa Martins Marcelino 4494eee13d TUN-10391: Add precheck integration tests
Adding integration tests for cloudflared pre-checks. This tests pre-check functionality to ensure it is working as expected.
2026-05-22 21:58:48 +00:00
Miguel da Costa Martins Marcelino 905d983d14 TUN-10391: Avoid blocking cloudflared due to logging
Pipes have a finite OS buffer (\~64KB Linux, \~4KB macOS, \~4KB Windows). Since nobody was reading stdout/stderr during the process lifetime, cloudflared would block once the buffer filled up. The post-terminate()/read() could only get whatever fit in the buffer, causing truncated logs.

There was also a race between terminate() and read(): the process might not have flushed its final output yet.

We're also deleting `test_default_only`. Since we changed `edge-ip-version` to auto, this test became redundant.
2026-05-22 18:15:54 +00:00
João "Pisco" Fernandes 168f09cb4c fix: Bump go to 1.26.3 and go.opentelemetry.io/otel and go-jose/v4 to fix CVE's 2026-05-22 17:29:40 +01:00
Miguel da Costa Martins Marcelino 0c9014870a TUN-10511: Revise --edge support for pre-checks
Fixing some bugs with DNS targets. Most importantly, these changes also fix some wrong assumptionsmade when trying to add support for the `--edge` flag:

1. Removes `StaticEdgeDNSResolver` in favor `resolveStaticEdge`. Since --edge does not imply resolving DNS, this fixes that assumption.
2. Adds EdgeAddrs, which allows us to skip DNS probes when set. This fixes the targets in the DNS rows.
3. Added a new `ResolvedTarget` struct, which joins addresses with the respective DNS results. This avoids the brittle logic we had before, where we assumed there were always two groups (one for each region) when running probes. So this not only makes the code more extensible in case we want to add more regions in the future but also adds support for multiple targets supplied via `--edge`.
4. Changes the existing nomenclature, going from calling things `region` to `target`. The term `region` works when resolving production regions (region1 and region2), but becomes misleading when we add the logic for `--edge`.

The end result of these changes is that we now see the correct addresses when you supply targets via `--edge`, while also making the code a bit clearer.
2026-05-14 09:06:02 +00:00
Miguel da Costa Martins Marcelino 31de04f858 TUN-10525: Add prechecks kill switch
Instead of having the  --precheck flag in cloudflared, we allow controlling prechecks via a DNS flag, so we can short-circuit this behavior in case anything goes wrong. Although we don't expect pre-checks to add that much traffic, we should still guarantee that we can stop pre-checks in case something goes wrong.
2026-05-13 18:05:11 +00:00
João "Pisco" Fernandes fbfd76089f fix: Update golang.org/x/net to v0.54.0
Check / check (1.22.x, ubuntu-latest) (push) Failing after 5m15s
Semgrep config / semgrep/ci (push) Failing after 1m19s
Check / check (1.22.x, macos-latest) (push) Has been cancelled
Check / check (1.22.x, windows-latest) (push) Has been cancelled
2026-05-13 13:15:15 +01:00
lneto 21ca2e225e Release 2026.5.0 2026-05-13 11:09:55 +01:00
lneto f674b82e2a TUN-10413: Centralize TLS curve configuration in crypto/ and adopt X25519MLKEM768 for QUIC/H2
Introduce a new crypto/ package as the single source of truth for TLS
curve preferences used on every edge-facing connection, and adopt
X25519MLKEM768 as the primary post-quantum key exchange for both QUIC
and HTTP/2:

  PQ Prefer (default):     X25519MLKEM768, P256Kyber768Draft00, CurveP256
  PQ Strict (--post-quantum): X25519MLKEM768, P256Kyber768Draft00

The curve list is identical under FIPS and non-FIPS builds, so
crypto.GetCurvePreferences takes only a features.PostQuantumMode and
returns a fresh slice on every call.

HTTP/2 now applies these curve preferences the same way QUIC does. The
previous PostQuantumStrict rejection in serveHTTP2 and the forced
QUIC-only selection in NewProtocolSelector are removed since both
transports support the same post-quantum curves; the needPQ parameter
is dropped from NewProtocolSelector accordingly.

Also fix a shared tls.Config race: both the QUIC and HTTP/2 paths now
Clone() the per-protocol entry from TunnelConfig.EdgeTLSConfigs before
mutating CurvePreferences instead of writing through the shared map
entry.

Legacy Kyber draft curve X25519Kyber768Draft00
and the unused removeDuplicates helper are removed along with the old
supervisor/pqtunnels.go / _test.go files.

AGENTS.md is updated with guidance on the new crypto/ package, the
cfdcrypto import alias, the tls.Config cloning rule, and the lint
workflow implications of .golangci.yaml's whole-files: true setting.
2026-05-12 07:47:38 +01:00
MiguelMarcelino ae3799a098 Bump golang.org/x/net from v0.40.0 to v0.53.0
Check / check (1.22.x, macos-latest) (push) Has been cancelled
Check / check (1.22.x, ubuntu-latest) (push) Has been cancelled
Check / check (1.22.x, windows-latest) (push) Has been cancelled
Semgrep config / semgrep/ci (push) Has been cancelled
2026-05-08 11:13:48 +00:00
João "Pisco" Fernandes 4d8df2b2c0 TUN-10513: Disable /debug/pprof/cmdline endpoint
Check / check (1.22.x, macos-latest) (push) Has been cancelled
Check / check (1.22.x, ubuntu-latest) (push) Has been cancelled
Check / check (1.22.x, windows-latest) (push) Has been cancelled
Semgrep config / semgrep/ci (push) Has been cancelled
2026-05-07 18:41:38 +01:00
Miguel da Costa Martins Marcelino a67c583bf1 TUN-10390: Call prechecks
Final run method, which runs cloudlflared pre-checks for both the normal startup procedure, as well as cloudflared diag.

For cloudflared diag, this produces a new json output to which is added to the final zip file.

Also added in a new flag to prevent this from running all the time, at least for now until we are 100% sure this works as intended. We will later remove this flag, only leaving in `--no-prechecks`, so this runs by default for everyone using cloudflared.

Tested pre-checks locally with origintunneld. The results show all pre-checks succeeding. In this case, it ran with only 1 region, since locally we run it with `--edge origintunneld1:7844`.

![Screenshot 2026-05-07 at 13.19.19.png](/uploads/8d0031d7c819d8a761707fe9d845667f/Screenshot_2026-05-07_at_13.19.19.png){width=900 height=217}
2026-05-07 17:27:58 +00:00
Miguel da Costa Martins Marcelino 22a955f7bb TUN-10511: Add Static DNS Resolvers
Check / check (1.22.x, macos-latest) (push) Has been cancelled
Check / check (1.22.x, ubuntu-latest) (push) Has been cancelled
Check / check (1.22.x, windows-latest) (push) Has been cancelled
Semgrep config / semgrep/ci (push) Has been cancelled
This PR allows us to use edge overrides with pre-checks. I forgot to account for the --edge flag when developing pre-check probes in TUN-10388. This should allow us to wire this flag in.

DNSResolver will still be injected via RunDialers, and we will take care of initialization in cmd.go. This allows us to keep pre-checks testable and inject mock DNSResolvers when needed.

I thought about overriding `edgediscovery.ResolveEdge` and `edgeDiscovery.StaticEdge` instead of `allregion.EdgeDiscovery` and `allregionResolveAddrs`, respectively. But that would imply changing our existing probe logic to support `Regions` instead of `[][]EdgeAddr`, which would mean more work. Additionally (and perhaps most importantly), using `Regions` would also require us to create new functions to extract a list of edge addresses for our probe tests. I don't think this would go well with the current implementation of `Regions`, as I believe it's intent is to encapsulate the logic around managing addresses per regions. Adding these functions would mean breaking this encapsulation.
2026-05-07 11:42:01 +00:00
Gonçalo Garcia a453612e7c TUN-10507: Bump go and go-boring to 1.26.2
## What

Bumps go-boring from 1.26.0-1 to 1.26.2-1 and CI builder image from \`3501-fc698419a625\` to \`3595-779e088c0ec4\`.

go1.26.2 (released 2026-04-07) includes security fixes to the \`go\` command, the compiler, and the \`archive/tar\`, \`crypto/tls\`, \`crypto/x509\`, \`html/template\`, and \`os\` packages, as well as bug fixes to the \`net\`, \`net/http\`, and \`net/url\` packages.

### Security fixes (relevant)
- **crypto/tls**: multiple CVEs — cloudflared uses TLS extensively for tunnel connections
- **crypto/x509**: CVE-2026-32280 (excessive chain-building in \`Verify\`), CVE-2026-32281 (quadratic work in policy validation)

### Net bug fixes (not applicable)
- **net/url #78111**: \`url.Parse\` regression for MongoDB-style multi-host URLs — not used in cloudflared
- **net/http #78019**: race condition on Windows when using \`os.File\` as HTTP request body — cloudflared does not pass \`os.File\` as a request body
- **net #77885**: \`ReadMsgUDP\`/\`WriteMsgUDP\` WSAEFAULT on Windows with empty non-nil oob — quic-go uses \`basicConn\` on Windows (\`ReadFrom\`, not \`ReadMsgUDP\`)

## Jira

[TUN-10507](https://jira.cfdata.org/browse/TUN-10507)
2026-05-07 08:39:53 +00:00
Miguel da Costa Martins Marcelino e8f8b2afb7 TUN-10390: Fix missing TLS settings
Check / check (1.22.x, macos-latest) (push) Has been cancelled
Check / check (1.22.x, ubuntu-latest) (push) Has been cancelled
Check / check (1.22.x, windows-latest) (push) Has been cancelled
Semgrep config / semgrep/ci (push) Has been cancelled
Fixing missing TLS settings. While developing the pre-check probes, I forgot to add the certificate settings, which are essential for establishing a connection to origintunneld. I discovered this while testing cloudflared locally.
2026-05-06 11:17:59 +00:00
Miguel da Costa Martins Marcelino 7585e38948 chore: Fix warnings
Check / check (1.22.x, macos-latest) (push) Has been cancelled
Check / check (1.22.x, ubuntu-latest) (push) Has been cancelled
Check / check (1.22.x, windows-latest) (push) Has been cancelled
Semgrep config / semgrep/ci (push) Has been cancelled
Fixing warnings in cloudflared log collector.

This attempts to fix errors like the ones shown below:

```
diagnostic/diagnostic.go:132:23: Error return value of `logHandle.Close` is not checked (errcheck)
	defer logHandle.Close()

diagnostic/diagnostic.go:134:26: G303: File creation in shared tmp directory without using ioutil.Tempfile (gosec)
	outputLogHandle, err := os.Create(filepath.Join(os.TempDir(), logFilename))
```
2026-05-05 08:28:41 +00:00
Miguel da Costa Martins Marcelino a9b6f703f0 TUN-10389: Implement main run method
Check / check (1.22.x, macos-latest) (push) Has been cancelled
Check / check (1.22.x, ubuntu-latest) (push) Has been cancelled
Check / check (1.22.x, windows-latest) (push) Has been cancelled
Semgrep config / semgrep/ci (push) Has been cancelled
This introduces  the main precheck logic. This will follow concurrency model, timeout handling, and retry logic established in the SPEC. We will follow the decision flow in the [connectivity pre-checks SPEC](https://wiki.cfdata.org/spaces/TUN/pages/1374967685/Connectivity+Pre-checks+for+cloudflared). You can find an attached image of what the decision flow should look like.

![image.png](/uploads/fa71215adc2da509f6cbbb74532e3d95/image.png){width=900 height=235}
2026-05-04 16:34:52 +00:00
Evan Raw da81fb02ec AUTH-4699, AUTH-8460, TUN-10179: Fix .lock file deletion race condition
Check / check (1.22.x, macos-latest) (push) Has been cancelled
Check / check (1.22.x, ubuntu-latest) (push) Has been cancelled
Check / check (1.22.x, windows-latest) (push) Has been cancelled
Semgrep config / semgrep/ci (push) Has been cancelled
Replace the lock file mechanism with PID+start-time based stale
detection so that no cleanup is required on process death.

When both org and app token locks were held, the first signal handler
to call os.Exit() would kill the process before the second handler
could delete its lock file. The orphaned lock file then caused the
next invocation to wait ~128 seconds in an exponential backoff loop
before forcibly deleting it. The same issue occurred on SIGKILL, OOM,
or any non-signal death.

Lock files now contain the holder's PID and process start time as
JSON. On acquisition, if a lock file already exists, the recorded
process is checked for liveness via gopsutil. Stale locks are
reclaimed immediately with no backoff. Atomic O_CREATE|O_EXCL
prevents races between concurrent acquirers.

Also adds a companion .url file so processes waiting on an active
lock can print the auth URL for the user.
2026-05-01 13:04:51 +00:00
Evan Raw 23b15d0eb6 AUTH-4699, AUTH-8460, TUN-10179: Vendor gopsutil/v4 for cross-platform process identification 2026-05-01 13:04:51 +00:00
Miguel da Costa Martins Marcelino 4a2cbd1870 TUN-10389: Improve probe functions
Check / check (1.22.x, macos-latest) (push) Has been cancelled
Check / check (1.22.x, ubuntu-latest) (push) Has been cancelled
Check / check (1.22.x, windows-latest) (push) Has been cancelled
Semgrep config / semgrep/ci (push) Has been cancelled
Follow-up to https://gitlab.cfdata.org/cloudflare/tun/cloudflared/-/merge_requests/1819. This applies suggestions from that PR to make the code easier to read and more maintainable.
2026-04-30 16:18:07 +00:00
Miguel da Costa Martins Marcelino 9978cfd0d5 TUN-10388 Implement dialers for connectivity checks
This PR implements all the dialers and resolvers needed to make pre-checks happen. So this task focuses on the following:

1. Implement the DNS probe: call DNSResolver.Resolve(region)
2. Implement the QUIC probe: call QUICDialer.DialQuic (handshake only, no stream opened) and record the result.
3. Implement the HTTP/2 probe: call TCPDialer.DialEdge (TCP + TLS handshake only, no frames sent) and record the result.
4. Implement the Management API probe: call ManagementDialer.DialContext to api.cloudflare.com:443 and record the result.
5. Export edgeDiscovery as EdgeDiscovery in edgediscovery/allregions/discovery.go so the pre-check can reuse the production DNS path.

This sets up the main components to implement the checker.
2026-04-30 15:15:25 +00:00
Miguel da Costa Martins Marcelino a0401df621 TUN-10388: Adding probe check
Adding new probe check for UDP connectivity. This ensures that we skip the connection index when doing probes in cloudflared.
2026-04-30 14:32:24 +01:00
Miguel da Costa Martins Marcelino cf17ba93b2 TUN-10388: Use pointer for suggested protocol
Check / check (1.22.x, macos-latest) (push) Has been cancelled
Check / check (1.22.x, ubuntu-latest) (push) Has been cancelled
Check / check (1.22.x, windows-latest) (push) Has been cancelled
Semgrep config / semgrep/ci (push) Has been cancelled
Switching `SuggestedProtocol` to a pointer, so we can pass nil whenever both QUIC and HTTP2 fail. We should not be suggesting anything when all our protocols have failed.
2026-04-29 13:36:38 +00:00
Miguel da Costa Martins Marcelino f827e6216b chore: Add pre-push hooks
Adding pre-push hooks to cloudflared. While developing in cloudflared, I found myself constantly bumping into issues in CI, as I was forgetting to run linters and tests at times. We should run these before pushing any code to our repo.
2026-04-29 13:09:22 +00:00
Harshini Ramanujam df981b4d89 SECENG-13496 update pkg docs for gokeyless to support multiple builds
* To support older glibc OS - building separate versions for compatibility

Closes SECENG-13496
2026-04-29 05:37:09 -04:00
Miguel da Costa Martins Marcelino ddd76fa05f TUN-10387: Add no-prechecks flag
Check / check (1.22.x, macos-latest) (push) Has been cancelled
Check / check (1.22.x, ubuntu-latest) (push) Has been cancelled
Check / check (1.22.x, windows-latest) (push) Has been cancelled
Semgrep config / semgrep/ci (push) Has been cancelled
Add a new no-prechecks flag to cloudflared. This will allow skipping connectivity pre-checks at startup.
2026-04-27 11:29:43 +00:00
Miguel da Costa Martins Marcelino 9f084e6800 TUN-10386: Add Table Renderer
Check / check (1.22.x, macos-latest) (push) Has been cancelled
Check / check (1.22.x, ubuntu-latest) (push) Has been cancelled
Check / check (1.22.x, windows-latest) (push) Has been cancelled
Semgrep config / semgrep/ci (push) Has been cancelled
The goals of this PR are twofold:

## **1. Introduce a new renderer to output to `stdout`**

Implement the table renderer that will be used to report the results to stdout. The renderer should output something similar to this:

```
─── CONNECTIVITY PRE-CHECKS ──────────────────────────────────────────────────
COMPONENT            TARGET                         STATUS   DETAILS
DNS Resolution       region1.v2.argotunnel.com      PASS     Resolved successfully
DNS Resolution       region2.v2.argotunnel.com      PASS     Resolved successfully
UDP Connectivity     Port 7844 (QUIC)               PASS     Handshake successful
TCP Connectivity     Port 7844 (HTTP/2)             PASS     TLS handshake successful
Cloudflare API       api.cloudflare.com:443         PASS     Reachable

SUMMARY: Environment is healthy. cloudflared will use 'quic' as primary protocol.
──────────────────────────────────────────────────────────────────────────────
```

## **2. Add a log-level renderer**

Add support for structured logging to print the table results as logs. Below is an example of how logs should look like:

```
{"level":"info","run_id":"52828729-dfwd-45b3-w12f-727cbdb4cbd4", "component":"DNS Resolution","target":"region1.v2.argotunnel.com","status":"pass","details":"Resolved successfully","time":"2024-01-15T10:30:00Z","message":"precheck"}
{"level":"info","run_id":"52828729-dfwd-45b3-w12f-727cbdb4cbd4", "component":"DNS Resolution","target":"region2.v2.argotunnel.com","status":"pass","details":"Resolved successfully","time":"2024-01-15T10:30:00Z","message":"precheck"}
{"level":"info","run_id":"52828729-dfwd-45b3-w12f-727cbdb4cbd4", "component":"QUIC Connectivity","target":"Port 7844 (QUIC)","status":"pass","details":"Handshake successful","time":"2024-01-15T10:30:01Z","message":"precheck"}
{"level":"info","run_id":"52828729-dfwd-45b3-w12f-727cbdb4cbd4", "component":"HTTP/2 Connectivity","target":"Port 7844 (HTTP/2)","status":"pass","details":"TLS handshake successful","time":"2024-01-15T10:30:01Z","message":"precheck"}
{"level":"info","run_id":"52828729-dfwd-45b3-w12f-727cbdb4cbd4", "component":"Management API","target":"api.cloudflare.com:443","status":"pass","details":"Reachable","time":"2024-01-15T10:30:01Z","message":"precheck"}
{"level":"info","run_id":"52828729-dfwd-45b3-w12f-727cbdb4cbd4", "hard_fail":false,"suggested_protocol":"quic","time":"2024-01-15T10:30:01Z","message":"precheck complete"}
```
2026-04-23 19:04:06 +00:00
Miguel da Costa Martins Marcelino df54d27710 TUN-10385: Add connectivity checks foundation
Check / check (1.22.x, macos-latest) (push) Has been cancelled
Check / check (1.22.x, ubuntu-latest) (push) Has been cancelled
Check / check (1.22.x, windows-latest) (push) Has been cancelled
Semgrep config / semgrep/ci (push) Has been cancelled
This adds the foundation for the new cloudflared pre-checks by creating a new prechecks package. This adds the following:

* types.go: Status, CheckResult, Report, Config (add IPVersion allregions.ConfigIPVersion field to Config)
* interfaces.go: DNSResolver, TCPDialer, QUICDialer, ManagementDialer
2026-04-15 22:40:23 +00:00
Miguel da Costa Martins Marcelino b0b898c235 TUN-10383: Set edge-ip-version to auto
Check / check (1.22.x, macos-latest) (push) Has been cancelled
Check / check (1.22.x, ubuntu-latest) (push) Has been cancelled
Check / check (1.22.x, windows-latest) (push) Has been cancelled
Semgrep config / semgrep/ci (push) Has been cancelled
To allow pre-checks to test both IPv6 and IPv4, we must change the default value of edge-ip-version's from 4 to auto. This will allows the tunnel (and pre-check) to probe both IPv4 and IPv6 addresses by default, respecting the system's DNS preference. Instead of always preferring IPv4, cloudflared will now use whichever address family the system resolver returns first.
2026-04-14 16:11:59 +00:00
Miguel da Costa Martins Marcelino 5287a9e24b TUN-10384: Probe TLS Helper
Add `ProbeTLSSettings` helper to connection/protocol.go that returns new settings with the `probe.cftunnel.com` SNI for pre-checks.
2026-04-14 15:35:03 +00:00
Miguel da Costa Martins Marcelino e2a71cbecc chore: Fix errors in cmd
Trying to fix the following errors that showed up in CI, which became an issue when doing the pre-check work in https://gitlab.cfdata.org/cloudflare/tun/cloudflared/-/merge_requests/1814:

```
cmd/cloudflared/tunnel/cmd.go:454:29: Error return value of `metricsListener.Close` is not checked (errcheck)
	defer metricsListener.Close()
	                           ^
cmd/cloudflared/tunnel/cmd.go:573:18: Error return value of `file.Close` is not checked (errcheck)
	defer file.Close()
	                ^
cmd/cloudflared/tunnel/cmd.go:574:13: Error return value of `fmt.Fprintf` is not checked (errcheck)
	fmt.Fprintf(file, "%d", os.Getpid())
	           ^
cmd/cloudflared/tunnel/cmd.go:47:2: G101: Potential hardcoded credentials: Password in URL (gosec)
	sentryDSN = "https://56a9c9fa5c364ab28f34b14f35ea0f1b:3e8827f6f9f740738eb11138f7bebb68@sentry.io/189878"
	^
cmd/cloudflared/tunnel/cmd.go:348:23: G703: Path traversal via taint analysis (gosec)
			if err := os.Rename(tmpTraceFile.Name(), traceOutputFilepath); err != nil {
			                   ^
cmd/cloudflared/tunnel/cmd.go:354:21: G703: Path traversal via taint analysis (gosec)
				err := os.Remove(tmpTraceFile.Name())
				                ^
cmd/cloudflared/tunnel/cmd.go:568:15: G304: Potential file inclusion via variable (gosec)
	file, err := os.Create(expandedPath)
	             ^
cmd/cloudflared/tunnel/cmd.go:260:10: ST1005: error strings should not be capitalized (staticcheck)
		return fmt.Errorf("Use `cloudflared tunnel run` to start tunnel %s", ref)
		       ^
cmd/cloudflared/tunnel/cmd.go:1146:5: SA4011: ineffective break statement. Did you mean to break out of the outer loop? (staticcheck)
				break
				^
9 issues:
* errcheck: 3
* gosec: 4
* staticcheck: 2
```
2026-04-14 14:56:10 +00:00
Harshini Ramanujam a0e55fc969 SECENG-13056 update gokeyless install instructions on pkg.cloudflare.com/index.html
Check / check (1.22.x, macos-latest) (push) Has been cancelled
Check / check (1.22.x, ubuntu-latest) (push) Has been cancelled
Check / check (1.22.x, windows-latest) (push) Has been cancelled
Semgrep config / semgrep/ci (push) Has been cancelled
Updating the instructions - now that I have updated gokeyless packages to sign with both keys. Will check in with TUN team to release.

Closes SECENG-13056
2026-04-10 08:59:48 -04:00
GoncaloGarcia 1e9deb1002 TUN-9952: Bump go to 1.26
Check / check (1.22.x, windows-latest) (push) Has been cancelled
Semgrep config / semgrep/ci (push) Has been cancelled
Check / check (1.22.x, macos-latest) (push) Has been cancelled
Check / check (1.22.x, ubuntu-latest) (push) Has been cancelled
2026-04-06 13:04:18 +01:00
GoncaloGarcia d2a87e9b93 Release 2026.3.0
Check / check (1.22.x, macos-latest) (push) Has been cancelled
Check / check (1.22.x, ubuntu-latest) (push) Has been cancelled
Check / check (1.22.x, windows-latest) (push) Has been cancelled
Semgrep config / semgrep/ci (push) Has been cancelled
2026-03-06 12:53:40 +00:00
João "Pisco" Fernandes c0bc3bdbf0 fix: Update go-sentry and go-oidc to address CVE's
Check / check (1.22.x, macos-latest) (push) Has been cancelled
Check / check (1.22.x, ubuntu-latest) (push) Has been cancelled
Check / check (1.22.x, windows-latest) (push) Has been cancelled
Semgrep config / semgrep/ci (push) Has been cancelled
2026-03-05 19:10:16 +00:00
João "Pisco" Fernandes 29b3a7aa7e chore: Addressing small fixes and typos
Check / check (1.22.x, macos-latest) (push) Has been cancelled
Check / check (1.22.x, ubuntu-latest) (push) Has been cancelled
Check / check (1.22.x, windows-latest) (push) Has been cancelled
Semgrep config / semgrep/ci (push) Has been cancelled
2026-03-05 16:53:48 +00:00
Gonçalo Garcia 372a4b7079 TUN-10292: Add cloudflared management token command
Check / check (1.22.x, macos-latest) (push) Has been cancelled
Check / check (1.22.x, ubuntu-latest) (push) Has been cancelled
Check / check (1.22.x, windows-latest) (push) Has been cancelled
Semgrep config / semgrep/ci (push) Has been cancelled
Create new management token command to support different resource
permissions (logs, admin, host_details). This fixes failing component
tests that need admin-level tokens to access management endpoints.

- Add ManagementResource enum values: Admin, HostDetails
- Create cmd/cloudflared/management package with token command
- Extract shared utilities to cliutil/management.go (GetManagementToken, CreateStderrLogger)
- Refactor tail/cmd.go to use shared utilities
- Update component tests to use new command with admin resource

Closes TUN-10292
2026-03-05 16:31:24 +00:00
Luis Neto 649705d291 TUN-10258: add agents.md
Check / check (1.22.x, ubuntu-latest) (push) Has been cancelled
Check / check (1.22.x, windows-latest) (push) Has been cancelled
Semgrep config / semgrep/ci (push) Has been cancelled
Check / check (1.22.x, macos-latest) (push) Has been cancelled
* chore: add agents.md

this was generated by opencode's /init command
2026-02-24 11:17:27 +00:00
Luis Neto 839b874cad TUN-10267: Update mods to fix CVE GO-2026-4394
* TUN-10267: Update mods to fix CVE GO-2026-4394

Closes TUN-10267
2026-02-23 14:22:02 +00:00
Gonçalo Garcia 059f4d9898 TUN-10247: Update tail command to use /management/logs endpoint
* TUN-10247: Update tail command to use /management/logs endpoint

The /management endpoint will be deprecated in favor of new /management/resource endpoints. Because of that, we'll need cloudflared to use the new endpoint.

Closes TUN-10247
2026-02-20 15:40:25 +00:00
João "Pisco" Fernandes a0bcbf6a44 TUN-9858: Add more information to proxy-dns removal message
## Summary
Add link to deprecation announcement and alternative DNS-over-HTTPS client recommendation in the proxy-dns error message.
2026-02-11 17:59:38 +00:00
1123 changed files with 176331 additions and 124006 deletions
+8 -37
View File
@@ -3,23 +3,12 @@
runOnBranches: "^master$"
COMPONENT: "common"
.register_inputs_stable_bookworm: &register_inputs_stable_bookworm
<<: *register_inputs
runOnChangesTo: ['RELEASE_NOTES']
FLAVOR: "bookworm"
SERIES: "stable"
.register_inputs_stable_trixie: &register_inputs_stable_trixie
<<: *register_inputs
runOnChangesTo: ['RELEASE_NOTES']
FLAVOR: "trixie"
SERIES: "stable"
.register_inputs_next_bookworm: &register_inputs_next_bookworm
<<: *register_inputs
FLAVOR: "bookworm"
SERIES: next
.register_inputs_next_trixie: &register_inputs_next_trixie
<<: *register_inputs
FLAVOR: "trixie"
@@ -66,6 +55,10 @@ cloudflared-arm64-stable:
ORIGINAL_NAME: true
CGO_ENABLED: 1
# Jobs names
.amd64-stable: &amd64-stable ["cloudflared-amd64-stable"]
.arm64-stable: &arm64-stable ["cloudflared-arm64-stable"]
############
### Next ###
############
@@ -85,25 +78,16 @@ cloudflared-arm64-next:
<<: *arm64-stable-vars
NIGHTLY: true
# Jobs names
.amd64-next: &amd64-next ["cloudflared-amd64-next"]
.arm64-next: &arm64-next ["cloudflared-arm64-next"]
include:
- local: .ci/commons.gitlab-ci.yml
##########################################
### Publish Packages to Internal Repos ###
##########################################
# Bookworm AMD64
- component: $CI_SERVER_FQDN/cloudflare/ci/apt-register/register@~latest
inputs:
<<: *register_inputs_stable_bookworm
jobPrefix: cloudflared-bookworm-amd64
needs: &amd64-stable ["cloudflared-amd64-stable"]
# Bookworm ARM64
- component: $CI_SERVER_FQDN/cloudflare/ci/apt-register/register@~latest
inputs:
<<: *register_inputs_stable_bookworm
jobPrefix: cloudflared-bookworm-arm64
needs: &arm64-stable ["cloudflared-arm64-stable"]
# Trixie AMD64
- component: $CI_SERVER_FQDN/cloudflare/ci/apt-register/register@~latest
@@ -122,19 +106,6 @@ include:
##################################################
### Publish Nightly Packages to Internal Repos ###
##################################################
# Bookworm AMD64
- component: $CI_SERVER_FQDN/cloudflare/ci/apt-register/register@~latest
inputs:
<<: *register_inputs_next_bookworm
jobPrefix: cloudflared-nightly-bookworm-amd64
needs: &amd64-next ['cloudflared-amd64-next']
# Bookworm ARM64
- component: $CI_SERVER_FQDN/cloudflare/ci/apt-register/register@~latest
inputs:
<<: *register_inputs_next_bookworm
jobPrefix: cloudflared-nightly-bookworm-arm64
needs: &arm64-next ['cloudflared-arm64-next']
# Trixie AMD64
- component: $CI_SERVER_FQDN/cloudflare/ci/apt-register/register@~latest
+1 -1
View File
@@ -6,7 +6,7 @@ RUN apt-get update && \
apt-get install --no-install-recommends --allow-downgrades -y \
build-essential \
git \
go-boring=1.24.13-1 \
go-boring=1.26.4-1 \
libffi-dev \
procps \
python3-dev \
+34
View File
@@ -0,0 +1,34 @@
include:
- local: .ci/commons.gitlab-ci.yml
###########################################################################
### Build and Push Internal Image (commit SHA on master, version on tag) ###
###########################################################################
- component: $CI_SERVER_FQDN/cloudflare/ci/docker-image/build-push-image@~latest
inputs:
stage: release-internal
jobPrefix: internal-image
runOnMR: false
runOnBranches: '^master$'
needs:
- generate-internal-image-version
commentImageRefs: false
runner: vm-linux-x86-4cpu-8gb
EXTRA_DIB_ARGS: "--manifest=.docker-images-internal"
###############################################################################
### Generate Internal Image Version File ###
### Uses `git describe`: version tag on tagged commits, SHA-based on master ###
###############################################################################
generate-internal-image-version:
stage: release-internal
image: $BUILD_IMAGE
rules:
- !reference [.default-rules, run-on-master]
needs:
- ci-image-get-image-ref
script:
- make generate-internal-image-version
artifacts:
paths:
- versions-internal
+2 -2
View File
@@ -4,8 +4,8 @@
outputDir: artifacts
runner: linux-x86-8cpu-16gb
stage: build
golangVersion: "boring-1.24"
imageVersion: "3462-0b23466e0715@sha256:42e8533370666a2463041572293a79e1449001ef803a993e6a860be00858c806"
golangVersion: "boring-1.26"
imageVersion: "3625-1801d52@sha256:9261597bc2d229c997522848260de758567643d58ae1097196ae368db89a1d0f"
CGO_ENABLED: 1
.default-packaging-job: &packaging-job-defaults
+1 -1
View File
@@ -8,7 +8,7 @@ include:
rules:
- !reference [.default-rules, run-always]
tags:
- windows-x86
- canary-windows-x86
cache: {}
##########################################
+8
View File
@@ -0,0 +1,8 @@
images:
- name: cloudflared
dockerfile: Dockerfile.$ARCH
context: .
version_file: versions-internal
architectures:
- amd64
- arm64
+29
View File
@@ -0,0 +1,29 @@
#!/bin/sh
# Pre-push hook for cloudflared
# Runs linting and tests before allowing pushes
set -e
echo "========================================"
echo "Running pre-push checks..."
echo "========================================"
# Run formatting check
echo ""
echo "==> Checking formatting..."
make fmt-check
# Run linter
echo ""
echo "==> Running linter..."
make lint
# Run tests
echo ""
echo "==> Running tests..."
make test
echo ""
echo "========================================"
echo "All pre-push checks passed!"
echo "========================================"
+23 -17
View File
@@ -1,24 +1,30 @@
name: Semgrep OSS scan
on:
pull_request: {}
push:
branches: [main, master]
workflow_dispatch: {}
push:
branches:
- main
- master
schedule:
- cron: '0 0 * * *'
name: Semgrep config
- cron: '0 0 25 * *'
concurrency:
group: semgrep-${{ github.event_name }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
permissions:
contents: read
jobs:
semgrep:
name: semgrep/ci
runs-on: ubuntu-latest
env:
SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }}
SEMGREP_URL: https://cloudflare.semgrep.dev
SEMGREP_APP_URL: https://cloudflare.semgrep.dev
SEMGREP_VERSION_CHECK_URL: https://cloudflare.semgrep.dev/api/check-version
container:
image: semgrep/semgrep
name: semgrep-oss
runs-on: ubuntu-slim
steps:
- uses: actions/checkout@v4
- run: semgrep ci
- uses: actions/checkout@v5
with:
fetch-depth: 1
- id: cache-semgrep
uses: actions/cache@v5
with:
path: ~/.local
key: semgrep-1.160.0-${{ runner.os }}
- if: steps.cache-semgrep.outputs.cache-hit != 'true'
run: pip install --user semgrep==1.160.0
- run: echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- run: semgrep scan --config=auto
+18 -4
View File
@@ -1,5 +1,5 @@
variables:
GO_VERSION: "1.24.13"
GO_VERSION: "1.26.4"
MAC_GO_VERSION: "go@$GO_VERSION"
WIN_GO_VERSION: "go$GO_VERSION"
GIT_DEPTH: "0"
@@ -9,7 +9,18 @@ default:
VAULT_ID_TOKEN:
aud: https://vault.cfdata.org
stages: [sync, pre-build, build, validate, test, package, release, release-internal, review]
stages:
[
sync,
pre-build,
build,
validate,
test,
package,
release,
release-internal,
review,
]
include:
#####################################################
@@ -52,9 +63,12 @@ include:
#####################################################
- local: .ci/apt-internal.gitlab-ci.yml
#####################################################
########## Release Internal Docker Image ############
#####################################################
- local: .ci/internal-image.gitlab-ci.yml
#####################################################
############## Manual Claude Review #################
#####################################################
- component: $CI_SERVER_FQDN/cloudflare/ci/ai/review@~latest
inputs:
whenToRun: "manual"
+14 -8
View File
@@ -1,3 +1,5 @@
version: "2"
linters:
enable:
# Some of the linters below are commented out. We should uncomment and start running them, but they return
@@ -14,10 +16,7 @@ linters:
- errcheck # Errcheck is a program for checking for unchecked errors in Go code. These unchecked errors can be critical bugs in some cases.
- errname # Checks that sentinel errors are prefixed with the Err and error types are suffixed with the Error.
- exhaustive # Check exhaustiveness of enum switch statements.
- gofmt # Gofmt checks whether code was gofmt-ed. By default this tool runs with -s option to check for code simplification.
- goimports # Check import statements are formatted according to the 'goimport' command. Reformat imports in autofix mode.
- gosec # Inspects source code for security problems.
- gosimple # Linter for Go source code that specializes in simplifying code.
- govet # Vet examines Go source code and reports suspicious constructs. It is roughly the same as 'go vet' and uses its passes.
- ineffassign # Detects when assignments to existing variables are not used.
- importas # Enforces consistent import aliases.
@@ -36,7 +35,13 @@ linters:
- wastedassign # Finds wasted assignment statements.
- whitespace # Whitespace is a linter that checks for unnecessary newlines at the start and end of functions, if, for, etc.
- zerologlint # Detects the wrong usage of zerolog that a user forgets to dispatch with Send or Msg.
# Other linters are disabled, list of all is here: https://golangci-lint.run/usage/linters/
# Other linters are disabled, list of all is here: https://golangci-lint.run/usage/linters/
formatters:
enable:
- gofmt # Formats code according to Go standard formatting
- goimports # Formats imports and groups them properly
run:
timeout: 5m
modules-download-mode: vendor
@@ -44,9 +49,10 @@ run:
# output configuration options
output:
formats:
- format: 'colored-line-number'
print-issued-lines: true
print-linter-name: true
text:
colors: true
print-linter-name: true
print-issued-lines: true
issues:
# Maximum issues count per one linter.
@@ -67,7 +73,7 @@ issues:
new: true
# Show only new issues created after git revision `REV`.
# Default: ""
new-from-rev: ac34f94d423273c8fa8fdbb5f2ac60e55f2c77d5
new-from-rev: d2a87e9b93456ad7f82417400f4209d513668487
# Show issues in any part of update files (requires new-from-rev or new-from-patch).
# Default: false
whole-files: true
+303
View File
@@ -0,0 +1,303 @@
# Cloudflared
Cloudflare's command-line tool and networking daemon written in Go.
Production-grade tunneling and network connectivity services used by millions of
developers and organizations worldwide.
## Essential Commands
### Build & Test (Always run before commits)
```bash
# Full development check (run before any commit)
make test lint
# Build for current platform
make cloudflared
# Run all unit tests with coverage
make test
make cover
# Run specific test
go test -run TestFunctionName ./path/to/package
# Run tests with race detection
go test -race ./...
```
### Platform-Specific Builds
```bash
# Linux
TARGET_OS=linux TARGET_ARCH=amd64 make cloudflared
# Windows
TARGET_OS=windows TARGET_ARCH=amd64 make cloudflared
# macOS ARM64
TARGET_OS=darwin TARGET_ARCH=arm64 make cloudflared
# FIPS compliant build
FIPS=true make cloudflared
```
### Code Quality & Formatting
```bash
# Run linter (38+ enabled linters)
make lint
# Auto-fix formatting
make fmt
gofmt -w .
goimports -w .
# Security scanning
make vet
# Component tests (Python integration tests)
cd component-tests && python -m pytest test_file.py::test_function_name
```
Notes on linting:
- `.golangci.yaml` is configured with `new-from-rev` and `whole-files: true`.
Touching a file triggers linting of the ENTIRE file, not just the changed
hunks. Expect to fix pre-existing issues in files you modify, or add
targeted `// nolint: <linter>` comments with a short justification.
- Prefer `defer func() { _ = resource.Close() }()` over `defer resource.Close()`
for `io.Closer` values whose error truly does not matter — this satisfies
`errcheck` without hiding real failures elsewhere.
## Project Knowledge
### Package Structure
- Use meaningful package names that reflect functionality
- Package names should be lowercase, single words when possible
- Avoid generic names like `util`, `common`, `helper`
#### Well-known shared packages
- `crypto/`: Single source of truth for TLS curve preferences and other
cryptographic primitives shared by every edge-facing transport. Import as
`cfdcrypto "github.com/cloudflare/cloudflared/crypto"` to avoid colliding
with the standard library's `crypto` package. Do NOT duplicate TLS curve
or cipher selection logic in other packages.
- `tlsconfig/`: Builds the base `*tls.Config` used for edge connections
(`CreateTunnelConfig`) and loads origin/CA pools. Curve selection is
intentionally NOT set here; it is applied per-connection from the
`crypto/` package so the same config can be cloned and reused across
protocols.
- `features/`: Runtime feature flags including `PostQuantumMode`
(`PostQuantumPrefer` = default, `PostQuantumStrict` = `--post-quantum`).
- `fips/`: Build-tag driven FIPS detection. Only `fips.IsFipsEnabled()` is
exposed; never branch on `fipsEnabled` inside a function if the two
branches return the same value.
### Function and Method Guidelines
```go
// Good: Clear purpose, proper error handling
func (c *Connection) HandleRequest(ctx context.Context, req *http.Request) error {
if req == nil {
return errors.New("request cannot be nil")
}
// Implementation...
return nil
}
```
### Error Handling
- Always handle errors explicitly, never ignore them
- Use `fmt.Errorf` for error wrapping
- Create meaningful error messages with context
- Use error variables for common errors
```go
// Good error handling patterns
if err != nil {
return fmt.Errorf("failed to process connection: %w", err)
}
```
### Logging Standards
- Use `github.com/rs/zerolog` for structured logging
- Include relevant context fields
- Use appropriate log levels (Debug, Info, Warn, Error)
```go
logger.Info().
Str("tunnelID", tunnel.ID).
Int("connIndex", connIndex).
Msg("Connection established")
```
### Testing Patterns
- Use `github.com/stretchr/testify` for assertions
- Test files end with `_test.go`
- Use table-driven tests for multiple scenarios
- Always use `t.Parallel()` for parallel-safe tests
- Use meaningful test names that describe behavior
```go
func TestMetricsListenerCreation(t *testing.T) {
t.Parallel()
// Test implementation
assert.Equal(t, expected, actual)
require.NoError(t, err)
}
```
### Constants and Variables
```go
const (
MaxGracePeriod = time.Minute * 3
MaxConcurrentStreams = math.MaxUint32
LogFieldConnIndex = "connIndex"
)
var (
// Group related variables
switchingProtocolText = fmt.Sprintf("%d %s", http.StatusSwitchingProtocols, http.StatusText(http.StatusSwitchingProtocols))
flushableContentTypes = []string{sseContentType, grpcContentType, sseJsonContentType}
)
```
### Type Definitions
- Define interfaces close to their usage
- Keep interfaces small and focused
- Use descriptive names for complex types
```go
type TunnelConnection interface {
Serve(ctx context.Context) error
}
type TunnelProperties struct {
Credentials Credentials
QuickTunnelUrl string
}
```
## Key Architectural Patterns
### Context Usage
- Always accept `context.Context` as first parameter for long-running operations
- Respect context cancellation in loops and blocking operations
- Pass context through call chains
### Concurrency
- Use channels for goroutine communication
- Protect shared state with mutexes
- Prefer `sync.RWMutex` for read-heavy workloads
- `*tls.Config` values stored in shared maps (e.g.
`TunnelConfig.EdgeTLSConfigs`) must be `Clone()`d before mutating
per-connection fields like `CurvePreferences` or `NextProtos`. Writing
through the shared pointer races with concurrent connection attempts.
### TLS & Post-Quantum key exchange
- Per-connection TLS configuration for edge connections is built via
`cfdcrypto.TLSConfigWithCurvePreferences(tlsConfig, pqMode)`. It clones
the provided `*tls.Config` and sets `CurvePreferences` based on `pqMode`,
so callers never need to clone or mutate `CurvePreferences` themselves.
Do NOT reach for the package-private `getCurvePreferences` helper; the
exported `TLSConfigWithCurvePreferences` is the only supported entry
point.
- Two PQ modes are supported and apply identically to QUIC and HTTP/2:
- `PostQuantumPrefer` (default): `[X25519MLKEM768, P256Kyber768Draft00, CurveP256]`
- `PostQuantumStrict` (`--post-quantum`): `[X25519MLKEM768, P256Kyber768Draft00]`
- FIPS and non-FIPS builds use the same curve list. Do NOT reintroduce a
`fipsEnabled` branch in curve-selection code; if the two modes ever
diverge, express the divergence inside `crypto/` so call sites remain
untouched.
- HTTP/2 supports post-quantum handshakes. Never re-add a
`PostQuantumStrict`-based rejection to H2 code paths, and never force
`--post-quantum` to select QUIC-only in protocol selection.
### Configuration
- Use structured configuration with validation
- Support both file-based and CLI flag configuration
- Provide sensible defaults
### Metrics and Observability
- Instrument code with Prometheus metrics
- Use OpenTelemetry for distributed tracing
- Include structured logging with relevant context
## Boundaries
### ✅ Always Do
- Run `make test lint` before any commit
- Handle all errors explicitly with proper context
- Use `github.com/rs/zerolog` for all logging
- Add `t.Parallel()` to all parallel-safe tests
- Follow the import grouping conventions
- Use meaningful variable and function names
- Include context.Context for long-running operations
- Close resources in defer statements
### ⚠️ Ask First Before
- Adding new dependencies to go.mod
- Modifying CI/CD configuration files
- Changing build system or Makefile
- Modifying component test infrastructure
- Adding new linter rules or changing golangci-lint config
- Making breaking changes to public APIs
- Changing logging levels or structured logging fields
### 🚫 Never Do
- Ignore errors without explicit handling (`_ = err`)
- Use generic package names (`util`, `helper`, `common`)
- Commit code that fails `make test lint`
- Use `fmt.Print*` instead of structured logging
- Modify vendor dependencies directly
- Commit secrets, credentials, or sensitive data
- Use deprecated or unsafe Go patterns
- Skip testing for new functionality
- Remove existing tests unless they're genuinely invalid
## Dependencies Management
- Use Go modules (`go.mod`) exclusively
- Vendor dependencies for reproducible builds
- Keep dependencies up-to-date and secure
- Prefer standard library when possible
- Cloudflared uses a fork of quic-go always check release notes before bumping
this dependency.
## Security Considerations
- FIPS compliance support available
- Vulnerability scanning integrated in CI
- Credential handling follows security best practices
- Network security with TLS/QUIC protocols
- Regular security audits and updates
- Post quantum encryption
## Common Patterns to Follow
1. **Graceful shutdown**: Always implement proper cleanup
2. **Resource management**: Close resources in defer statements
3. **Error propagation**: Wrap errors with meaningful context
4. **Configuration validation**: Validate inputs early
5. **Logging consistency**: Use structured logging throughout
6. **Testing coverage**: Aim for comprehensive test coverage
7. **Documentation**: Comment exported functions and types
Remember: This is a mission-critical networking tool used in production by many
organizations. Code quality, security, and reliability are paramount.
+5 -1
View File
@@ -1,3 +1,7 @@
## 2026.4.0
### Breaking Change
- The default value of `--edge-ip-version` has changed from `4` to `auto`. This means cloudflared will now use whichever address family (IPv4 or IPv6) the system resolver returns first, instead of always preferring IPv4. Users who require IPv4-only connections should explicitly set `--edge-ip-version 4`.
## 2026.2.0
### Breaking Change
- Removes the `proxy-dns` feature from cloudflared. This feature allowed running a local DNS over HTTPS (DoH) proxy.
@@ -292,7 +296,7 @@ of uptime. Previous cloudflared versions will soon be unable to run legacy tempo
### Bug Fixes
- Tunnel create and delete commands no longer use path to credentials from the configuration file.
If you need ot place tunnel credentials file at a specific location, you must use `--credentials-file` flag.
If you need to place tunnel credentials file at a specific location, you must use `--credentials-file` flag.
- Access ssh-gen creates properly named keys for SSH short lived certs.
+2 -2
View File
@@ -1,7 +1,7 @@
# use a builder image for building cloudflare
ARG TARGET_GOOS
ARG TARGET_GOARCH
FROM golang:1.24.13 AS builder
FROM golang:1.26.4 AS builder
ENV GO111MODULE=on \
CGO_ENABLED=0 \
TARGET_GOOS=${TARGET_GOOS} \
@@ -20,7 +20,7 @@ COPY . .
RUN make cloudflared
# use a distroless base image with glibc
FROM gcr.io/distroless/base-debian13:nonroot
FROM gcr.io/distroless/base-debian13:nonroot@sha256:b78832f41c8128046807c24840ebee4f1c18ba7870eed423d8750c272c15e147
LABEL org.opencontainers.image.source="https://github.com/cloudflare/cloudflared"
+2 -2
View File
@@ -1,5 +1,5 @@
# use a builder image for building cloudflare
FROM golang:1.24.13 AS builder
FROM golang:1.26.4 AS builder
ENV GO111MODULE=on \
CGO_ENABLED=0 \
# the CONTAINER_BUILD envvar is used set github.com/cloudflare/cloudflared/metrics.Runtime=virtual
@@ -15,7 +15,7 @@ COPY . .
RUN GOOS=linux GOARCH=amd64 make cloudflared
# use a distroless base image with glibc
FROM gcr.io/distroless/base-debian13:nonroot
FROM gcr.io/distroless/base-debian13:nonroot-amd64@sha256:ce2a20e0e277b7d913aa8bcfa098fc2a543dc08028f7393434963fa24b39ea81
LABEL org.opencontainers.image.source="https://github.com/cloudflare/cloudflared"
+2 -2
View File
@@ -1,5 +1,5 @@
# use a builder image for building cloudflare
FROM golang:1.24.13 AS builder
FROM golang:1.26.4 AS builder
ENV GO111MODULE=on \
CGO_ENABLED=0 \
# the CONTAINER_BUILD envvar is used set github.com/cloudflare/cloudflared/metrics.Runtime=virtual
@@ -15,7 +15,7 @@ COPY . .
RUN GOOS=linux GOARCH=arm64 make cloudflared
# use a distroless base image with glibc
FROM gcr.io/distroless/base-debian13:nonroot-arm64
FROM gcr.io/distroless/base-debian13:nonroot-arm64@sha256:7a4876f88e7fe3190972c274b679b3473f61e8d990a4dce3627961b3e22a0eaf
LABEL org.opencontainers.image.source="https://github.com/cloudflare/cloudflared"
+10
View File
@@ -159,6 +159,10 @@ container:
generate-docker-version:
echo latest $(VERSION) > versions
.PHONY: generate-internal-image-version
generate-internal-image-version:
echo $(VERSION) > versions-internal
.PHONY: test
test: vet
@@ -289,3 +293,9 @@ ci-test: fmt-check lint test
.PHONY: ci-fips-test
ci-fips-test:
@FIPS=true $(MAKE) ci-test
.PHONY: install-hooks
install-hooks:
git config core.hooksPath .githooks
@echo "Git hooks installed from .githooks/"
@echo "Pre-push hook will run: make fmt-check lint test"
+11 -4
View File
@@ -10,7 +10,7 @@ You can also use `cloudflared` to access Tunnel origins (that are protected with
at Layer 4 (i.e., not HTTP/websocket), which is relevant for use cases such as SSH, RDP, etc.
Such usages are available under `cloudflared access help`.
You can instead use [WARP client](https://developers.cloudflare.com/cloudflare-one/team-and-resources/devices/warp/)
You can instead use [WARP client](https://developers.cloudflare.com/warp-client/)
to access private origins behind Tunnels for Layer 4 traffic without requiring `cloudflared access` commands on the client side.
@@ -40,7 +40,7 @@ User documentation for Cloudflare Tunnel can be found at https://developers.clou
Once installed, you can authenticate `cloudflared` into your Cloudflare account and begin creating Tunnels to serve traffic to your origins.
* Create a Tunnel with [these instructions](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/get-started/)
* Create a Tunnel with [these instructions](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/get-started/create-remote-tunnel/)
* Route traffic to that Tunnel:
* Via public [DNS records in Cloudflare](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/routing-to-tunnel/dns/)
* Or via a public hostname guided by a [Cloudflare Load Balancer](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/routing-to-tunnel/public-load-balancers/)
@@ -62,7 +62,7 @@ For example, as of January 2023 Cloudflare will support cloudflared version 2023
### Requirements
- [GNU Make](https://www.gnu.org/software/make/)
- [capnp](https://capnproto.org/install.html)
- [go >= 1.24](https://go.dev/doc/install)
- [go >= 1.26](https://go.dev/doc/install)
- Optional tools:
- [capnpc-go](https://pkg.go.dev/zombiezen.com/go/capnproto2/capnpc-go)
- [goimports](https://pkg.go.dev/golang.org/x/tools/cmd/goimports)
@@ -79,4 +79,11 @@ To locally run the tests run `make test`
To format the code and keep a good code quality use `make fmt` and `make lint`
### Mocks
After changes on interfaces you might need to regenerate the mocks, so run `make mock`
After changes on interfaces you might need to regenerate the mocks, so run `make mocks`
### Git Hooks
To avoid CI errors, you can install pre-push hooks that run linting and tests before each push:
```bash
make install-hooks
```
This will configure git to use the hooks in `.githooks/` that run `make fmt-check lint test` before each push.
+86
View File
@@ -1,3 +1,89 @@
2026.7.2
- 2026-07-15 VULN-118896: MacOS service: use --token-file instead of --token
- 2026-07-14 Update gcr.io/distroless/base-debian13:nonroot Docker digest to b78832f
- 2026-07-14 chore: Change internal image name
- 2026-07-14 Update gcr.io/distroless/base-debian13:nonroot-arm Docker digest to 7a4876f88
- 2026-07-13 VULN-118896: Linux service: Use --token-file instead of --token
- 2026-07-13 chore: Remove bookworm internal register publish
- 2026-07-13 Update gcr.io/distroless/base-debian13:nonroot-amd64 Docker digest to ce2a20e
2026.7.1
- 2026-07-09 Revert "TUN-10621: Propagate max wait timeout"
2026.7.0
- 2026-07-08 chore: Bump go-chi to version 5.3.1
- 2026-07-08 Revert "TUN-10557: Bump quic-go v0.59.1"
- 2026-07-01 TUN-10621: Test websocket path
- 2026-06-30 ci: add Semgrep OSS scanning workflow
- 2026-06-29 TUN-10621: Propagate max wait timeout
- 2026-06-29 Migrate config renovate.json
- 2026-06-25 chore: Fix renovate
- 2026-06-25 Pin dependencies
- 2026-06-25 Consolidate init-system detection into shared inits package
- 2026-06-24 Fix lint issues
- 2026-06-22 Add OpenRC support
- 2026-06-22 Fix broken installed-service detection in systemd uninstall
- 2026-06-18 TUN-10557: Bump quic-go v0.59.1
2026.6.1
- 2026-06-18 TUN-10630: Fix precheck protocol override
- 2026-06-18 Revert "TUN-10557: Bump quic-go v0.59.1"
- 2026-06-16 chore: Fix warnings
- 2026-06-15 TUN-10612: Add renovate to cloudflared to update distroless images explicitely
- 2026-06-11 TUN-9251: Publish internal image
- 2026-05-26 TUN-10557: Bump quic-go v0.59.1
2026.6.0
- 2026-06-08 TUN-10558: Bump go to v1.24.4, x/crypto to v0.52.0 and google.golang.org/grpc to v1.81.1
- 2026-06-01 TUN-10563: introduce QUICConnection interface
2026.5.2
- 2026-05-26 TUN-10391: Avoid using fmt.Println
2026.5.1
- 2026-05-22 fix: Bump go to 1.26.3 and go.opentelemetry.io/otel and go-jose/v4 to fix CVE's
- 2026-05-22 TUN-10391: Avoid blocking cloudflared due to logging
- 2026-05-22 TUN-10391: Add precheck integration tests
- 2026-05-14 TUN-10511: Revise --edge support for pre-checks
- 2026-05-13 fix: Update golang.org/x/net to v0.54.0
- 2026-05-13 TUN-10525: Add prechecks kill switch
2026.5.0
- 2026-05-08 Bump golang.org/x/net from v0.40.0 to v0.53.0
- 2026-05-07 TUN-10507: Bump go and go-boring to 1.26.2
- 2026-05-07 TUN-10511: Add Static DNS Resolvers
- 2026-05-07 TUN-10390: Call prechecks
- 2026-05-07 TUN-10513: Disable /debug/pprof/cmdline endpoint
- 2026-05-06 TUN-10390: Fix missing TLS settings
- 2026-05-05 chore: Fix warnings
- 2026-05-04 TUN-10389: Implement main run method
- 2026-04-30 TUN-10388: Adding probe check
- 2026-04-30 TUN-10388 Implement dialers for connectivity checks
- 2026-04-30 TUN-10389: Improve probe functions
- 2026-04-29 SECENG-13496 update pkg docs for gokeyless to support multiple builds
- 2026-04-29 chore: Add pre-push hooks
- 2026-04-29 TUN-10388: Use pointer for suggested protocol
- 2026-04-27 TUN-10387: Add no-prechecks flag
- 2026-04-23 TUN-10386: Add Table Renderer
- 2026-04-21 AUTH-4699, AUTH-8460, TUN-10179: Vendor gopsutil/v4 for cross-platform process identification
- 2026-04-21 AUTH-4699, AUTH-8460, TUN-10179: Fix .lock file deletion race condition
- 2026-04-20 TUN-10413: Centralize TLS curve configuration in crypto/ and adopt X25519MLKEM768 for QUIC/H2
- 2026-04-15 TUN-10385: Add connectivity checks foundation
- 2026-04-14 chore: Fix errors in cmd
- 2026-04-14 TUN-10384: Probe TLS Helper
- 2026-04-14 TUN-10383: Set edge-ip-version to auto
- 2026-04-10 SECENG-13056 update gokeyless install instructions on pkg.cloudflare.com/index.html
- 2026-04-02 TUN-9952: Bump go to 1.26
2026.3.0
- 2026-03-05 TUN-10292: Add cloudflared management token command
- 2026-03-03 chore: Addressing small fixes and typos
- 2026-03-03 fix: Update go-sentry and go-oidc to address CVE's
- 2026-02-24 TUN-10258: add agents.md
- 2026-02-23 TUN-10267: Update mods to fix CVE GO-2026-4394
- 2026-02-20 TUN-10247: Update tail command to use /management/logs endpoint
- 2026-02-11 TUN-9858: Add more information to proxy-dns removal message
2026.2.0
- 2026-02-06 TUN-10216: TUN fix cloudflare vulnerabilities GO-2026-4340 and GO-2026-4341
- 2026-02-02 TUN-9858: Remove proxy-dns feature from cloudflared
+2 -3
View File
@@ -17,8 +17,7 @@ import (
// Websocket is used to carry data via WS binary frames over the tunnel from client to the origin
// This implements the functions for glider proxy (sock5) and the carrier interface
type Websocket struct {
log *zerolog.Logger
isSocks bool
log *zerolog.Logger
}
// NewWSConnection returns a new connection object
@@ -36,7 +35,7 @@ func (ws *Websocket) ServeStream(options *StartOptions, conn io.ReadWriter) erro
ws.log.Err(err).Str(LogFieldOriginURL, options.OriginURL).Msg("failed to connect to origin")
return err
}
defer wsConn.Close()
defer func() { _ = wsConn.Close() }()
stream.Pipe(wsConn, conn, ws.log)
return nil
+22 -16
View File
@@ -2,10 +2,11 @@ package carrier
import (
"context"
crand "crypto/rand"
"crypto/tls"
"crypto/x509"
"fmt"
"math/rand"
"math/big"
"testing"
"time"
@@ -23,7 +24,7 @@ import (
func websocketClientTLSConfig(t *testing.T) *tls.Config {
certPool := x509.NewCertPool()
helloCert, err := tlsconfig.GetHelloCertificateX509()
assert.NoError(t, err)
require.NoError(t, err)
certPool.AddCert(helloCert)
assert.NotNil(t, certPool)
return &tls.Config{RootCAs: certPool}
@@ -43,8 +44,8 @@ func TestServe(t *testing.T) {
shutdownC := make(chan struct{})
errC := make(chan error)
listener, err := hello.CreateTLSListener("localhost:1111")
assert.NoError(t, err)
defer listener.Close()
require.NoError(t, err)
defer func() { _ = listener.Close() }()
go func() {
errC <- hello.StartHelloWorldServer(&log, listener, shutdownC)
@@ -56,19 +57,21 @@ func TestServe(t *testing.T) {
assert.NotNil(t, tlsConfig)
d := gws.Dialer{TLSClientConfig: tlsConfig}
conn, resp, err := clientConnect(req, &d)
assert.NoError(t, err)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
assert.Equal(t, "websocket", resp.Header.Get("Upgrade"))
for i := 0; i < 1000; i++ {
messageSize := rand.Int()%2048 + 1
clientMessage := make([]byte, messageSize)
// rand.Read always returns len(clientMessage) and a nil error
rand.Read(clientMessage)
messageSize, err := crand.Int(crand.Reader, big.NewInt(2048))
require.NoError(t, err)
clientMessage := make([]byte, int(messageSize.Int64())+1)
_, err = crand.Read(clientMessage)
require.NoError(t, err)
err = conn.WriteMessage(websocket.BinaryFrame, clientMessage)
assert.NoError(t, err)
require.NoError(t, err)
messageType, message, err := conn.ReadMessage()
assert.NoError(t, err)
require.NoError(t, err)
assert.Equal(t, websocket.BinaryFrame, messageType)
assert.Equal(t, clientMessage, message)
}
@@ -97,27 +100,30 @@ func TestWebsocketWrapper(t *testing.T) {
req := testRequest(t, testAddr, nil)
conn, resp, err := clientConnect(req, &d)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
assert.Equal(t, "websocket", resp.Header.Get("Upgrade"))
// Websocket now connected to test server so lets check our wrapper
wrapper := cfwebsocket.GorillaConn{Conn: conn}
buf := make([]byte, 100)
wrapper.Write([]byte("abc"))
_, err = wrapper.Write([]byte("abc"))
require.NoError(t, err)
n, err := wrapper.Read(buf)
require.NoError(t, err)
require.Equal(t, n, 3)
require.Equal(t, 3, n)
require.Equal(t, "abc", string(buf[:n]))
// Test partial read, read 1 of 3 bytes in one read and the other 2 in another read
wrapper.Write([]byte("abc"))
_, err = wrapper.Write([]byte("abc"))
require.NoError(t, err)
buf = buf[:1]
n, err = wrapper.Read(buf)
require.NoError(t, err)
require.Equal(t, n, 1)
require.Equal(t, 1, n)
require.Equal(t, "a", string(buf[:n]))
buf = buf[:cap(buf)]
n, err = wrapper.Read(buf)
require.NoError(t, err)
require.Equal(t, n, 2)
require.Equal(t, 2, n)
require.Equal(t, "bc", string(buf[:n]))
}
+8 -12
View File
@@ -45,9 +45,7 @@ type baseEndpoints struct {
var _ Client = (*RESTClient)(nil)
func NewRESTClient(baseURL, accountTag, zoneTag, authToken, userAgent string, log *zerolog.Logger) (*RESTClient, error) {
if strings.HasSuffix(baseURL, "/") {
baseURL = baseURL[:len(baseURL)-1]
}
baseURL = strings.TrimSuffix(baseURL, "/")
accountLevelEndpoint, err := url.Parse(fmt.Sprintf("%s/accounts/%s/cfd_tunnel", baseURL, accountTag))
if err != nil {
return nil, errors.Wrap(err, "failed to create account level endpoint")
@@ -68,7 +66,7 @@ func NewRESTClient(baseURL, accountTag, zoneTag, authToken, userAgent string, lo
TLSHandshakeTimeout: defaultTimeout,
ResponseHeaderTimeout: defaultTimeout,
}
http2.ConfigureTransport(&httpTransport)
_ = http2.ConfigureTransport(&httpTransport)
return &RESTClient{
baseEndpoints: &baseEndpoints{
accountLevel: *accountLevelEndpoint,
@@ -161,7 +159,6 @@ func fetchExhaustively[T any](requestFn func(int) (*http.Response, error)) ([]*T
if envelope.Pagination.Count < envelope.Pagination.PerPage || len(fullResponse) >= envelope.Pagination.TotalCount {
break
}
}
return fullResponse, nil
}
@@ -179,14 +176,13 @@ func fetchPage[T any](requestFn func(int) (*http.Response, error), page int) (*r
}
var parsedRspBody []*T
return envelope, parsedRspBody, parseResponseBody(envelope, &parsedRspBody)
}
return nil, nil, errors.New(fmt.Sprintf("Failed to fetch page. Server returned: %d", pageResp.StatusCode))
}
type response struct {
Success bool `json:"success,omitempty"`
Errors []apiErr `json:"errors,omitempty"`
Errors []apiError `json:"errors,omitempty"`
Messages []string `json:"messages,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
Pagination Pagination `json:"result_info,omitempty"`
@@ -206,19 +202,19 @@ func (r *response) checkErrors() error {
if len(r.Errors) == 1 {
return r.Errors[0]
}
var messages string
var messagesBuilder strings.Builder
for _, e := range r.Errors {
messages += fmt.Sprintf("%s; ", e)
messagesBuilder.WriteString(fmt.Sprintf("%s; ", e))
}
return fmt.Errorf("API errors: %s", messages)
return fmt.Errorf("API errors: %s", messagesBuilder.String())
}
type apiErr struct {
type apiError struct {
Code json.Number `json:"code,omitempty"`
Message string `json:"message,omitempty"`
}
func (e apiErr) Error() string {
func (e apiError) Error() string {
return fmt.Sprintf("code: %v, reason: %s", e.Code, e.Message)
}
+1 -1
View File
@@ -8,7 +8,7 @@ type TunnelClient interface {
CreateTunnel(name string, tunnelSecret []byte) (*TunnelWithToken, error)
GetTunnel(tunnelID uuid.UUID) (*Tunnel, error)
GetTunnelToken(tunnelID uuid.UUID) (string, error)
GetManagementToken(tunnelID uuid.UUID) (string, error)
GetManagementToken(tunnelID uuid.UUID, resource ManagementResource) (string, error)
DeleteTunnel(tunnelID uuid.UUID, cascade bool) error
ListTunnels(filter *TunnelFilter) ([]*Tunnel, error)
ListActiveClients(tunnelID uuid.UUID) ([]*ActiveClient, error)
+29 -11
View File
@@ -15,6 +15,27 @@ import (
var ErrTunnelNameConflict = errors.New("tunnel with name already exists")
type ManagementResource int
const (
Logs ManagementResource = iota
Admin
HostDetails
)
func (r ManagementResource) String() string {
switch r {
case Logs:
return "logs"
case Admin:
return "admin"
case HostDetails:
return "host_details"
default:
return ""
}
}
type Tunnel struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
@@ -50,10 +71,6 @@ type newTunnel struct {
TunnelSecret []byte `json:"tunnel_secret"`
}
type managementRequest struct {
Resources []string `json:"resources"`
}
type CleanupParams struct {
queryParams url.Values
}
@@ -137,15 +154,16 @@ func (r *RESTClient) GetTunnelToken(tunnelID uuid.UUID) (token string, err error
return "", r.statusCodeToError("get tunnel token", resp)
}
func (r *RESTClient) GetManagementToken(tunnelID uuid.UUID) (token string, err error) {
// managementEndpointPath returns the path segment for a management resource endpoint
func managementEndpointPath(tunnelID uuid.UUID, res ManagementResource) string {
return fmt.Sprintf("%v/management/%s", tunnelID, res.String())
}
func (r *RESTClient) GetManagementToken(tunnelID uuid.UUID, res ManagementResource) (token string, err error) {
endpoint := r.baseEndpoints.accountLevel
endpoint.Path = path.Join(endpoint.Path, fmt.Sprintf("%v/management", tunnelID))
endpoint.Path = path.Join(endpoint.Path, managementEndpointPath(tunnelID, res))
body := &managementRequest{
Resources: []string{"logs"},
}
resp, err := r.sendRequest("POST", endpoint, body)
resp, err := r.sendRequest("POST", endpoint, nil)
if err != nil {
return "", errors.Wrap(err, "REST request failed")
}
+71 -7
View File
@@ -2,7 +2,6 @@ package cfapi
import (
"bytes"
"fmt"
"net"
"reflect"
"strings"
@@ -11,6 +10,7 @@ import (
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
var loc, _ = time.LoadLocation("UTC")
@@ -52,7 +52,6 @@ func Test_unmarshalTunnel(t *testing.T) {
}
func TestUnmarshalTunnelOk(t *testing.T) {
jsonBody := `{"success": true, "result": {"id": "00000000-0000-0000-0000-000000000000","name":"test","created_at":"0001-01-01T00:00:00Z","connections":[]}}`
expected := Tunnel{
ID: uuid.Nil,
@@ -61,12 +60,11 @@ func TestUnmarshalTunnelOk(t *testing.T) {
Connections: []Connection{},
}
actual, err := unmarshalTunnel(bytes.NewReader([]byte(jsonBody)))
assert.NoError(t, err)
assert.Equal(t, &expected, actual)
require.NoError(t, err)
require.Equal(t, &expected, actual)
}
func TestUnmarshalTunnelErr(t *testing.T) {
tests := []string{
`abc`,
`{"success": true, "result": abc}`,
@@ -76,7 +74,73 @@ func TestUnmarshalTunnelErr(t *testing.T) {
for i, test := range tests {
_, err := unmarshalTunnel(bytes.NewReader([]byte(test)))
assert.Error(t, err, fmt.Sprintf("Test #%v failed", i))
assert.Error(t, err, "Test #%v failed", i)
}
}
func TestManagementResource_String(t *testing.T) {
tests := []struct {
name string
resource ManagementResource
want string
}{
{
name: "Logs",
resource: Logs,
want: "logs",
},
{
name: "Admin",
resource: Admin,
want: "admin",
},
{
name: "HostDetails",
resource: HostDetails,
want: "host_details",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, tt.resource.String())
})
}
}
func TestManagementResource_String_Unknown(t *testing.T) {
unknown := ManagementResource(999)
assert.Equal(t, "", unknown.String())
}
func TestManagementEndpointPath(t *testing.T) {
tunnelID := uuid.MustParse("b34cc7ce-925b-46ee-bc23-4cb5c18d8292")
tests := []struct {
name string
resource ManagementResource
want string
}{
{
name: "Logs resource",
resource: Logs,
want: "b34cc7ce-925b-46ee-bc23-4cb5c18d8292/management/logs",
},
{
name: "Admin resource",
resource: Admin,
want: "b34cc7ce-925b-46ee-bc23-4cb5c18d8292/management/admin",
},
{
name: "HostDetails resource",
resource: HostDetails,
want: "b34cc7ce-925b-46ee-bc23-4cb5c18d8292/management/host_details",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := managementEndpointPath(tunnelID, tt.resource)
assert.Equal(t, tt.want, got)
})
}
}
@@ -97,6 +161,6 @@ func TestUnmarshalConnections(t *testing.T) {
}},
}
actual, err := parseConnectionsDetails(bytes.NewReader([]byte(jsonBody)))
assert.NoError(t, err)
require.NoError(t, err)
assert.Equal(t, []*ActiveClient{&expected}, actual)
}
+4
View File
@@ -72,3 +72,7 @@ func (c ConnectionOptionsSnapshot) ConnectionOptions() *pogs.ConnectionOptions {
func (c ConnectionOptionsSnapshot) LogFields(event *zerolog.Event) *zerolog.Event {
return event.Strs("features", c.client.Features)
}
func (c *Config) ConnectionFeaturesSnapshot() features.FeatureSnapshot {
return c.featureSelector.Snapshot()
}
+20
View File
@@ -3,6 +3,7 @@ package access
import (
"errors"
"fmt"
"net"
"net/http"
"net/url"
"strings"
@@ -23,6 +24,24 @@ func parseRequestHeaders(values []string) http.Header {
return headers
}
// bracketBareIPv6 wraps bare IPv6 addresses in a URL with square brackets.
// Go 1.26 tightened net/url parsing to strictly require RFC 3986 bracket syntax
// for IPv6 addresses in URLs. Before Go 1.26, bare forms like "http://::1" were
// accepted; now they are rejected. This function detects bare IPv6 in the host
// portion and brackets it so that url.ParseRequestURI can parse it correctly.
func bracketBareIPv6(input string) string {
prefix := input[:strings.Index(input, "://")+3]
rest := input[len(prefix):]
host := rest
if i := strings.IndexAny(rest, "/?#"); i >= 0 {
host = rest[:i]
}
if net.ParseIP(host) != nil && strings.Contains(host, ":") {
return prefix + "[" + host + "]" + rest[len(host):]
}
return input
}
// parseHostname will attempt to convert a user provided URL string into a string with some light error checking on
// certain expectations from the URL.
// Will convert all HTTP URLs to HTTPS
@@ -33,6 +52,7 @@ func parseURL(input string) (*url.URL, error) {
if !strings.HasPrefix(input, "https://") && !strings.HasPrefix(input, "http://") {
input = fmt.Sprintf("https://%s", input)
}
input = bracketBareIPv6(input)
url, err := url.ParseRequestURI(input)
if err != nil {
return nil, fmt.Errorf("failed to parse as URL: %w", err)
+28 -3
View File
@@ -5,6 +5,7 @@ import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseRequestHeaders(t *testing.T) {
@@ -15,6 +16,30 @@ func TestParseRequestHeaders(t *testing.T) {
assert.Equal(t, "000:000:0:1:asd", values.Get("cf-trace-id"))
}
func TestBracketBareIPv6(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"https://::1", "https://[::1]"},
{"https://::1/path", "https://[::1]/path"},
{"https://::1:8080", "https://[::1:8080]"},
{"https://::1:8080/path", "https://[::1:8080]/path"},
{"https://::1?query=1", "https://[::1]?query=1"}, // query without path
{"https://::1#fragment", "https://[::1]#fragment"}, // fragment without path
{"https://[::1]", "https://[::1]"}, // already bracketed
{"https://[::1]:8080", "https://[::1]:8080"}, // already bracketed with port
{"https://127.0.0.1", "https://127.0.0.1"}, // IPv4 unchanged
{"https://example.com", "https://example.com"}, // hostname unchanged
{"https://example.com:8080", "https://example.com:8080"}, // hostname:port unchanged
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
assert.Equal(t, tt.expected, bracketBareIPv6(tt.input))
})
}
}
func TestParseURL(t *testing.T) {
schemes := []string{
"http://",
@@ -28,8 +53,8 @@ func TestParseURL(t *testing.T) {
{"localhost", "localhost"},
{"127.0.0.1", "127.0.0.1"},
{"127.0.0.1:9090", "127.0.0.1:9090"},
{"::1", "::1"},
{"::1:8080", "::1:8080"},
{"::1", "[::1]"},
{"::1:8080", "[::1:8080]"},
{"[::1]", "[::1]"},
{"[::1]:8080", "[::1]:8080"},
{":8080", ":8080"},
@@ -49,7 +74,7 @@ func TestParseURL(t *testing.T) {
input := fmt.Sprintf("%s%s%s", scheme, host.input, path)
expected := fmt.Sprintf("%s%s%s", "https://", host.expected, path)
url, err := parseURL(input)
assert.NoError(t, err, "input: %s\texpected: %s", input, expected)
require.NoError(t, err, "input: %s\texpected: %s", input, expected)
assert.Equal(t, expected, url.String())
assert.Equal(t, host.expected, url.Host)
assert.Equal(t, "https", url.Scheme)
+57
View File
@@ -1,6 +1,9 @@
package cliutil
import (
"strings"
"github.com/rs/zerolog"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v2/altsrc"
@@ -57,3 +60,57 @@ func ConfigureLoggingFlags(shouldHide bool) []cli.Flag {
FlagLogOutput,
}
}
// LogTable renders lines inside an ASCII table and logs each rendered row.
func LogTable(log *zerolog.Logger, lines []string, title ...string) {
tableTitle := ""
if len(title) > 0 {
tableTitle = title[0]
}
for _, line := range asciiBox(lines, tableTitle, 2) {
if line != "" {
log.Info().Msg(line)
}
}
}
// asciiBox wraps lines in a bordered ASCII box with an optional title row.
func asciiBox(lines []string, title string, padding int) (box []string) {
maxLen := maxLen(lines, title)
spacer := strings.Repeat(" ", padding)
border := "+" + strings.Repeat("-", maxLen+(padding*2)) + "+"
box = append(box, border)
if title != "" {
box = append(box, renderBoxLine(centerLine(title, maxLen), maxLen, spacer))
box = append(box, border)
}
for _, line := range lines {
box = append(box, renderBoxLine(line, maxLen, spacer))
}
box = append(box, border)
return
}
// renderBoxLine pads a single line so it fills the box width.
func renderBoxLine(line string, maxLen int, spacer string) string {
return "|" + spacer + line + strings.Repeat(" ", maxLen-len(line)) + spacer + "|"
}
// centerLine pads line evenly so it is centered within width.
func centerLine(line string, width int) string {
padding := width - len(line)
leftPadding := padding / 2
rightPadding := padding - leftPadding
return strings.Repeat(" ", leftPadding) + line + strings.Repeat(" ", rightPadding)
}
// maxLen returns the longest visible line length including the title.
func maxLen(lines []string, title string) int {
max := len(title)
for _, line := range lines {
if len(line) > max {
max = len(line)
}
}
return max
}
+60
View File
@@ -0,0 +1,60 @@
package cliutil
import (
"bytes"
"encoding/json"
"testing"
"github.com/rs/zerolog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLogTableWithoutTitle(t *testing.T) {
t.Parallel()
lines := captureTableLogs(t, []string{"first", "second"})
assert.Equal(t, []string{
"+----------+",
"| first |",
"| second |",
"+----------+",
}, lines)
}
func TestLogTableWithTitle(t *testing.T) {
t.Parallel()
lines := captureTableLogs(t, []string{"first", "second"}, "TT")
assert.Equal(t, []string{
"+----------+",
"| TT |",
"+----------+",
"| first |",
"| second |",
"+----------+",
}, lines)
}
func captureTableLogs(t *testing.T, lines []string, title ...string) []string {
t.Helper()
var buf bytes.Buffer
logger := zerolog.New(&buf)
LogTable(&logger, lines, title...)
// nolint: prealloc
var messages []string
for _, line := range bytes.Split(bytes.TrimSpace(buf.Bytes()), []byte("\n")) {
var entry struct {
Message string `json:"message"`
}
require.NoError(t, json.Unmarshal(line, &entry))
messages = append(messages, entry.Message)
}
return messages
}
+84
View File
@@ -0,0 +1,84 @@
package cliutil
import (
"errors"
"fmt"
"io"
"os"
"time"
"github.com/google/uuid"
"github.com/mattn/go-colorable"
"github.com/rs/zerolog"
"github.com/urfave/cli/v2"
"github.com/cloudflare/cloudflared/cfapi"
cfdflags "github.com/cloudflare/cloudflared/cmd/cloudflared/flags"
"github.com/cloudflare/cloudflared/credentials"
)
// Error definitions for management token operations
var (
ErrNoTunnelID = errors.New("no tunnel ID provided")
ErrInvalidTunnelID = errors.New("unable to parse provided tunnel id as a valid UUID")
)
// GetManagementToken acquires a management token from Cloudflare API for the specified resource
func GetManagementToken(c *cli.Context, log *zerolog.Logger, res cfapi.ManagementResource, buildInfo *BuildInfo) (string, error) {
userCreds, err := credentials.Read(c.String(cfdflags.OriginCert), log)
if err != nil {
return "", err
}
var apiURL string
if userCreds.IsFEDEndpoint() {
apiURL = credentials.FedRampBaseApiURL
} else {
apiURL = c.String(cfdflags.ApiURL)
}
client, err := userCreds.Client(apiURL, buildInfo.UserAgent(), log)
if err != nil {
return "", err
}
tunnelIDString := c.Args().First()
if tunnelIDString == "" {
return "", ErrNoTunnelID
}
tunnelID, err := uuid.Parse(tunnelIDString)
if err != nil {
return "", fmt.Errorf("%w: %v", ErrInvalidTunnelID, err)
}
token, err := client.GetManagementToken(tunnelID, res)
if err != nil {
return "", err
}
return token, nil
}
// CreateStderrLogger creates a logger that outputs to stderr to avoid interfering with stdout
func CreateStderrLogger(c *cli.Context) *zerolog.Logger {
level, levelErr := zerolog.ParseLevel(c.String(cfdflags.LogLevel))
if levelErr != nil {
level = zerolog.InfoLevel
}
var writer io.Writer
switch c.String(cfdflags.LogFormatOutput) {
case cfdflags.LogFormatOutputValueJSON:
// zerolog by default outputs as JSON
writer = os.Stderr
case cfdflags.LogFormatOutputValueDefault:
// "default" and unset use the same logger output format
fallthrough
default:
writer = zerolog.ConsoleWriter{
Out: colorable.NewColorable(os.Stderr),
TimeFormat: time.RFC3339,
}
}
log := zerolog.New(writer).With().Timestamp().Logger().Level(level)
return &log
}
+71
View File
@@ -1,6 +1,11 @@
package main
import (
"errors"
"fmt"
"os"
"path"
"github.com/rs/zerolog"
"github.com/urfave/cli/v2"
@@ -8,6 +13,71 @@ import (
"github.com/cloudflare/cloudflared/cmd/cloudflared/tunnel"
)
const (
defaultTokenFile = "token"
tokenPerms os.FileMode = 0o600
)
func ensureConfigDirExists(configDir string) error {
if err := os.Mkdir(configDir, 0o755); err != nil { //nolint:gosec // config dir must be traversable by non-root user
if errors.Is(err, os.ErrExist) {
return nil
}
return fmt.Errorf("failed to create config dir at %s: %w", configDir, err)
}
return nil
}
func writeTokenToFile(path string, token string) error {
if _, err := tunnel.ParseToken(token); err != nil {
return cliutil.UsageError("Provided tunnel token is not valid (%s).", err)
}
if err := os.WriteFile(path, []byte(token), tokenPerms); err != nil {
return fmt.Errorf("failed to write token to %s: %w", path, err)
}
// If the token file already existed with unrestrictive perms, os.WriteFile
// above will not update them
if err := os.Chmod(path, tokenPerms); err != nil {
return fmt.Errorf("failed to restrict permissions on token file %s: %w", path, err)
}
return nil
}
func removeTokenFile(configDir string, log *zerolog.Logger) {
tp := tokenPath(configDir)
err := os.Remove(tp)
if err != nil && !errors.Is(err, os.ErrNotExist) {
log.Warn().Msgf("Could not remove service token file at %s: %v", tp, err)
}
}
func buildArgsForTokenFile(configDir string) []string {
return []string{
"tunnel", "run", "--token-file", tokenPath(configDir),
}
}
func tokenPath(configDir string) string {
return path.Join(configDir, defaultTokenFile)
}
func writeTokenToConfigDir(c *cli.Context, configDir string) error {
if err := ensureConfigDirExists(configDir); err != nil {
return err
}
if err := writeTokenToFile(tokenPath(configDir), c.Args().First()); err != nil {
return err
}
return nil
}
// nolint:unused // This function is used by the Windows build, the unused warning when building for Linux and MacOS is spurious
func buildArgsForToken(c *cli.Context, log *zerolog.Logger) ([]string, error) {
token := c.Args().First()
if _, err := tunnel.ParseToken(token); err != nil {
@@ -19,6 +89,7 @@ func buildArgsForToken(c *cli.Context, log *zerolog.Logger) ([]string, error) {
}, nil
}
// nolint:unused // This function is used by the Windows build, the unused warning when building for Linux and MacOS is spurious
func getServiceExtraArgsFromCliArgs(c *cli.Context, log *zerolog.Logger) ([]string, error) {
if c.NArg() > 0 {
// currently, we only support extra args for token
+6
View File
@@ -81,6 +81,9 @@ const (
// EdgeBindAddress is the command line flag to bind to IP address for outgoing connections to Cloudflare Edge
EdgeBindAddress = "edge-bind-address"
// CACert Certificate Authority authenticating connections with Cloudflare's edge network.
CACert = "cacert"
// Force is the command line flag to specify if you wish to force an action
Force = "force"
@@ -120,6 +123,9 @@ const (
// NoAutoUpdate is the command line flag to disable cloudflared from checking for updates
NoAutoUpdate = "no-autoupdate"
// NoPrechecks is the command line flag to skip connectivity pre-checks at startup.
NoPrechecks = "no-prechecks"
// LogLevel is the command line flag for the cloudflared logging level
LogLevel = "loglevel"
+39
View File
@@ -0,0 +1,39 @@
// Package inits provides detection of the init system managing the host
// (systemd, OpenRC, or SysV). It is shared by the service installer and the
// auto-updater so that init-system detection lives in a single place.
//
// The functions are safe to call on any GOOS; on non-Linux platforms they
// report that no Linux init system is in use.
package inits
import (
"os"
"runtime"
)
// IsSystemd reports whether the host is managed by systemd.
func IsSystemd() bool {
_, err := os.Stat("/run/systemd/system")
return err == nil
}
// IsOpenRC reports whether the host is managed by OpenRC.
func IsOpenRC() bool {
for _, path := range []string{"/sbin/openrc-run", "/usr/sbin/openrc-run", "/usr/bin/openrc-run"} {
if _, err := os.Stat(path); err == nil {
return true
}
}
return false
}
// IsSysV reports whether the host relies on a SysV-style init system, i.e. a
// Linux host that is managed by neither systemd nor OpenRC. systemd and OpenRC
// keep the service alive themselves, so only SysV needs the process to restart
// itself after an auto-update.
func IsSysV() bool {
if runtime.GOOS != "linux" {
return false
}
return !IsSystemd() && !IsOpenRC()
}
+150 -41
View File
@@ -3,6 +3,7 @@
package main
import (
"errors"
"fmt"
"io"
"os"
@@ -11,6 +12,7 @@ import (
"github.com/urfave/cli/v2"
"github.com/cloudflare/cloudflared/cmd/cloudflared/cliutil"
"github.com/cloudflare/cloudflared/cmd/cloudflared/inits"
"github.com/cloudflare/cloudflared/cmd/cloudflared/tunnel"
"github.com/cloudflare/cloudflared/config"
"github.com/cloudflare/cloudflared/logger"
@@ -22,8 +24,21 @@ func runApp(app *cli.App, _ chan struct{}) {
Usage: "Manages the cloudflared system service",
Subcommands: []*cli.Command{
{
Name: "install",
Usage: "Install cloudflared as a system service",
Name: "install",
Usage: "Install cloudflared as a system service",
ArgsUsage: "[TOKEN]",
Description: `
Installs cloudflared as a service using the detected init system (e.g., sysv,
systemd, openrc).
A token may optionally be provided. If a token is provided, it will be written
to disk in the service configuration directory and the cloudflared service
configured to use it via the --token-file argument.
If no token is provided, cloudflared will attempt to find a configuration file
with tunnel credentials from a predetermined list of configuration directory
paths. If found, it will use that configuration file and credentials (or error
out if no configuration file with credentials was found).`,
Action: cliutil.ConfiguredAction(installLinuxService),
Flags: []cli.Flag{
noUpdateServiceFlag,
@@ -49,13 +64,14 @@ const (
cloudflaredService = "cloudflared.service"
cloudflaredUpdateService = "cloudflared-update.service"
cloudflaredUpdateTimer = "cloudflared-update.timer"
cloudflaredOpenRCService = "cloudflared"
)
var systemdAllTemplates = map[string]ServiceTemplate{
cloudflaredService: {
Path: fmt.Sprintf("/etc/systemd/system/%s", cloudflaredService),
Content: `[Unit]
Description=cloudflared
Description=Cloudflare Tunnel client
After=network-online.target
Wants=network-online.target
@@ -97,12 +113,12 @@ WantedBy=timers.target
var sysvTemplate = ServiceTemplate{
Path: "/etc/init.d/cloudflared",
FileMode: 0755,
FileMode: 0o755,
// nolint: dupword
Content: `#!/bin/sh
# For RedHat and cousins:
# chkconfig: 2345 99 01
# description: cloudflared
# description: Cloudflare Tunnel client
# processname: {{.Path}}
### BEGIN INIT INFO
# Provides: {{.Path}}
@@ -110,8 +126,8 @@ var sysvTemplate = ServiceTemplate{
# Required-Stop:
# Default-Start: 2 3 4 5
# Default-Stop: 0 1 6
# Short-Description: cloudflared
# Description: cloudflared agent
# Short-Description: Cloudflare Tunnel client
# Description: Cloudflare Tunnel client
### END INIT INFO
name=$(basename $(readlink -f $0))
cmd="{{.Path}} --pidfile /var/run/$name.pid {{ range .ExtraArgs }} {{ . }}{{ end }}"
@@ -186,19 +202,52 @@ exit 0
`,
}
var openrcTemplate = ServiceTemplate{
Path: "/etc/init.d/" + cloudflaredOpenRCService,
FileMode: 0o755,
Content: `#!/sbin/openrc-run
description="Cloudflare Tunnel client"
: "${cloudflared_user:=root}"
command="{{.Path}}"
command_args="{{ range .ExtraArgs }} {{ . }}{{ end }}"
command_user="${cloudflared_user}"
pidfile="/run/${RC_SVCNAME}.pid"
output_log="/var/log/${RC_SVCNAME}.log"
error_log="/var/log/${RC_SVCNAME}.err"
# Use OpenRC's supervisor so the tunnel is restarted on failure.
supervisor="supervise-daemon"
respawn_delay=5
respawn_max=0
depend() {
need net
use dns logger
after net firewall
}
`,
}
var openrcConfTemplate = ServiceTemplate{
Path: "/etc/conf.d/" + cloudflaredOpenRCService,
FileMode: 0o644,
Content: `# Configuration for the cloudflared OpenRC service.
# User the cloudflared daemon runs as. Defaults to root.
#cloudflared_user="cloudflared"
`,
}
var noUpdateServiceFlag = &cli.BoolFlag{
Name: "no-update-service",
Usage: "Disable auto-update of the cloudflared linux service, which restarts the server to upgrade for new versions.",
Value: false,
}
func isSystemd() bool {
if _, err := os.Stat("/run/systemd/system"); err == nil {
return true
}
return false
}
func installLinuxService(c *cli.Context) error {
log := logger.CreateLoggerFromContext(c, logger.EnableTerminalLog)
@@ -210,27 +259,46 @@ func installLinuxService(c *cli.Context) error {
Path: etPath,
}
// Check if the "no update flag" is set
autoUpdate := !c.IsSet(noUpdateServiceFlag.Name)
var extraArgsFunc func(c *cli.Context, log *zerolog.Logger) ([]string, error)
var extraArgs []string
if c.NArg() == 0 {
extraArgsFunc = buildArgsForConfig
// If passed no arguments e.g., "$ cloudflared service install",
// install the service using the detected config file (or error-out if
// no config exists).
if extraArgs, err = buildArgsForConfig(c, log); err != nil {
return err
}
} else {
extraArgsFunc = buildArgsForToken
}
// If passed one argument e.g., "$ cloudflared service install <token>"
// write the token to the config directory and install the service
// using --token-file pointing to that file. This is the quick setup
// the tunnel UI suggests.
extraArgs, err := extraArgsFunc(c, log)
if err != nil {
return err
// Ensure token file is removed if install fails
defer func() {
if err != nil {
removeTokenFile(serviceConfigDir, log)
}
}()
if err = writeTokenToConfigDir(c, serviceConfigDir); err != nil {
return fmt.Errorf("could not write token to configuration directory: %w", err)
}
extraArgs = buildArgsForTokenFile(serviceConfigDir)
}
templateArgs.ExtraArgs = extraArgs
// Check if the "no update flag" is set
autoUpdate := !c.IsSet(noUpdateServiceFlag.Name)
switch {
case isSystemd():
case inits.IsSystemd():
log.Info().Msgf("Using Systemd")
err = installSystemd(&templateArgs, autoUpdate, log)
case inits.IsOpenRC():
log.Info().Msgf("Using OpenRC")
err = installOpenRC(&templateArgs, autoUpdate)
default:
log.Info().Msgf("Using SysV")
err = installSysv(&templateArgs, autoUpdate, log)
@@ -258,14 +326,11 @@ func buildArgsForConfig(c *cli.Context, log *zerolog.Logger) ([]string, error) {
return err == nil && val != ""
}
if src.TunnelID == "" || !configPresent(tunnel.CredFileFlag) {
return nil, fmt.Errorf(`Configuration file %s must contain entries for the tunnel to run and its associated credentials:
tunnel: TUNNEL-UUID
credentials-file: CREDENTIALS-FILE
`, src.Source())
return nil, fmt.Errorf("configuration file %s must contain entries for the tunnel to run and its associated credentials (tunnel: TUNNEL-UUID, credentials-file: CREDENTIALS-FILE)", src.Source())
}
if src.Source() != serviceConfigPath {
if exists, err := config.FileExists(serviceConfigPath); err != nil || exists {
return nil, fmt.Errorf("Possible conflicting configuration in %[1]s and %[2]s. Either remove %[2]s or run `cloudflared --config %[2]s service install`", src.Source(), serviceConfigPath)
return nil, fmt.Errorf("possible conflicting configuration in %[1]s and %[2]s. Either remove %[2]s or run `cloudflared --config %[2]s service install`", src.Source(), serviceConfigPath)
}
if err := copyFile(src.Source(), serviceConfigPath); err != nil {
@@ -348,22 +413,51 @@ func installSysv(templateArgs *ServiceTemplateArgs, autoUpdate bool, log *zerolo
return runCommand("service", "cloudflared", "start")
}
func installOpenRC(templateArgs *ServiceTemplateArgs, autoUpdate bool) error {
if autoUpdate {
templateArgs.ExtraArgs = append([]string{"--autoupdate-freq", "24h0m0s"}, templateArgs.ExtraArgs...)
} else {
templateArgs.ExtraArgs = append([]string{"--no-autoupdate"}, templateArgs.ExtraArgs...)
}
if err := openrcConfTemplate.Generate(templateArgs); err != nil {
return fmt.Errorf("error generating OpenRC conf.d template: %w", err)
}
if err := openrcTemplate.Generate(templateArgs); err != nil {
return fmt.Errorf("error generating OpenRC service template: %w", err)
}
if err := runCommand("rc-update", "add", cloudflaredOpenRCService, "default"); err != nil {
return fmt.Errorf("rc-update add %s default: %w", cloudflaredOpenRCService, err)
}
if err := runCommand("rc-service", cloudflaredOpenRCService, "start"); err != nil {
return fmt.Errorf("rc-service %s start: %w", cloudflaredOpenRCService, err)
}
return nil
}
func uninstallLinuxService(c *cli.Context) error {
log := logger.CreateLoggerFromContext(c, logger.EnableTerminalLog)
var err error
switch {
case isSystemd():
case inits.IsSystemd():
log.Info().Msg("Using Systemd")
err = uninstallSystemd(log)
case inits.IsOpenRC():
log.Info().Msg("Using OpenRC")
err = uninstallOpenRC(log)
default:
log.Info().Msg("Using SysV")
err = uninstallSysv(log)
}
removeTokenFile(serviceConfigDir, log)
if err == nil {
log.Info().Msg("Linux service for cloudflared uninstalled successfully")
}
return err
}
@@ -371,7 +465,11 @@ func uninstallSystemd(log *zerolog.Logger) error {
// Get only the installed services
installedServices := make(map[string]ServiceTemplate)
for serviceName, serviceTemplate := range systemdAllTemplates {
if err := runCommand("systemctl", "list-units", "--all", "|", "grep", serviceName); err == nil {
path, err := serviceTemplate.ResolvePath()
if err != nil {
return fmt.Errorf("error resolving path for service %q: %w", serviceName, err)
}
if _, err := os.Stat(path); err == nil {
installedServices[serviceName] = serviceTemplate
} else {
log.Info().Msgf("Service '%s' not installed, skipping its uninstall", serviceName)
@@ -431,28 +529,39 @@ func uninstallSysv(log *zerolog.Logger) error {
return nil
}
func ensureConfigDirExists(configDir string) error {
ok, err := config.FileExists(configDir)
if !ok && err == nil {
err = os.Mkdir(configDir, 0755)
func uninstallOpenRC(log *zerolog.Logger) error {
if err := runCommand("rc-service", cloudflaredOpenRCService, "stop"); err != nil {
log.Warn().Err(err).Msg("could not stop cloudflared OpenRC service, continuing uninstall")
}
return err
if err := runCommand("rc-update", "del", cloudflaredOpenRCService, "default"); err != nil {
log.Warn().Err(err).Msg("could not remove cloudflared from the default runlevel, continuing uninstall")
}
for _, template := range []ServiceTemplate{openrcTemplate, openrcConfTemplate} {
path, err := template.ResolvePath()
if err != nil {
return fmt.Errorf("error resolving OpenRC template path: %w", err)
}
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("error removing %s: %w", path, err)
}
}
return nil
}
func copyFile(src, dest string) error {
srcFile, err := os.Open(src)
srcFile, err := os.Open(src) //nolint:gosec // operator-provided service config path
if err != nil {
return err
}
defer srcFile.Close()
defer func() { _ = srcFile.Close() }()
destFile, err := os.Create(dest)
destFile, err := os.Create(dest) //nolint:gosec // operator-provided service config path
if err != nil {
return err
}
ok := false
defer func() {
destFile.Close()
_ = destFile.Close()
if !ok {
_ = os.Remove(dest)
}
+80 -40
View File
@@ -5,6 +5,7 @@ package main
import (
"fmt"
"os"
"path"
homedir "github.com/mitchellh/go-homedir"
"github.com/pkg/errors"
@@ -24,8 +25,19 @@ func runApp(app *cli.App, _ chan struct{}) {
Usage: "Manages the cloudflared launch agent",
Subcommands: []*cli.Command{
{
Name: "install",
Usage: "Install cloudflared as an user launch agent",
Name: "install",
Usage: "Install cloudflared as an user launch agent",
ArgsUsage: "[TOKEN]",
Description: `
Installs cloudflared as a launchd-managed service.
A token may optionally be provided. If a token is provided, it will be written
to disk in the service configuration directory and the cloudflared service
configured to use it via the --token-file argument.
If no token is provided, cloudflared will run without the --token-file argument,
causing it to look for credentials in a configuration file upon startup.`,
Action: cliutil.ConfiguredAction(installLaunchd),
},
{
@@ -76,38 +88,43 @@ func isRootUser() bool {
return os.Geteuid() == 0
}
func installPath() (string, error) {
// User is root, use /Library/LaunchDaemons instead of home directory
func resolveLibraryPath(subPath, fileName string) (string, error) {
// We use the system-wide /Library/... instead of ~/Library/... if the user is root
if isRootUser() {
return fmt.Sprintf("/Library/LaunchDaemons/%s.plist", launchdIdentifier), nil
return path.Join("/Library", subPath, fileName), nil
}
userHomeDir, err := userHomeDir()
// This returns the home dir of the executing user using OS-specific method
// for discovering the home dir. It's not recommended to call this when the
// user has root permission as $HOME depends on what options the user uses
// with sudo.
userHomeDir, err := homedir.Dir()
if err != nil {
return "", err
return "", errors.Wrap(err, "Cannot determine home directory for the user")
}
return fmt.Sprintf("%s/Library/LaunchAgents/%s.plist", userHomeDir, launchdIdentifier), nil
return path.Join(userHomeDir, "Library", subPath, fileName), nil
}
// For docs on these subdirectories, see:
// https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/FileSystemProgrammingGuide/MacOSXDirectories/MacOSXDirectories.html
func installPath() (string, error) {
subpath := "LaunchAgents"
if isRootUser() {
subpath = "LaunchDaemons"
}
return resolveLibraryPath(subpath, launchdIdentifier+".plist")
}
func stdoutPath() (string, error) {
if isRootUser() {
return fmt.Sprintf("/Library/Logs/%s.out.log", launchdIdentifier), nil
}
userHomeDir, err := userHomeDir()
if err != nil {
return "", err
}
return fmt.Sprintf("%s/Library/Logs/%s.out.log", userHomeDir, launchdIdentifier), nil
return resolveLibraryPath("Logs", launchdIdentifier+".out.log")
}
func stderrPath() (string, error) {
if isRootUser() {
return fmt.Sprintf("/Library/Logs/%s.err.log", launchdIdentifier), nil
}
userHomeDir, err := userHomeDir()
if err != nil {
return "", err
}
return fmt.Sprintf("%s/Library/Logs/%s.err.log", userHomeDir, launchdIdentifier), nil
return resolveLibraryPath("Logs", launchdIdentifier+".err.log")
}
func configPath() (string, error) {
return resolveLibraryPath("Application Support", launchdIdentifier)
}
func installLaunchd(c *cli.Context) error {
@@ -125,18 +142,45 @@ func installLaunchd(c *cli.Context) error {
etPath, err := os.Executable()
if err != nil {
log.Err(err).Msg("Error determining executable path")
return fmt.Errorf("Error determining executable path: %v", err)
return fmt.Errorf("Error determining executable path: %w", err)
}
installPath, err := installPath()
if err != nil {
log.Err(err).Msg("Error determining install path")
return errors.Wrap(err, "Error determining install path")
}
extraArgs, err := getServiceExtraArgsFromCliArgs(c, log)
if err != nil {
errMsg := "Unable to determine extra arguments for launch daemon"
log.Err(err).Msg(errMsg)
return errors.Wrap(err, errMsg)
var extraArgs []string
if c.NArg() > 0 {
// The service has been installed using a token e.g.,
// $ cloudflared service install <token>
//
// Write the token file to a config directory so we can start the
// daemon with --token-file
// Don't use :=, if we did so we would create a new err variable and
// shadow the outer one, causing the defer below to not have access to
// the outer err
var cp string
cp, err = configPath()
if err != nil {
log.Err(err).Msg("Error determining path to config directory")
return err
}
// Ensure token file is removed if install fails at any point from now
// on
defer func() {
if err != nil {
removeTokenFile(cp, log)
}
}()
if err = writeTokenToConfigDir(c, cp); err != nil {
return fmt.Errorf("could not write token to configuration directory: %w", err)
}
extraArgs = buildArgsForTokenFile(cp)
}
stdoutPath, err := stdoutPath()
@@ -206,17 +250,13 @@ func uninstallLaunchd(c *cli.Context) error {
if err == nil {
log.Info().Msg("Launchd for cloudflared was uninstalled successfully")
}
return err
}
func userHomeDir() (string, error) {
// This returns the home dir of the executing user using OS-specific method
// for discovering the home dir. It's not recommended to call this function
// when the user has root permission as $HOME depends on what options the user
// use with sudo.
homeDir, err := homedir.Dir()
cp, err := configPath()
if err != nil {
return "", errors.Wrap(err, "Cannot determine home directory for the user")
log.Err(err).Msg("error determining path to config directory, not removing token file")
return err
}
return homeDir, nil
removeTokenFile(cp, log)
return nil
}
+3
View File
@@ -13,6 +13,7 @@ import (
"github.com/cloudflare/cloudflared/cmd/cloudflared/access"
"github.com/cloudflare/cloudflared/cmd/cloudflared/cliutil"
cfdflags "github.com/cloudflare/cloudflared/cmd/cloudflared/flags"
"github.com/cloudflare/cloudflared/cmd/cloudflared/management"
"github.com/cloudflare/cloudflared/cmd/cloudflared/proxydns"
"github.com/cloudflare/cloudflared/cmd/cloudflared/tail"
"github.com/cloudflare/cloudflared/cmd/cloudflared/tunnel"
@@ -91,6 +92,7 @@ func main() {
tracing.Init(Version)
token.Init(Version)
tail.Init(bInfo)
management.Init(bInfo)
runApp(app, graceShutdownC)
}
@@ -152,6 +154,7 @@ To determine if an update happened in a script, check for error code 11.`,
cmds = append(cmds, proxydns.Command()) // removed feature, only here for error message
cmds = append(cmds, access.Commands()...)
cmds = append(cmds, tail.Command())
cmds = append(cmds, management.Command())
return cmds
}
+105
View File
@@ -0,0 +1,105 @@
package management
import (
"encoding/json"
"fmt"
"os"
"github.com/urfave/cli/v2"
"github.com/cloudflare/cloudflared/cfapi"
"github.com/cloudflare/cloudflared/cmd/cloudflared/cliutil"
cfdflags "github.com/cloudflare/cloudflared/cmd/cloudflared/flags"
"github.com/cloudflare/cloudflared/credentials"
)
var buildInfo *cliutil.BuildInfo
// Init initializes the management package with build info
func Init(bi *cliutil.BuildInfo) {
buildInfo = bi
}
// Command returns the management command with its subcommands
func Command() *cli.Command {
return &cli.Command{
Name: "management",
Usage: "Monitor cloudflared tunnels via management API",
Category: "Management",
Hidden: true,
Subcommands: []*cli.Command{
buildTokenSubcommand(),
},
}
}
// buildTokenSubcommand creates the token subcommand
func buildTokenSubcommand() *cli.Command {
return &cli.Command{
Name: "token",
Action: cliutil.ConfiguredAction(tokenCommand),
Usage: "Get management access jwt for a specific resource",
UsageText: "cloudflared management token --resource <resource> TUNNEL_ID",
Description: "Get management access jwt for a tunnel with specified resource permissions (logs, admin, host_details)",
Hidden: true,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "resource",
Usage: "Resource type for token permissions: logs, admin, or host_details",
Required: true,
},
&cli.StringFlag{
Name: cfdflags.OriginCert,
Usage: "Path to the certificate generated for your origin when you run cloudflared login.",
EnvVars: []string{"TUNNEL_ORIGIN_CERT"},
Value: credentials.FindDefaultOriginCertPath(),
},
&cli.StringFlag{
Name: cfdflags.LogLevel,
Value: "info",
Usage: "Application logging level {debug, info, warn, error, fatal}",
EnvVars: []string{"TUNNEL_LOGLEVEL"},
},
cliutil.FlagLogOutput,
},
}
}
// tokenCommand handles the token subcommand execution
func tokenCommand(c *cli.Context) error {
log := cliutil.CreateStderrLogger(c)
// Parse and validate resource flag
resourceStr := c.String("resource")
resource, err := parseResource(resourceStr)
if err != nil {
return fmt.Errorf("invalid resource '%s': %w", resourceStr, err)
}
// Get management token
token, err := cliutil.GetManagementToken(c, log, resource, buildInfo)
if err != nil {
return err
}
// Output JSON to stdout
tokenResponse := struct {
Token string `json:"token"`
}{Token: token}
return json.NewEncoder(os.Stdout).Encode(tokenResponse)
}
// parseResource converts resource string to ManagementResource enum
func parseResource(resource string) (cfapi.ManagementResource, error) {
switch resource {
case "logs":
return cfapi.Logs, nil
case "admin":
return cfapi.Admin, nil
case "host_details":
return cfapi.HostDetails, nil
default:
return 0, fmt.Errorf("must be one of: logs, admin, host_details")
}
}
+71
View File
@@ -0,0 +1,71 @@
package management
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/cloudflare/cloudflared/cfapi"
)
func TestParseResource_ValidResources(t *testing.T) {
t.Parallel()
tests := []struct {
input string
expected cfapi.ManagementResource
}{
{"logs", cfapi.Logs},
{"admin", cfapi.Admin},
{"host_details", cfapi.HostDetails},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
t.Parallel()
result, err := parseResource(tt.input)
require.NoError(t, err)
assert.Equal(t, tt.expected, result)
})
}
}
func TestParseResource_InvalidResource(t *testing.T) {
t.Parallel()
invalid := []string{"invalid", "LOGS", "Admin", "", "metrics", "host-details"}
for _, input := range invalid {
t.Run(input, func(t *testing.T) {
t.Parallel()
_, err := parseResource(input)
require.Error(t, err)
assert.Contains(t, err.Error(), "must be one of")
})
}
}
func TestCommandStructure(t *testing.T) {
t.Parallel()
cmd := Command()
assert.Equal(t, "management", cmd.Name)
assert.True(t, cmd.Hidden)
assert.Len(t, cmd.Subcommands, 1)
tokenCmd := cmd.Subcommands[0]
assert.Equal(t, "token", tokenCmd.Name)
assert.True(t, tokenCmd.Hidden)
// Verify required flags exist
var hasResourceFlag bool
for _, flag := range tokenCmd.Flags {
if flag.Names()[0] == "resource" {
hasResourceFlag = true
break
}
}
assert.True(t, hasResourceFlag, "token command should have --resource flag")
}
+2 -2
View File
@@ -10,7 +10,7 @@ import (
"github.com/cloudflare/cloudflared/logger"
)
const removedMessage = "dns-proxy feature is not supported, since version 2026.2.0"
const removedMessage = "dns-proxy feature is no longer supported"
func Command() *cli.Command {
return &cli.Command{
@@ -24,7 +24,7 @@ func Command() *cli.Command {
func Run(c *cli.Context) error {
log := logger.CreateLoggerFromContext(c, logger.EnableTerminalLog)
err := errors.New(removedMessage)
log.Err(err).Msg("DNS Proxy is no longer supported")
log.Error().Msg("DNS Proxy is no longer supported since version 2026.2.0 (https://developers.cloudflare.com/changelog/2025-11-11-cloudflared-proxy-dns/). As an alternative consider using https://developers.cloudflare.com/1.1.1.1/encryption/dns-over-https/dns-over-https-client/")
return err
}
+7 -69
View File
@@ -4,7 +4,6 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
@@ -13,11 +12,11 @@ import (
"time"
"github.com/google/uuid"
"github.com/mattn/go-colorable"
"github.com/rs/zerolog"
"github.com/urfave/cli/v2"
"nhooyr.io/websocket"
"github.com/cloudflare/cloudflared/cfapi"
"github.com/cloudflare/cloudflared/cmd/cloudflared/cliutil"
cfdflags "github.com/cloudflare/cloudflared/cmd/cloudflared/flags"
"github.com/cloudflare/cloudflared/credentials"
@@ -50,9 +49,9 @@ func buildTailManagementTokenSubcommand() *cli.Command {
}
func managementTokenCommand(c *cli.Context) error {
log := createLogger(c)
log := cliutil.CreateStderrLogger(c)
token, err := getManagementToken(c, log)
token, err := cliutil.GetManagementToken(c, log, cfapi.Logs, buildInfo)
if err != nil {
return err
}
@@ -161,31 +160,6 @@ func handleValidationError(resp *http.Response, log *zerolog.Logger) {
}
}
// logger will be created to emit only against the os.Stderr as to not obstruct with normal output from
// management requests
func createLogger(c *cli.Context) *zerolog.Logger {
level, levelErr := zerolog.ParseLevel(c.String(cfdflags.LogLevel))
if levelErr != nil {
level = zerolog.InfoLevel
}
var writer io.Writer
switch c.String(cfdflags.LogFormatOutput) {
case cfdflags.LogFormatOutputValueJSON:
// zerolog by default outputs as JSON
writer = os.Stderr
case cfdflags.LogFormatOutputValueDefault:
// "default" and unset use the same logger output format
fallthrough
default:
writer = zerolog.ConsoleWriter{
Out: colorable.NewColorable(os.Stderr),
TimeFormat: time.RFC3339,
}
}
log := zerolog.New(writer).With().Timestamp().Logger().Level(level)
return &log
}
// parseFilters will attempt to parse provided filters to send to with the EventStartStreaming
func parseFilters(c *cli.Context) (*management.StreamingFilters, error) {
var level *management.LogLevel
@@ -230,49 +204,13 @@ func parseFilters(c *cli.Context) (*management.StreamingFilters, error) {
}, nil
}
// getManagementToken will make a call to the Cloudflare API to acquire a management token for the requested tunnel.
func getManagementToken(c *cli.Context, log *zerolog.Logger) (string, error) {
userCreds, err := credentials.Read(c.String(cfdflags.OriginCert), log)
if err != nil {
return "", err
}
var apiURL string
if userCreds.IsFEDEndpoint() {
apiURL = credentials.FedRampBaseApiURL
} else {
apiURL = c.String(cfdflags.ApiURL)
}
client, err := userCreds.Client(apiURL, buildInfo.UserAgent(), log)
if err != nil {
return "", err
}
tunnelIDString := c.Args().First()
if tunnelIDString == "" {
return "", errors.New("no tunnel ID provided")
}
tunnelID, err := uuid.Parse(tunnelIDString)
if err != nil {
return "", errors.New("unable to parse provided tunnel id as a valid UUID")
}
token, err := client.GetManagementToken(tunnelID)
if err != nil {
return "", err
}
return token, nil
}
// buildURL will build the management url to contain the required query parameters to authenticate the request.
func buildURL(c *cli.Context, log *zerolog.Logger) (url.URL, error) {
func buildURL(c *cli.Context, log *zerolog.Logger, res cfapi.ManagementResource) (url.URL, error) {
var err error
token := c.String("token")
if token == "" {
token, err = getManagementToken(c, log)
token, err = cliutil.GetManagementToken(c, log, res, buildInfo)
if err != nil {
return url.URL{}, fmt.Errorf("unable to acquire management token for requested tunnel id: %w", err)
}
@@ -323,7 +261,7 @@ func printJSON(log *management.Log, logger *zerolog.Logger) {
// Run implements a foreground runner
func Run(c *cli.Context) error {
log := createLogger(c)
log := cliutil.CreateStderrLogger(c)
signals := make(chan os.Signal, 10)
signal.Notify(signals, syscall.SIGTERM, syscall.SIGINT)
@@ -345,7 +283,7 @@ func Run(c *cli.Context) error {
return nil
}
u, err := buildURL(c, log)
u, err := buildURL(c, log, cfapi.Logs)
if err != nil {
log.Err(err).Msg("unable to construct management request URL")
return nil
+80 -17
View File
@@ -4,6 +4,7 @@ import (
"bufio"
"context"
"fmt"
"net"
"net/url"
"os"
"path/filepath"
@@ -31,11 +32,13 @@ import (
"github.com/cloudflare/cloudflared/credentials"
"github.com/cloudflare/cloudflared/diagnostic"
"github.com/cloudflare/cloudflared/edgediscovery"
"github.com/cloudflare/cloudflared/edgediscovery/allregions"
"github.com/cloudflare/cloudflared/ingress"
"github.com/cloudflare/cloudflared/logger"
"github.com/cloudflare/cloudflared/management"
"github.com/cloudflare/cloudflared/metrics"
"github.com/cloudflare/cloudflared/orchestration"
"github.com/cloudflare/cloudflared/prechecks"
"github.com/cloudflare/cloudflared/signal"
"github.com/cloudflare/cloudflared/supervisor"
"github.com/cloudflare/cloudflared/tlsconfig"
@@ -44,6 +47,7 @@ import (
)
const (
//nolint:gosec // This is the Sentry DSN for cloudflared which is safe to be public
sentryDSN = "https://56a9c9fa5c364ab28f34b14f35ea0f1b:3e8827f6f9f740738eb11138f7bebb68@sentry.io/189878"
LogFieldCommand = "command"
@@ -76,6 +80,7 @@ var (
"config",
cfdflags.AutoUpdateFreq,
cfdflags.NoAutoUpdate,
cfdflags.NoPrechecks,
cfdflags.Metrics,
"pidfile",
"url",
@@ -230,7 +235,7 @@ func TunnelCommand(c *cli.Context) error {
return err
}
// Run a adhoc named tunnel
// Run an adhoc named tunnel
// Allows for the creation, routing (optional), and startup of a tunnel in one command
// --name required
// --url or --hello-world required
@@ -240,8 +245,8 @@ func TunnelCommand(c *cli.Context) error {
if err != nil {
return errors.Wrap(err, "Invalid hostname provided")
}
url := c.String("url")
if url == hostname && url != "" && hostname != "" {
tunnelURL := c.String("url")
if tunnelURL == hostname && tunnelURL != "" && hostname != "" {
return fmt.Errorf("hostname and url shouldn't match. See --help for more information")
}
@@ -257,7 +262,7 @@ func TunnelCommand(c *cli.Context) error {
// If user provides a config, check to see if they meant to use `tunnel run` instead
if ref := config.GetConfiguration().TunnelID; ref != "" {
return fmt.Errorf("Use `cloudflared tunnel run` to start tunnel %s", ref)
return fmt.Errorf("use `cloudflared tunnel run` to start tunnel %s", ref)
}
// Classic tunnel usage is no longer supported
@@ -345,12 +350,14 @@ func StartServer(
traceLog.Err(err).Msg("Failed to close temporary trace output file")
}
traceOutputFilepath := c.String(cfdflags.TraceOutput)
//nolint:gosec // File path is safe because it is explicitly provided by the user via the --trace-output flag
if err := os.Rename(tmpTraceFile.Name(), traceOutputFilepath); err != nil {
traceLog.
Err(err).
Str(LogFieldTraceOutputFilepath, traceOutputFilepath).
Msg("Failed to rename temporary trace output file")
} else {
//nolint:gosec // File path is safe, since it is created by os.CreateTemp
err := os.Remove(tmpTraceFile.Name())
if err != nil {
traceLog.Err(err).Msg("Failed to remove the temporary trace file")
@@ -368,7 +375,7 @@ func StartServer(
info.Log(log)
logClientOptions(c, log)
// this context drives the server, when it's cancelled tunnel and all other components (origins, dns, etc...) should stop
// this context drives the server, when it's canceled tunnel and all other components (origins, dns, etc...) should stop
ctx, cancel := context.WithCancel(c.Context)
defer cancel()
@@ -410,6 +417,13 @@ func StartServer(
}
connectorID := tunnelConfig.ClientConfig.ConnectorID
// Run connectivity pre-checks for cloudflared. This runs in a separate
// goroutine, as we want to keep initializing cloudflared while prechecks
// are running. Prechecks are controlled via DNS flag for remote kill-switch capability.
if !tunnelConfig.ClientConfig.ConnectionFeaturesSnapshot().SkipPrechecks && !c.Bool(cfdflags.NoPrechecks) {
go runPrechecks(c, log, tunnelConfig.Region)
}
// Disable ICMP packet routing for quick tunnels
if quickTunnelURL != "" {
tunnelConfig.ICMPRouterServer = nil
@@ -451,7 +465,7 @@ func StartServer(
return errors.Wrap(err, "Error opening metrics server listener")
}
defer metricsListener.Close()
defer func() { _ = metricsListener.Close() }()
wg.Add(1)
go func() {
@@ -509,6 +523,42 @@ func StartServer(
return waitToShutdown(&wg, cancel, errC, graceShutdownC, gracePeriod, log)
}
// runPrechecks executes connectivity pre-checks and logs the results.
// Pre-checks are diagnostic only and do not gate tunnel startup.
func runPrechecks(c *cli.Context, log *zerolog.Logger, region string) {
ipVersion := allregions.Auto
if ipVersionStr := c.String(cfdflags.EdgeIpVersion); ipVersionStr != "" {
parsedVersion, err := parseConfigIPVersion(ipVersionStr)
if err == nil {
ipVersion = parsedVersion
} else {
log.Warn().Str("edgeIpVersion", ipVersionStr).Err(err).Msg("Invalid edge-ip-version value, using auto")
}
}
cfg := prechecks.Config{
Region: region,
IPVersion: ipVersion,
EdgeAddrs: c.StringSlice(cfdflags.Edge),
ProtocolOverride: c.String(cfdflags.Protocol),
}
dialers := prechecks.RunDialers{
DNSResolver: &prechecks.EdgeDNSResolver{Log: log},
TCPDialer: &prechecks.EdgeTCPDialer{},
QUICDialer: &prechecks.EdgeQUICDialer{},
ManagementDialer: &prechecks.NetManagementDialer{Dialer: net.Dialer{}},
}
report := prechecks.Run(c.Context, c.String(cfdflags.CACert), cfg, log, dialers)
// Output the human-readable table
cliutil.LogTable(log, report.String(), "CONNECTIVITY PRE-CHECKS")
// Also log structured results for log aggregation
report.LogEvent(log)
}
func waitToShutdown(wg *sync.WaitGroup,
cancelServerContext func(),
errC <-chan error,
@@ -565,13 +615,14 @@ func writePidFile(waitForSignal *signal.Signal, pidPathname string, log *zerolog
log.Err(err).Str(LogFieldPIDPathname, pidPathname).Msg("Unable to expand the path, try to use absolute path in --pidfile")
return
}
file, err := os.Create(expandedPath)
cleanPath := filepath.Clean(expandedPath)
file, err := os.Create(cleanPath)
if err != nil {
log.Err(err).Str(LogFieldExpandedPath, expandedPath).Msg("Unable to write pid")
return
}
defer file.Close()
fmt.Fprintf(file, "%d", os.Getpid())
defer func() { _ = file.Close() }()
_, _ = fmt.Fprintf(file, "%d", os.Getpid())
}
func hostnameFromURI(uri string) string {
@@ -627,7 +678,7 @@ func tunnelFlags(shouldHide bool) []cli.Flag {
Name: cfdflags.EdgeIpVersion,
Usage: "Cloudflare Edge IP address version to connect with. {4, 6, auto}",
EnvVars: []string{"TUNNEL_EDGE_IP_VERSION"},
Value: "4",
Value: "auto",
Hidden: false,
}),
altsrc.NewStringFlag(&cli.StringFlag{
@@ -637,7 +688,7 @@ func tunnelFlags(shouldHide bool) []cli.Flag {
Hidden: false,
}),
altsrc.NewStringFlag(&cli.StringFlag{
Name: tlsconfig.CaCertFlag,
Name: cfdflags.CACert,
Usage: "Certificate Authority authenticating connections with Cloudflare's edge network.",
EnvVars: []string{"TUNNEL_CACERT"},
Hidden: true,
@@ -877,6 +928,13 @@ func configureCloudflaredFlags(shouldHide bool) []cli.Flag {
Value: false,
Hidden: shouldHide,
}),
altsrc.NewBoolFlag(&cli.BoolFlag{
Name: cfdflags.NoPrechecks,
Usage: "Skip connectivity pre-checks at startup.",
EnvVars: []string{"TUNNEL_NO_PRECHECKS"},
Value: false,
Hidden: shouldHide,
}),
altsrc.NewStringFlag(&cli.StringFlag{
Name: cfdflags.Metrics,
Value: metrics.GetMetricsDefaultAddress(metrics.Runtime),
@@ -900,6 +958,7 @@ and virtualized host network stacks from each other`,
}
func configureProxyFlags(shouldHide bool) []cli.Flag {
//nolint: prealloc
flags := []cli.Flag{
altsrc.NewStringFlag(&cli.StringFlag{
Name: "url",
@@ -1135,6 +1194,12 @@ func sshFlags(shouldHide bool) []cli.Flag {
}
func stdinControl(reconnectCh chan supervisor.ReconnectSignal, log *zerolog.Logger) {
helpStr := strings.Join([]string{
"Supported command:",
"reconnect [delay]",
"- restarts one randomly chosen connection with optional delay before reconnect\n",
}, "\n")
for {
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
@@ -1143,7 +1208,7 @@ func stdinControl(reconnectCh chan supervisor.ReconnectSignal, log *zerolog.Logg
switch parts[0] {
case "":
break
continue
case "reconnect":
var reconnect supervisor.ReconnectSignal
if len(parts) > 1 {
@@ -1155,13 +1220,11 @@ func stdinControl(reconnectCh chan supervisor.ReconnectSignal, log *zerolog.Logg
}
log.Info().Msgf("Sending %+v", reconnect)
reconnectCh <- reconnect
case "help":
log.Info().Msg(helpStr)
default:
log.Info().Str(LogFieldCommand, command).Msg("Unknown command")
fallthrough
case "help":
log.Info().Msg(`Supported command:
reconnect [delay]
- restarts one randomly chosen connection with optional delay before reconnect`)
log.Info().Msg(helpStr)
}
}
}
+6 -15
View File
@@ -140,23 +140,13 @@ func prepareTunnelConfig(
}
tags = append(tags, pogs.Tag{Name: "ID", Value: clientConfig.ConnectorID.String()})
clientFeatures := featureSelector.Snapshot()
pqMode := clientFeatures.PostQuantum
if pqMode == features.PostQuantumStrict {
// Error if the user tries to force a non-quic transport protocol
if transportProtocol != connection.AutoSelectFlag && transportProtocol != connection.QUIC.String() {
return nil, nil, fmt.Errorf("post-quantum is only supported with the quic transport")
}
transportProtocol = connection.QUIC.String()
}
cfg := config.GetConfiguration()
ingressRules, err := ingress.ParseIngressFromConfigAndCLI(cfg, c, log)
if err != nil {
return nil, nil, err
}
protocolSelector, err := connection.NewProtocolSelector(transportProtocol, namedTunnel.Credentials.AccountTag, c.IsSet(TunnelTokenFlag), isPostQuantumEnforced, edgediscovery.ProtocolPercentage, connection.ResolveTTL, log)
protocolSelector, err := connection.NewProtocolSelector(transportProtocol, namedTunnel.Credentials.AccountTag, c.IsSet(TunnelTokenFlag), edgediscovery.ProtocolPercentage, connection.ResolveTTL, log)
if err != nil {
return nil, nil, err
}
@@ -168,7 +158,7 @@ func prepareTunnelConfig(
if tlsSettings == nil {
return nil, nil, fmt.Errorf("%s has unknown TLS settings", p)
}
edgeTLSConfig, err := tlsconfig.CreateTunnelConfig(c, tlsSettings.ServerName)
edgeTLSConfig, err := tlsconfig.CreateTunnelConfig(c.String(flags.CACert), tlsSettings.ServerName)
if err != nil {
return nil, nil, errors.Wrap(err, "unable to create TLS config to connect with edge")
}
@@ -261,6 +251,7 @@ func prepareTunnelConfig(
DisableQUICPathMTUDiscovery: c.Bool(flags.QuicDisablePathMTUDiscovery),
QUICConnectionLevelFlowControlLimit: c.Uint64(flags.QuicConnLevelFlowControlLimit),
QUICStreamLevelFlowControlLimit: c.Uint64(flags.QuicStreamLevelFlowControlLimit),
NoPrechecks: c.Bool(flags.NoPrechecks),
OriginDNSService: dnsService,
OriginDialerService: originDialerService,
}
@@ -300,7 +291,7 @@ func gracePeriod(c *cli.Context) (time.Duration, error) {
}
func isRunningFromTerminal() bool {
return term.IsTerminal(int(os.Stdout.Fd()))
return term.IsTerminal(int(os.Stdout.Fd())) // nolint:gosec
}
// ParseConfigIPVersion returns the IP version from possible expected values from config
@@ -341,7 +332,7 @@ func testIPBindable(ip net.IP) error {
if err != nil {
return err
}
listener.Close()
_ = listener.Close()
return nil
}
@@ -503,7 +494,7 @@ func findLocalAddr(dst net.IP, port int) (netip.Addr, error) {
if err != nil {
return netip.Addr{}, err
}
defer udpConn.Close()
defer func() { _ = udpConn.Close() }()
localAddrPort, err := netip.ParseAddrPort(udpConn.LocalAddr().String())
if err != nil {
return netip.Addr{}, err
+2 -1
View File
@@ -100,6 +100,7 @@ func login(c *cli.Context) error {
c.Bool(cfdflags.AutoCloseInterstitial),
isFEDRamp,
log,
"",
)
if err != nil {
log.Error().Err(err).Msgf("Failed to write the certificate.\n\nYour browser will download the certificate instead. You will have to manually\ncopy it to the following path:\n\n%s\n", path)
@@ -122,7 +123,7 @@ func login(c *cli.Context) error {
return err
}
if err := os.WriteFile(path, resourceData, 0600); err != nil {
if err := os.WriteFile(path, resourceData, 0600); err != nil { // nolint: gosec
return errors.Wrap(err, fmt.Sprintf("error writing cert to %s", path))
}
+4 -28
View File
@@ -11,6 +11,7 @@ import (
"github.com/google/uuid"
"github.com/pkg/errors"
"github.com/cloudflare/cloudflared/cmd/cloudflared/cliutil"
"github.com/cloudflare/cloudflared/cmd/cloudflared/flags"
"github.com/cloudflare/cloudflared/connection"
)
@@ -44,7 +45,7 @@ func RunQuickTunnel(sc *subcommandContext) error {
if err != nil {
return errors.Wrap(err, "failed to request quick Tunnel")
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
// This will read the entire response into memory so we can print it in case of error
rsp_body, err := io.ReadAll(resp.Body)
@@ -76,12 +77,10 @@ func RunQuickTunnel(sc *subcommandContext) error {
url = "https://" + url
}
for _, line := range AsciiBox([]string{
cliutil.LogTable(sc.log, []string{
"Your quick Tunnel has been created! Visit it at (it may take some time to be reachable):",
url,
}, 2) {
sc.log.Info().Msg(line)
}
})
if !sc.c.IsSet(flags.Protocol) {
_ = sc.c.Set(flags.Protocol, "quic")
@@ -116,26 +115,3 @@ type QuickTunnel struct {
AccountTag string `json:"account_tag"`
Secret []byte `json:"secret"`
}
// Print out the given lines in a nice ASCII box.
func AsciiBox(lines []string, padding int) (box []string) {
maxLen := maxLen(lines)
spacer := strings.Repeat(" ", padding)
border := "+" + strings.Repeat("-", maxLen+(padding*2)) + "+"
box = append(box, border)
for _, line := range lines {
box = append(box, "|"+spacer+line+strings.Repeat(" ", maxLen-len(line))+spacer+"|")
}
box = append(box, border)
return
}
func maxLen(lines []string) int {
max := 0
for _, line := range lines {
if len(line) > max {
max = len(line)
}
}
return max
}
+20 -15
View File
@@ -421,7 +421,7 @@ func listCommand(c *cli.Context) error {
func formatAndPrintTunnelList(tunnels []*cfapi.Tunnel, showRecentlyDisconnected bool) {
writer := tabWriter()
defer writer.Flush()
defer func() { _ = writer.Flush() }()
_, _ = fmt.Fprintln(writer, "You can obtain more detailed information for each tunnel with `cloudflared tunnel info <name/uuid>`")
@@ -444,13 +444,14 @@ func formatAndPrintTunnelList(tunnels []*cfapi.Tunnel, showRecentlyDisconnected
func fmtConnections(connections []cfapi.Connection, showRecentlyDisconnected bool) string {
// Count connections per colo
numConnsPerColo := make(map[string]uint, len(connections))
for _, connection := range connections {
if !connection.IsPendingReconnect || showRecentlyDisconnected {
numConnsPerColo[connection.ColoName]++
for _, cfConnections := range connections {
if !cfConnections.IsPendingReconnect || showRecentlyDisconnected {
numConnsPerColo[cfConnections.ColoName]++
}
}
// Get sorted list of colos
// nolint: prealloc
sortedColos := []string{}
for coloName := range numConnsPerColo {
sortedColos = append(sortedColos, coloName)
@@ -488,11 +489,12 @@ func readyCommand(c *cli.Context) error {
if err != nil {
return err
}
// nolint: gosec // URL is constructed from the user-configured local metrics endpoint.
res, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
defer func() { _ = res.Body.Close() }()
if res.StatusCode != 200 {
body, err := io.ReadAll(res.Body)
if err != nil {
@@ -613,7 +615,7 @@ func getTunnel(sc *subcommandContext, tunnelID uuid.UUID) (*cfapi.Tunnel, error)
func formatAndPrintConnectionsList(tunnelInfo Info, showRecentlyDisconnected bool) {
writer := tabWriter()
defer writer.Flush()
defer func() { _ = writer.Flush() }()
// Print the general tunnel info table
_, _ = fmt.Fprintf(writer, "NAME: %s\nID: %s\nCREATED: %s\n\n", tunnelInfo.Name, tunnelInfo.ID, tunnelInfo.CreatedAt)
@@ -654,14 +656,14 @@ func formatAndPrintConnectionsList(tunnelInfo Info, showRecentlyDisconnected boo
func tabWriter() *tabwriter.Writer {
const (
minWidth = 0
tabWidth = 8
padding = 1
padChar = ' '
flags = 0
minWidth = 0
tabWidth = 8
padding = 1
padChar = ' '
formatFlags = 0
)
writer := tabwriter.NewWriter(os.Stdout, minWidth, tabWidth, padding, padChar, flags)
writer := tabwriter.NewWriter(os.Stdout, minWidth, tabWidth, padding, padChar, formatFlags)
return writer
}
@@ -712,7 +714,8 @@ func renderOutput(format string, v interface{}) error {
}
func buildRunCommand() *cli.Command {
flags := []cli.Flag{
//nolint: prealloc
cliFlags := []cli.Flag{
credentialsFileFlag,
credentialsContentsFlag,
postQuantumFlag,
@@ -725,7 +728,7 @@ func buildRunCommand() *cli.Command {
maxActiveFlowsFlag,
dnsResolverAddrsFlag,
}
flags = append(flags, configureProxyFlags(false)...)
cliFlags = append(cliFlags, configureProxyFlags(false)...)
return &cli.Command{
Name: "run",
Action: cliutil.ConfiguredAction(runCommand),
@@ -740,7 +743,7 @@ func buildRunCommand() *cli.Command {
If you experience other problems running the tunnel, "cloudflared tunnel cleanup" may help by removing
any old connection records.
`,
Flags: flags,
Flags: cliFlags,
CustomHelpTemplate: commandHelpTemplate(),
}
}
@@ -765,6 +768,7 @@ func runCommand(c *cli.Context) error {
// Check if tokenStr is blank before checking for tokenFile
if tokenStr == "" {
if tokenFile := c.String(TunnelTokenFileFlag); tokenFile != "" {
// nolint: gosec
data, err := os.ReadFile(tokenFile)
if err != nil {
return cliutil.UsageError("Failed to read token file: %s", err.Error())
@@ -1105,6 +1109,7 @@ func diagCommand(ctx *cli.Context) error {
Address: sctx.c.String(flags.Metrics),
ContainerID: sctx.c.String(diagContainerIDFlagName),
PodID: sctx.c.String(diagPodFlagName),
Region: sctx.c.String(flags.Region),
Toggles: diagnostic.Toggles{
NoDiagLogs: sctx.c.Bool(noDiagLogsFlagName),
NoDiagMetrics: sctx.c.Bool(noDiagMetricsFlagName),
+5 -15
View File
@@ -16,6 +16,7 @@ import (
"github.com/cloudflare/cloudflared/cmd/cloudflared/cliutil"
cfdflags "github.com/cloudflare/cloudflared/cmd/cloudflared/flags"
"github.com/cloudflare/cloudflared/cmd/cloudflared/inits"
"github.com/cloudflare/cloudflared/config"
"github.com/cloudflare/cloudflared/logger"
)
@@ -112,10 +113,10 @@ func CheckForUpdate(options updateOptions) (CheckResult, error) {
func encodeWindowsPath(path string) string {
// We do this because Windows allows spaces in directories such as
// Program Files but does not allow these directories to be spaced in batch files.
targetPath := strings.Replace(path, "Program Files (x86)", "PROGRA~2", -1)
targetPath := strings.ReplaceAll(path, "Program Files (x86)", "PROGRA~2")
// This is to do the same in 32 bit systems. We do this second so that the first
// replace is for x86 dirs.
targetPath = strings.Replace(targetPath, "Program Files", "PROGRA~1", -1)
targetPath = strings.ReplaceAll(targetPath, "Program Files", "PROGRA~1")
return targetPath
}
@@ -248,7 +249,7 @@ func (a *AutoUpdater) Run(ctx context.Context) error {
updateOutcome := loggedUpdate(a.log, updateOptions{updateDisabled: !a.configurable.enabled})
if updateOutcome.Updated {
buildInfo.CloudflaredVersion = updateOutcome.Version
if IsSysV() {
if inits.IsSysV() {
// SysV doesn't have a mechanism to keep service alive, we have to restart the process
a.log.Info().Msg("Restarting service managed by SysV...")
pid, err := a.listeners.StartProcess()
@@ -298,16 +299,5 @@ func wasInstalledFromPackageManager() bool {
}
func isRunningFromTerminal() bool {
return term.IsTerminal(int(os.Stdout.Fd()))
}
func IsSysV() bool {
if runtime.GOOS != "linux" {
return false
}
if _, err := os.Stat("/run/systemd/system"); err == nil {
return false
}
return true
return term.IsTerminal(int(os.Stdout.Fd())) // nolint:gosec
}
+27 -37
View File
@@ -1,10 +1,9 @@
import json
import subprocess
from time import sleep
from constants import MANAGEMENT_HOST_NAME
from setup import get_config_from_file
from util import get_tunnel_connector_id
from util import get_tunnel_connector_id, CloudflaredProcess
SINGLE_CASE_TIMEOUT = 600
@@ -30,7 +29,7 @@ class CloudflaredCli:
listed = self._run_command(cmd_args, "list")
return json.loads(listed.stdout)
def get_management_token(self, config, config_path):
def get_management_token(self, config, config_path, resource):
basecmd = [config.cloudflared_binary]
if config_path is not None:
basecmd += ["--config", str(config_path)]
@@ -38,18 +37,35 @@ class CloudflaredCli:
if origincert:
basecmd += ["--origincert", origincert]
cmd_args = ["tail", "token", config.get_tunnel_id()]
cmd_args = ["management", "token", "--resource", resource, config.get_tunnel_id()]
cmd = basecmd + cmd_args
result = run_subprocess(cmd, "token", self.logger, check=True, capture_output=True, timeout=15)
return json.loads(result.stdout.decode("utf-8").strip())["token"]
def get_management_url(self, path, config, config_path):
access_jwt = self.get_management_token(config, config_path)
def get_tail_token(self, config, config_path):
"""
Get management token using the 'tail token' command.
Returns a token scoped for 'logs' resource.
"""
basecmd = [config.cloudflared_binary]
if config_path is not None:
basecmd += ["--config", str(config_path)]
origincert = get_config_from_file()["origincert"]
if origincert:
basecmd += ["--origincert", origincert]
cmd_args = ["tail", "token", config.get_tunnel_id()]
cmd = basecmd + cmd_args
result = run_subprocess(cmd, "tail-token", self.logger, check=True, capture_output=True, timeout=15)
return json.loads(result.stdout.decode("utf-8").strip())["token"]
def get_management_url(self, path, config, config_path, resource):
access_jwt = self.get_management_token(config, config_path, resource)
connector_id = get_tunnel_connector_id()
return f"https://{MANAGEMENT_HOST_NAME}/{path}?connector_id={connector_id}&access_token={access_jwt}"
def get_management_wsurl(self, path, config, config_path):
access_jwt = self.get_management_token(config, config_path)
def get_management_wsurl(self, path, config, config_path, resource):
access_jwt = self.get_management_token(config, config_path, resource)
connector_id = get_tunnel_connector_id()
return f"wss://{MANAGEMENT_HOST_NAME}/{path}?connector_id={connector_id}&access_token={access_jwt}"
@@ -66,38 +82,12 @@ class CloudflaredCli:
def __enter__(self):
self.basecmd += ["run"]
self.process = subprocess.Popen(self.basecmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
self.logger.info(f"Run cmd {self.basecmd}")
return self.process
self.cfd = CloudflaredProcess(self.basecmd, allow_input=False, capture_output=True)
return self.cfd
def __exit__(self, exc_type, exc_value, exc_traceback):
terminate_gracefully(self.process, self.logger, self.basecmd)
self.logger.debug(f"{self.basecmd} logs: {self.process.stderr.read()}")
def terminate_gracefully(process, logger, cmd):
process.terminate()
process_terminated = wait_for_terminate(process)
if not process_terminated:
process.kill()
logger.warning(f"{cmd}: cloudflared did not terminate within wait period. Killing process. logs: \
stdout: {process.stdout.read()}, stderr: {process.stderr.read()}")
def wait_for_terminate(opened_subprocess, attempts=10, poll_interval=1):
"""
wait_for_terminate polls the opened_subprocess every x seconds for a given number of attempts.
It returns true if the subprocess was terminated and false if it didn't.
"""
for _ in range(attempts):
if _is_process_stopped(opened_subprocess):
return True
sleep(poll_interval)
return False
def _is_process_stopped(process):
return process.poll() is not None
self.cfd.cleanup()
def cert_path():
+11
View File
@@ -5,6 +5,17 @@ MAX_LOG_LINES = 50
MANAGEMENT_HOST_NAME = "management.argotunnel.com"
# How long to wait for the cloudflared process to exit after SIGTERM before
# sending SIGKILL.
GRACEFUL_SHUTDOWN_TIMEOUT = 10
# How long to wait for each pipe reader thread to finish after the process
# exits.
READER_THREAD_JOIN_TIMEOUT = 5
# How long to wait for an expected log message to appear before giving up.
LOG_POLL_TIMEOUT = 30
# How often to re-check the accumulated log lines while polling.
LOG_POLL_INTERVAL = 0.5
def protocols():
return ["http2", "quic"]
-10
View File
@@ -17,16 +17,6 @@ class TestEdgeDiscovery:
config["edge-ip-version"] = edge_ip_version
return config
@pytest.mark.parametrize("protocol", protocols())
def test_default_only(self, tmp_path, component_tests_config, protocol):
"""
This test runs a tunnel to connect via IPv4-only edge addresses (default is unset "--edge-ip-version 4")
"""
if self.has_ipv6_only():
pytest.skip("Host has IPv6 only support and current default is IPv4 only")
self.expect_address_connections(
tmp_path, component_tests_config, protocol, None, self.expect_ipv4_address)
@pytest.mark.parametrize("protocol", protocols())
def test_ipv4_only(self, tmp_path, component_tests_config, protocol):
"""
+10 -7
View File
@@ -1,8 +1,9 @@
#!/usr/bin/env python
import json
import os
import time
from constants import MAX_LOG_LINES
from constants import MAX_LOG_LINES, LOG_POLL_INTERVAL, LOG_POLL_TIMEOUT
from util import start_cloudflared, wait_tunnel_ready, send_requests
# Rolling logger rotate log files after 1 MB
@@ -12,12 +13,14 @@ expect_message = "Starting Hello"
def assert_log_to_terminal(cloudflared):
for _ in range(0, MAX_LOG_LINES):
line = cloudflared.stderr.readline()
if not line:
break
if expect_message.encode() in line:
return
# All logs are drained by a background thread into cloudflared.stdout_lines.
# Poll the accumulated lines until the expected message appears.
deadline = time.monotonic() + LOG_POLL_TIMEOUT
while time.monotonic() < deadline:
for line in list(cloudflared.stdout_lines):
if expect_message.encode() in line:
return
time.sleep(LOG_POLL_INTERVAL)
raise Exception(f"terminal log doesn't contain {expect_message}")
+39 -5
View File
@@ -1,10 +1,11 @@
#!/usr/bin/env python
import json
import requests
from conftest import CfdModes
from constants import METRICS_PORT, MAX_RETRIES, BACKOFF_SECS
from retrying import retry
from cli import CloudflaredCli
from util import LOGGER, write_config, start_cloudflared, wait_tunnel_ready, send_requests
from util import LOGGER, write_config, start_cloudflared, wait_tunnel_ready, send_requests, decode_jwt_payload
import platform
"""
@@ -35,7 +36,7 @@ class TestManagement:
require_min_connections=1)
cfd_cli = CloudflaredCli(config, config_path, LOGGER)
connector_id = cfd_cli.get_connector_id(config)[0]
url = cfd_cli.get_management_url("host_details", config, config_path)
url = cfd_cli.get_management_url("host_details", config, config_path, resource="host_details")
resp = send_request(url, headers=headers)
# Assert response json.
@@ -58,7 +59,7 @@ class TestManagement:
with start_cloudflared(tmp_path, config, cfd_pre_args=["tunnel", "--ha-connections", "1"], new_process=True):
wait_tunnel_ready(require_min_connections=1)
cfd_cli = CloudflaredCli(config, config_path, LOGGER)
url = cfd_cli.get_management_url("metrics", config, config_path)
url = cfd_cli.get_management_url("metrics", config, config_path, resource="admin")
resp = send_request(url)
# Assert response.
@@ -79,7 +80,7 @@ class TestManagement:
with start_cloudflared(tmp_path, config, cfd_pre_args=["tunnel", "--ha-connections", "1"], new_process=True):
wait_tunnel_ready(require_min_connections=1)
cfd_cli = CloudflaredCli(config, config_path, LOGGER)
url = cfd_cli.get_management_url("debug/pprof/heap", config, config_path)
url = cfd_cli.get_management_url("debug/pprof/heap", config, config_path, resource="admin")
resp = send_request(url)
# Assert response.
@@ -100,12 +101,45 @@ class TestManagement:
with start_cloudflared(tmp_path, config, cfd_pre_args=["tunnel", "--ha-connections", "1", "--management-diagnostics=false"], new_process=True):
wait_tunnel_ready(require_min_connections=1)
cfd_cli = CloudflaredCli(config, config_path, LOGGER)
url = cfd_cli.get_management_url("metrics", config, config_path)
url = cfd_cli.get_management_url("metrics", config, config_path, resource="admin")
resp = send_request(url)
# Assert response.
assert resp.status_code == 404, "Expected cloudflared to return 404 for /metrics"
def test_tail_token_command(self, tmp_path, component_tests_config):
"""
Validates that 'cloudflared tail token' command returns a token
scoped for 'logs' and 'ping' resources.
"""
# TUN-7377: wait_tunnel_ready does not work properly in windows
if platform.system() == "Windows":
return
config = component_tests_config(cfd_mode=CfdModes.NAMED, provide_ingress=False)
LOGGER.debug(config)
config_path = write_config(tmp_path, config.full_config)
cfd_cli = CloudflaredCli(config, config_path, LOGGER)
token = cfd_cli.get_tail_token(config, config_path)
# Verify token was returned
assert token, "Expected non-empty token to be returned"
# Decode JWT payload to verify resource claims
claims = decode_jwt_payload(token)
resource_tag = 'res'
# Verify the token has 'logs' and 'ping' in resource array
assert resource_tag in claims, f"Expected {resource_tag} claim in token"
assert isinstance(claims['res'], list), f"Expected {resource_tag} to be an array"
assert 'logs' in claims[resource_tag], \
f"Expected 'logs' in resource array, got: {claims[resource_tag]}"
assert 'ping' in claims[resource_tag], \
f"Expected 'ping' in resource array, got: {claims[resource_tag]}"
LOGGER.info(f"Tail token successfully verified with resources: {claims[resource_tag]}")
+541
View File
@@ -0,0 +1,541 @@
#!/usr/bin/env python
"""
Integration tests for cloudflared connectivity pre-checks (TUN-10391).
Scope
-----
These tests verify the end-to-end behavior of cloudflared pre-checks:
- that the human-readable table written to the log output has the correct
structure and content,
- that structured JSON log lines are emitted with the expected fields, and
- that running the `diag` subcommand against a live tunnel instance produces a
zip archive that contains prechecks.json.
They do NOT cover every failure mode of the precheck logic — those are owned
by the unit tests in prechecks/checker_test.go which use mock dialers.
At the integration level the only reliable way to induce specific failure modes
without real firewall intervention is:
- --edge <unreachable>: StaticEdgeDNSResolver resolves the literal IP
directly (DNS row = PASS), then both QUIC and HTTP/2 probes time out
-> hard fail (both transports blocked).
This does NOT exercise the DNS-failure -> transport-skip path.
DNS failure and Management API failure cannot be triggered via CLI flags alone;
they require network-level intervention outside the component-test harness.
stdout/stderr design
--------------------
The pre-checks table is emitted via cliutil.LogTable, which wraps the content
in an ASCII box and logs each line at Info level through zerolog. zerolog
writes to stderr, which the test harness merges into stdout (stderr=STDOUT in
Popen). We poll a --logfile for the "precheck complete" sentinel before
leaving the `with` block, ensuring the goroutine has finished. We then call
cfd.terminate(). After the `with` block exits, the process is dead and all
output has been captured by CloudflaredProcess's background reader thread. We
read the accumulated lines from cfd.stdout_lines.
Box format (cliutil.asciiBox with padding=2, title="CONNECTIVITY PRE-CHECKS"):
+----...----+
| CONNECTIVITY PRE-CHECKS | (centered title)
+----...----+
| COMPONENT TARGET ... | (content rows)
...
+----...----+
"""
import json
import os
import re
import subprocess
import time
import zipfile as zipfilemod
from constants import METRICS_PORT
from util import LOGGER, start_cloudflared, wait_tunnel_ready
# ASCII box constants (cliutil.asciiBox, padding=2, title="CONNECTIVITY PRE-CHECKS")
BOX_TITLE = "CONNECTIVITY PRE-CHECKS"
BOX_BORDER_RE = re.compile(r"^\+(-+)\+$", re.MULTILINE) # matches +----...----+
COL_HEADER = "COMPONENT" # first word of the column-header row
# zerolog console format: "2006-01-02T15:04:05Z LVL <message>"
_LOG_PREFIX_RE = re.compile(r"^\S+ \w+ ")
# Component names (probes.go: componentXxx)
COMP_DNS = "DNS Resolution"
COMP_QUIC = "UDP Connectivity"
COMP_H2 = "TCP Connectivity"
COMP_API = "Cloudflare API"
# Target labels used in the rendered table.
#
# probeRegion() (checker.go:216) always overwrites the Target field of
# whatever CheckResult the inner probe function returns with the regionTarget
# hostname, so QUIC and HTTP/2 rows carry the same region hostname as the
# corresponding DNS row — not the "Port 7844 (QUIC/HTTP2)" strings that
# targetPortQUIC/targetPortHTTP2 define. Those port-label constants are only
# used in the empty-addrs SKIP branch and inside action message strings.
TARGET_API = "api.cloudflare.com:443"
TARGET_REGION1 = "region1.v2.argotunnel.com"
TARGET_REGION2 = "region2.v2.argotunnel.com"
# Details strings (probes.go: detailsXxx)
DETAILS_DNS_RESOLVED = "DNS Resolved successfully"
DETAILS_QUIC_OK = "QUIC connection successful"
DETAILS_HTTP2_OK = "HTTP/2 connection successful"
DETAILS_API_OK = "API is reachable"
DETAILS_QUIC_FAIL = "QUIC connection failed"
DETAILS_HTTP2_FAIL = "HTTP/2 connection is blocked or unreachable"
# Status labels (result.go: xyzStatus)
PASS = "PASS"
FAIL = "FAIL"
SKIP = "SKIP"
# Action prefixes (result.go: renderActions)
PREFIX_ERROR = "ERROR: "
PREFIX_WARNING = "WARNING: "
# Action messages (probes.go: actionXxx)
ACTION_QUIC_BLOCKED = "Allow outbound QUIC traffic on port 7844 or use HTTP2."
ACTION_HTTP2_BLOCKED = "Allow outbound TCP on port 7844."
# Exact summary lines (result.go: summaryLine)
SUMMARY_HEALTHY = "SUMMARY: Environment is healthy. cloudflared will use 'quic' as primary protocol."
SUMMARY_CRITICAL = "SUMMARY: Environment has critical failures. cloudflared may not be able to establish a tunnel."
# structured log constants (result.go)
LOG_MSG_PRECHECK = "precheck"
LOG_MSG_PRECHECK_COMPLETE = "precheck complete"
STATUS_PASS_LOG = "pass"
UNREACHABLE_EDGE = "192.0.2.1:7844"
# cloudflared dial timeout per probe: 5 s, up to 2 retries -> ~15 s total.
PRECHECK_POLL_TIMEOUT_SECS = 15
PRECHECK_POLL_INTERVAL_SECS = 1
# ---------- helpers ----------
def _poll_log_file_for_precheck_complete(log_file: str, timeout: float) -> list[dict]:
"""
Poll a JSON log file until a 'precheck complete' line appears or timeout
expires. Returns all precheck-related log lines found.
cloudflared's --logfile writes one JSON object per line. Polling keeps
the test fast on healthy networks and still tolerates slow CI hosts.
We re-read from the beginning of the file on every poll because the file
is append-only, small, and tracking a byte offset would add complexity with
no meaningful performance benefit for a ~15 s total window.
"""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
lines = _read_precheck_log_lines_from_file(log_file)
if any(l.get("message") == LOG_MSG_PRECHECK_COMPLETE for l in lines):
return lines
time.sleep(PRECHECK_POLL_INTERVAL_SECS)
return _read_precheck_log_lines_from_file(log_file)
def _read_precheck_log_lines_from_file(log_file: str) -> list[dict]:
"""Parse all precheck-related JSON log lines from a --logfile path."""
result = []
try:
with open(log_file, "r") as f:
for raw_line in f:
raw_line = raw_line.strip()
if not raw_line:
continue
try:
obj = json.loads(raw_line)
except json.JSONDecodeError:
continue
msg = obj.get("message") or obj.get("msg", "")
if msg in (LOG_MSG_PRECHECK, LOG_MSG_PRECHECK_COMPLETE):
result.append(obj)
except FileNotFoundError:
pass
return result
# stdout table parse
class TableRow:
"""One data row parsed from the rendered precheck table."""
def __init__(self, component: str, target: str, status: str, details: str):
self.component = component
self.target = target
self.status = status
self.details = details
def __repr__(self):
return f"TableRow({self.component!r}, {self.target!r}, {self.status!r}, {self.details!r})"
def _strip_log_prefix(line: str) -> str:
"""Remove the zerolog console prefix ('2006-01-02T15:04:05Z LVL ') if present."""
return _LOG_PREFIX_RE.sub("", line, count=1)
def _unbox_line(line: str) -> str:
"""Strip the box border padding from a content line: '| text |' -> 'text'.
Accepts lines that may still carry a zerolog console prefix; the prefix is
removed before the box delimiters are stripped.
"""
msg = _strip_log_prefix(line)
if msg.startswith("|") and msg.endswith("|"):
return msg[1:-1].strip()
return msg.strip()
def _parse_table(stdout: str) -> list[TableRow]:
"""
Parse the data rows from a precheck table in stdout.
The table is now wrapped in an ASCII box by cliutil.LogTable. Each
content line has the form '| <content> |', optionally preceded by a
zerolog console prefix. We strip both the prefix and the box borders
before splitting on two-or-more spaces (text/tabwriter padding=2).
We skip the column-header row and stop at blank lines, SUMMARY, box
border lines, ERROR, or WARNING lines.
"""
rows = []
in_data = False
for raw_line in stdout.splitlines():
msg = _strip_log_prefix(raw_line)
line = _unbox_line(raw_line)
if line.startswith("COMPONENT"):
in_data = True
continue
if not in_data:
continue
if (line == "" or line.startswith("SUMMARY") or BOX_BORDER_RE.match(msg)
or line.startswith("ERROR") or line.startswith("WARNING")):
in_data = False
continue
parts = re.split(r" +", line.rstrip())
if len(parts) >= 3:
rows.append(TableRow(
component=parts[0],
target=parts[1],
status=parts[2],
details=parts[3] if len(parts) >= 4 else "",
))
return rows
def _rows_for(rows: list[TableRow], component: str) -> list[TableRow]:
return [r for r in rows if r.component == component]
# log assertions
def _assert_precheck_summary_log(
log_lines: list[dict],
*,
hard_fail: bool,
suggested_protocol: str | None = None,
):
"""Assert the 'precheck complete' summary log line has the expected fields."""
summary_lines = [l for l in log_lines if l.get("message") == LOG_MSG_PRECHECK_COMPLETE]
assert len(summary_lines) == 1, \
f"Expected exactly one '{LOG_MSG_PRECHECK_COMPLETE}' log line; got {summary_lines}"
summary = summary_lines[0]
assert summary.get("hard_fail") is hard_fail, \
f"Expected hard_fail={hard_fail} in summary log: {summary}"
if suggested_protocol is not None:
assert summary.get("suggested_protocol") == suggested_protocol, \
(f"Expected suggested_protocol={suggested_protocol!r}; "
f"got {summary.get('suggested_protocol')!r}")
# ---------- Tests ----------
class TestPrechecksHappyPath:
"""
On a healthy connection all probes pass. We assert:
- the full table structure (header, column header, separator)
- every row's component, target, status, and details
- no ERROR/WARNING action lines
- the exact summary line
- the structured log summary (hard_fail=false, suggested_protocol=quic)
"""
def test_prechecks_pass_on_healthy_connection(self, tmp_path, component_tests_config):
log_file = str(tmp_path / "cloudflared.log")
config = component_tests_config({"logfile": log_file})
with start_cloudflared(
tmp_path,
config,
cfd_pre_args=["tunnel", "--ha-connections", "1"],
cfd_args=["run"],
new_process=True,
capture_output=True,
) as cfd:
wait_tunnel_ready(tunnel_url=config.get_url(), require_min_connections=1)
# Poll the log file for the sentinel before signalling the process.
log_lines = _poll_log_file_for_precheck_complete(
log_file, timeout=PRECHECK_POLL_TIMEOUT_SECS
)
# Signal shutdown.
cfd.terminate()
# The process is now dead. All output was captured by the background
# reader thread into cfd.stdout_lines (stderr is merged into stdout).
stdout = b"".join(cfd.stdout_lines).decode(errors="replace")
LOGGER.debug(f"[happy-path] stdout:\n{stdout}")
LOGGER.debug(f"[happy-path] log_lines:\n{log_lines}")
# Strip zerolog console prefixes so pattern matching works on raw messages.
messages = "\n".join(_strip_log_prefix(l) for l in stdout.splitlines())
# ── table structure ──────────────────────────────────────────────────
# zerolog writes to stderr which is merged into stdout by the harness.
# The table is wrapped in an ASCII box by cliutil.LogTable.
assert BOX_TITLE in messages, \
f"Expected box title '{BOX_TITLE}' in output;\ngot:\n{stdout}"
assert COL_HEADER in messages, \
f"Expected column header row in output;\ngot:\n{stdout}"
assert BOX_BORDER_RE.search(messages), \
f"Expected box border line (+---+) in output;\ngot:\n{stdout}"
# ── row content ──────────────────────────────────────────────────────
rows = _parse_table(stdout)
assert len(rows) == 7, \
f"Expected 7 rows (2 DNS + 2 QUIC + 2 HTTP/2 + 1 API); got {len(rows)}: {rows}"
dns_rows = _rows_for(rows, COMP_DNS)
assert len(dns_rows) == 2, f"Expected 2 DNS rows; got {dns_rows}"
assert dns_rows[0].target == TARGET_REGION1
assert dns_rows[1].target == TARGET_REGION2
for r in dns_rows:
assert r.status == PASS, f"DNS row not PASS: {r}"
assert r.details == DETAILS_DNS_RESOLVED, f"DNS row details wrong: {r}"
quic_rows = _rows_for(rows, COMP_QUIC)
assert len(quic_rows) == 2, f"Expected 2 QUIC rows; got {quic_rows}"
assert quic_rows[0].target == TARGET_REGION1, f"QUIC row[0] target wrong: {quic_rows[0]}"
assert quic_rows[1].target == TARGET_REGION2, f"QUIC row[1] target wrong: {quic_rows[1]}"
for r in quic_rows:
assert r.status == PASS, f"QUIC row not PASS: {r}"
assert r.details == DETAILS_QUIC_OK, f"QUIC row details wrong: {r}"
h2_rows = _rows_for(rows, COMP_H2)
assert len(h2_rows) == 2, f"Expected 2 HTTP/2 rows; got {h2_rows}"
assert h2_rows[0].target == TARGET_REGION1, f"HTTP/2 row[0] target wrong: {h2_rows[0]}"
assert h2_rows[1].target == TARGET_REGION2, f"HTTP/2 row[1] target wrong: {h2_rows[1]}"
for r in h2_rows:
assert r.status == PASS, f"HTTP/2 row not PASS: {r}"
assert r.details == DETAILS_HTTP2_OK, f"HTTP/2 row details wrong: {r}"
api_rows = _rows_for(rows, COMP_API)
assert len(api_rows) == 1, f"Expected 1 API row; got {api_rows}"
assert api_rows[0].target == TARGET_API, f"API row target wrong: {api_rows[0]}"
assert api_rows[0].status == PASS, f"API row not PASS: {api_rows[0]}"
assert api_rows[0].details == DETAILS_API_OK, f"API row details wrong: {api_rows[0]}"
# ── no action lines ──────────────────────────────────────────────────
assert PREFIX_ERROR not in messages, f"Unexpected ERROR action:\n{stdout}"
assert PREFIX_WARNING not in messages, f"Unexpected WARNING action:\n{stdout}"
# ── summary line ─────────────────────────────────────────────────────
assert SUMMARY_HEALTHY in messages, \
f"Expected healthy summary;\ngot:\n{stdout}"
# ── structured log ───────────────────────────────────────────────────
assert len(log_lines) > 0, \
"Expected at least one structured precheck log line in log file"
for line in log_lines:
if line.get("message") == LOG_MSG_PRECHECK:
assert line.get("status") == STATUS_PASS_LOG, \
f"Expected status=pass in precheck log line: {line}"
_assert_precheck_summary_log(log_lines, hard_fail=False, suggested_protocol="quic")
class TestPrechecksHardFail:
"""
When --edge points at an unreachable IP, StaticEdgeDNSResolver resolves
the literal address directly (DNS row = PASS), but both transport probes
time out -> hard fail. We assert:
- the full table structure
- DNS row: PASS (the literal IP was resolved)
- QUIC row: FAIL with correct details + ERROR action
- HTTP/2 row: FAIL with correct details + ERROR action
- API row: PASS (api.cloudflare.com:443 is independently reachable)
- the exact critical summary line
- the structured log summary (hard_fail=true)
This test does NOT call wait_tunnel_ready because the tunnel will not
connect to the unreachable address.
"""
def test_prechecks_hard_fail_when_edge_unreachable(self, tmp_path, component_tests_config):
log_file = str(tmp_path / "cloudflared.log")
config = component_tests_config({"logfile": log_file})
with start_cloudflared(
tmp_path,
config,
cfd_pre_args=[
"tunnel",
"--ha-connections", "1",
"--edge", UNREACHABLE_EDGE,
],
cfd_args=["run"],
new_process=True,
capture_output=True,
) as cfd:
log_lines = _poll_log_file_for_precheck_complete(
log_file, timeout=PRECHECK_POLL_TIMEOUT_SECS
)
cfd.terminate()
stdout = b"".join(cfd.stdout_lines).decode(errors="replace")
LOGGER.debug(f"[hard-fail] stdout:\n{stdout}")
LOGGER.debug(f"[hard-fail] log_lines:\n{log_lines}")
# Strip zerolog console prefixes so pattern matching works on raw messages.
messages = "\n".join(_strip_log_prefix(l) for l in stdout.splitlines())
# ── table structure ──────────────────────────────────────────────────
# zerolog writes to stderr which is merged into stdout by the harness.
# The table is wrapped in an ASCII box by cliutil.LogTable.
assert BOX_TITLE in messages, \
f"Expected box title '{BOX_TITLE}' in output;\ngot:\n{stdout}"
assert COL_HEADER in messages, \
f"Expected column header row in output;\ngot:\n{stdout}"
assert BOX_BORDER_RE.search(messages), \
f"Expected box border line (+---+) in output;\ngot:\n{stdout}"
# ── row content ──────────────────────────────────────────────────────
rows = _parse_table(stdout)
assert len(rows) == 4, \
f"Expected 4 rows (1 DNS + 1 QUIC + 1 HTTP/2 + 1 API); got {len(rows)}: {rows}"
dns_rows = _rows_for(rows, COMP_DNS)
assert len(dns_rows) == 1, f"Expected 1 DNS row; got {dns_rows}"
assert dns_rows[0].target == UNREACHABLE_EDGE
assert dns_rows[0].status == PASS, f"DNS row not PASS: {dns_rows[0]}"
assert dns_rows[0].details == DETAILS_DNS_RESOLVED, f"DNS row details wrong: {dns_rows[0]}"
quic_rows = _rows_for(rows, COMP_QUIC)
assert len(quic_rows) == 1, f"Expected 1 QUIC row; got {quic_rows}"
assert quic_rows[0].target == UNREACHABLE_EDGE, f"QUIC row target wrong: {quic_rows[0]}"
assert quic_rows[0].status == FAIL, f"QUIC row not FAIL: {quic_rows[0]}"
assert quic_rows[0].details == DETAILS_QUIC_FAIL, f"QUIC row details wrong: {quic_rows[0]}"
h2_rows = _rows_for(rows, COMP_H2)
assert len(h2_rows) == 1, f"Expected 1 HTTP/2 row; got {h2_rows}"
assert h2_rows[0].target == UNREACHABLE_EDGE, f"HTTP/2 row target wrong: {h2_rows[0]}"
assert h2_rows[0].status == FAIL, f"HTTP/2 row not FAIL: {h2_rows[0]}"
assert h2_rows[0].details == DETAILS_HTTP2_FAIL, f"HTTP/2 row details wrong: {h2_rows[0]}"
api_rows = _rows_for(rows, COMP_API)
assert len(api_rows) == 1, f"Expected 1 API row; got {api_rows}"
assert api_rows[0].target == TARGET_API, f"API row target wrong: {api_rows[0]}"
assert api_rows[0].status == PASS, f"API row not PASS: {api_rows[0]}"
assert api_rows[0].details == DETAILS_API_OK, f"API row details wrong: {api_rows[0]}"
assert f"{PREFIX_ERROR}{ACTION_QUIC_BLOCKED}" in messages, \
f"Expected QUIC ERROR action;\ngot:\n{stdout}"
assert f"{PREFIX_ERROR}{ACTION_HTTP2_BLOCKED}" in messages, \
f"Expected HTTP/2 ERROR action;\ngot:\n{stdout}"
assert SUMMARY_CRITICAL in messages, \
f"Expected critical summary;\ngot:\n{stdout}"
_assert_precheck_summary_log(log_lines, hard_fail=True, suggested_protocol=None)
class TestPreChecksDiag:
"""
Verify that `cloudflared tunnel diag` includes prechecks.json in the
diagnostic zip archive produced against a live tunnel instance.
The precheck job in diagnostic.go is gated on noDiagNetwork; we do NOT
pass --no-diag-network so prechecks.json must be present. We skip the
heavier collectors (logs, metrics, system, runtime) to keep the test fast.
The diag subcommand writes the zip to its current working directory. We
run it with cwd=tmp_path so the archive lands there and is cleaned up
automatically by pytest. We resolve config.cloudflared_binary to an
absolute path before changing cwd, because the binary path may be relative
to the original working directory.
"""
def test_diag_contains_prechecks_json(self, tmp_path, component_tests_config):
config = component_tests_config()
binary = os.path.abspath(config.cloudflared_binary)
with start_cloudflared(
tmp_path,
config,
cfd_pre_args=["tunnel", "--ha-connections", "1"],
cfd_args=["run"],
new_process=True,
capture_output=True,
) as cfd:
wait_tunnel_ready(tunnel_url=config.get_url(), require_min_connections=1)
# Run the diag subcommand as a one-shot process against the
# already-running instance. We skip log/metrics/system/runtime
# collectors; the network collector (which runs prechecks) is left
# enabled.
diag_result = subprocess.run(
[
binary,
"tunnel",
"diag",
"--metrics", f"localhost:{METRICS_PORT}",
"--no-diag-logs",
"--no-diag-metrics",
"--no-diag-system",
"--no-diag-runtime",
],
cwd=str(tmp_path),
capture_output=True,
timeout=60,
)
cfd.terminate()
diag_stdout = diag_result.stdout.decode(errors="replace")
diag_stderr = diag_result.stderr.decode(errors="replace")
LOGGER.debug(f"[diag] stdout:\n{diag_stdout}")
LOGGER.debug(f"[diag] stderr:\n{diag_stderr}")
assert diag_result.returncode == 0, (
f"cloudflared tunnel diag exited with code {diag_result.returncode}\n"
f"stdout:\n{diag_stdout}\nstderr:\n{diag_stderr}"
)
# Locate the zip file written to tmp_path by the diag command.
zip_files = list(tmp_path.glob("cloudflared-diag-*.zip"))
assert len(zip_files) == 1, \
f"Expected exactly one cloudflared-diag-*.zip in {tmp_path}; found {zip_files}"
zip_path = zip_files[0]
with zipfilemod.ZipFile(zip_path) as zf:
names = zf.namelist()
LOGGER.debug(f"[diag] zip contents: {names}")
assert "prechecks.json" in names, \
f"Expected prechecks.json in diag zip; got: {names}"
# Must be valid JSON containing at least the RunID field that
# prechecks.Run() always sets.
with zf.open("prechecks.json") as fh:
data = json.load(fh)
assert "RunID" in data, \
f"Expected RunID key in prechecks.json; got keys: {list(data.keys())}"
+5 -5
View File
@@ -25,7 +25,7 @@ class TestTail:
with start_cloudflared(tmp_path, config, cfd_args=["run", "--hello-world"], new_process=True):
wait_tunnel_ready(tunnel_url=config.get_url(), require_min_connections=1)
cfd_cli = CloudflaredCli(config, config_path, LOGGER)
url = cfd_cli.get_management_wsurl("logs", config, config_path)
url = cfd_cli.get_management_wsurl("logs", config, config_path, resource="logs")
async with connect(url, open_timeout=5, close_timeout=3) as websocket:
await websocket.send('{"type": "start_streaming"}')
await websocket.send('{"type": "stop_streaming"}')
@@ -44,7 +44,7 @@ class TestTail:
with start_cloudflared(tmp_path, config, cfd_args=["run", "--hello-world"], new_process=True):
wait_tunnel_ready(tunnel_url=config.get_url(), require_min_connections=1)
cfd_cli = CloudflaredCli(config, config_path, LOGGER)
url = cfd_cli.get_management_wsurl("logs", config, config_path)
url = cfd_cli.get_management_wsurl("logs", config, config_path, resource="logs")
async with connect(url, open_timeout=5, close_timeout=5) as websocket:
# send start_streaming
await websocket.send(json.dumps({
@@ -71,7 +71,7 @@ class TestTail:
with start_cloudflared(tmp_path, config, cfd_args=["run", "--hello-world"], new_process=True):
wait_tunnel_ready(tunnel_url=config.get_url(), require_min_connections=1)
cfd_cli = CloudflaredCli(config, config_path, LOGGER)
url = cfd_cli.get_management_wsurl("logs", config, config_path)
url = cfd_cli.get_management_wsurl("logs", config, config_path, resource="logs")
async with connect(url, open_timeout=5, close_timeout=5) as websocket:
# send start_streaming with tcp logs only
await websocket.send(json.dumps({
@@ -98,7 +98,7 @@ class TestTail:
with start_cloudflared(tmp_path, config, cfd_args=["run", "--hello-world"], new_process=True):
wait_tunnel_ready(tunnel_url=config.get_url(), require_min_connections=1)
cfd_cli = CloudflaredCli(config, config_path, LOGGER)
url = cfd_cli.get_management_wsurl("logs", config, config_path)
url = cfd_cli.get_management_wsurl("logs", config, config_path, resource="logs")
async with connect(url, open_timeout=5, close_timeout=5) as websocket:
# send start_streaming with info logs only
await websocket.send(json.dumps({
@@ -126,7 +126,7 @@ class TestTail:
with start_cloudflared(tmp_path, config, cfd_args=["run", "--hello-world"], new_process=True):
wait_tunnel_ready(tunnel_url=config.get_url(), require_min_connections=1)
cfd_cli = CloudflaredCli(config, config_path, LOGGER)
url = cfd_cli.get_management_wsurl("logs", config, config_path)
url = cfd_cli.get_management_wsurl("logs", config, config_path, resource="logs")
task = asyncio.ensure_future(start_streaming_to_be_remotely_closed(url))
override_task = asyncio.ensure_future(start_streaming_override(url))
await asyncio.wait([task, override_task])
+108 -8
View File
@@ -2,6 +2,7 @@ import logging
import os
import platform
import subprocess
import threading
from contextlib import contextmanager
from time import sleep
import sys
@@ -12,7 +13,65 @@ import requests
import yaml
from retrying import retry
from constants import METRICS_PORT, MAX_RETRIES, BACKOFF_SECS
from constants import METRICS_PORT, MAX_RETRIES, BACKOFF_SECS, GRACEFUL_SHUTDOWN_TIMEOUT, READER_THREAD_JOIN_TIMEOUT
class CloudflaredProcess:
"""
Wrapper around a Popen process that continuously drains stdout and stderr
in background threads to prevent OS pipe buffers from filling up and
blocking the child process. Captured output is logged when the process
is cleaned up.
"""
def __init__(self, cmd, allow_input, capture_output):
output = subprocess.PIPE if capture_output else subprocess.DEVNULL
stdin = subprocess.PIPE if allow_input else None
self.process = subprocess.Popen(cmd, stdin=stdin, stdout=output, stderr=subprocess.STDOUT)
self._capture_output = capture_output
self._stdout_lines = []
self._threads = []
if capture_output:
self._threads.append(self._start_reader(self.process.stdout, self._stdout_lines))
@staticmethod
def _start_reader(pipe, sink):
def _drain():
for line in pipe:
sink.append(line)
pipe.close()
t = threading.Thread(target=_drain, daemon=True)
t.start()
return t
def terminate(self):
"""Terminate the process if it is still running."""
if self.process.poll() is None:
self.process.terminate()
def cleanup(self):
"""Terminate, wait for exit, join reader threads, and log output."""
self.terminate()
try:
self.process.wait(timeout=GRACEFUL_SHUTDOWN_TIMEOUT)
except subprocess.TimeoutExpired:
self.process.kill()
self.process.wait()
for t in self._threads:
t.join(timeout=READER_THREAD_JOIN_TIMEOUT)
if self._capture_output:
stdout = b"".join(self._stdout_lines).decode("utf-8", errors="replace")
if stdout:
LOGGER.info(f"cloudflared stdout:\n{stdout}")
@property
def stdout_lines(self):
return self._stdout_lines
# Proxy common Popen attributes so callers can still use the wrapper
# as if it were a Popen (e.g. send_signal, stdin, pid, returncode).
def __getattr__(self, name):
return getattr(self.process, name)
def configure_logger():
logger = logging.getLogger(__name__)
@@ -75,20 +134,15 @@ def cloudflared_cmd(config, config_path, cfd_args, cfd_pre_args, root):
LOGGER.info(f"Run cmd {cmd} with config {config}")
return cmd
@contextmanager
def run_cloudflared_background(cmd, allow_input, capture_output):
output = subprocess.PIPE if capture_output else subprocess.DEVNULL
stdin = subprocess.PIPE if allow_input else None
cfd = None
try:
cfd = subprocess.Popen(cmd, stdin=stdin, stdout=output, stderr=output)
cfd = CloudflaredProcess(cmd, allow_input, capture_output)
yield cfd
finally:
if cfd:
cfd.terminate()
if capture_output:
LOGGER.info(f"cloudflared log: {cfd.stderr.read()}")
cfd.cleanup()
def get_quicktunnel_url():
@@ -185,3 +239,49 @@ def send_request(session, url, require_ok):
if require_ok:
assert resp.status_code == 200, f"{url} returned {resp}"
return resp if resp.status_code == 200 else None
def decode_jwt_payload(token):
"""
Decode the payload section of a JWT token without signature verification.
JWT Structure:
==============
A JWT consists of three Base64URL-encoded parts separated by dots:
HEADER.PAYLOAD.SIGNATURE
The payload contains the JWT claims (the actual data/permissions).
Args:
token (str): The complete JWT token string
Returns:
dict: The decoded payload as a dictionary containing JWT claims
Raises:
ValueError: If the token doesn't have exactly 3 parts
Note:
This function does NOT verify the signature - it only decodes the payload.
Use this only when you trust the token source (e.g., tokens you just generated).
"""
import base64
import json
# Split JWT into its three components
parts = token.split('.')
if len(parts) != 3:
raise ValueError(f"Invalid JWT format: expected 3 parts, got {len(parts)}")
# Extract and decode the payload (middle section)
# Base64 requires padding to be a multiple of 4 characters
payload_encoded = parts[1]
remainder = len(payload_encoded) % 4
if remainder != 0:
payload_padded = payload_encoded + '=' * (4 - remainder)
else:
payload_padded = payload_encoded
# Decode from Base64URL format and parse JSON
decoded_payload = base64.urlsafe_b64decode(payload_padded)
return json.loads(decoded_payload)
+3 -1
View File
@@ -84,7 +84,7 @@ type TunnelToken struct {
}
func (t TunnelToken) Credentials() Credentials {
// nolint: gosimple
// nolint: staticcheck
return Credentials{
AccountTag: t.AccountTag,
TunnelSecret: t.TunnelSecret,
@@ -122,6 +122,7 @@ const (
// ShouldFlush returns whether this kind of connection should actively flush data
func (t Type) shouldFlush() bool {
// nolint: exhaustive
switch t {
case TypeWebsocket, TypeTCP, TypeControlStream:
return true
@@ -131,6 +132,7 @@ func (t Type) shouldFlush() bool {
}
func (t Type) String() string {
// nolint: exhaustive
switch t {
case TypeWebsocket:
return "websocket"
+2 -2
View File
@@ -146,8 +146,8 @@ func wsEchoEndpoint(w ResponseWriter, r *http.Request) error {
case <-wsCtx.Done():
case <-r.Context().Done():
}
readPipe.Close()
writePipe.Close()
_ = readPipe.Close()
_ = writePipe.Close()
}()
originConn := &echoPipe{reader: readPipe, writer: writePipe}
+9
View File
@@ -0,0 +1,9 @@
package dialopts
// DialOpts holds the configuration for dialing a QUIC connection.
type DialOpts struct {
// SkipPortReuse skips UDP port reuse. This is useful for probe connections
// that should use a random ephemeral port to avoid interfering with the
// main connection flow.
SkipPortReuse bool
}
+21 -9
View File
@@ -19,6 +19,9 @@ const (
edgeH2TLSServerName = "h2.cftunnel.com"
// edgeQUICServerName is the server name to establish quic connection with edge.
edgeQUICServerName = "quic.cftunnel.com"
// probeTLSServerName is the server name used for pre-flight connectivity checks.
probeTLSServerName = "probe.cftunnel.com"
quicProtos = "argotunnel"
AutoSelectFlag = "auto"
// SRV and TXT record resolution TTL
ResolveTTL = time.Hour
@@ -69,7 +72,24 @@ func (p Protocol) TLSSettings() *TLSSettings {
case QUIC:
return &TLSSettings{
ServerName: edgeQUICServerName,
NextProtos: []string{"argotunnel"},
NextProtos: []string{quicProtos},
}
default:
return nil
}
}
// ProbeTLSSettings returns TLS settings for pre-flight connectivity checks.
func (p Protocol) ProbeTLSSettings() *TLSSettings {
switch p {
case HTTP2:
return &TLSSettings{
ServerName: probeTLSServerName,
}
case QUIC:
return &TLSSettings{
ServerName: probeTLSServerName,
NextProtos: []string{quicProtos},
}
default:
return nil
@@ -204,18 +224,10 @@ func NewProtocolSelector(
protocolFlag string,
accountTag string,
tunnelTokenProvided bool,
needPQ bool,
protocolFetcher edgediscovery.PercentageFetcher,
resolveTTL time.Duration,
log *zerolog.Logger,
) (ProtocolSelector, error) {
// With --post-quantum, we force quic
if needPQ {
return &staticProtocolSelector{
current: QUIC,
}, nil
}
threshold := switchThreshold(accountTag)
fetchedProtocol, err := getProtocol(ProtocolList, protocolFetcher, threshold)
log.Debug().Msgf("Fetched protocol: %s", fetchedProtocol)
+54 -34
View File
@@ -5,6 +5,7 @@ import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/cloudflare/cloudflared/edgediscovery"
)
@@ -14,15 +15,6 @@ const (
testAccountTag = "testAccountTag"
)
func mockFetcher(getError bool, protocolPercent ...edgediscovery.ProtocolPercent) edgediscovery.PercentageFetcher {
return func() (edgediscovery.ProtocolPercents, error) {
if getError {
return nil, fmt.Errorf("failed to fetch percentage")
}
return protocolPercent, nil
}
}
type dynamicMockFetcher struct {
protocolPercents edgediscovery.ProtocolPercents
err error
@@ -39,7 +31,6 @@ func TestNewProtocolSelector(t *testing.T) {
name string
protocol string
tunnelTokenProvided bool
needPQ bool
expectedProtocol Protocol
hasFallback bool
expectedFallback Protocol
@@ -67,18 +58,6 @@ func TestNewProtocolSelector(t *testing.T) {
hasFallback: true,
expectedFallback: HTTP2,
},
{
name: "named tunnel (post quantum)",
protocol: AutoSelectFlag,
needPQ: true,
expectedProtocol: QUIC,
},
{
name: "named tunnel (post quantum) w/http2",
protocol: "http2",
needPQ: true,
expectedProtocol: QUIC,
},
}
fetcher := dynamicMockFetcher{
@@ -87,16 +66,16 @@ func TestNewProtocolSelector(t *testing.T) {
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
selector, err := NewProtocolSelector(test.protocol, testAccountTag, test.tunnelTokenProvided, test.needPQ, fetcher.fetch(), ResolveTTL, &log)
selector, err := NewProtocolSelector(test.protocol, testAccountTag, test.tunnelTokenProvided, fetcher.fetch(), ResolveTTL, &log)
if test.wantErr {
assert.Error(t, err, fmt.Sprintf("test %s failed", test.name))
assert.Error(t, err, "test %s failed", test.name)
} else {
assert.NoError(t, err, fmt.Sprintf("test %s failed", test.name))
assert.Equal(t, test.expectedProtocol, selector.Current(), fmt.Sprintf("test %s failed", test.name))
require.NoError(t, err, "test %s failed", test.name)
assert.Equalf(t, test.expectedProtocol, selector.Current(), "test %s failed", test.name)
fallback, ok := selector.Fallback()
assert.Equal(t, test.hasFallback, ok, fmt.Sprintf("test %s failed", test.name))
assert.Equalf(t, test.hasFallback, ok, "test %s failed", test.name)
if test.hasFallback {
assert.Equal(t, test.expectedFallback, fallback, fmt.Sprintf("test %s failed", test.name))
assert.Equalf(t, test.expectedFallback, fallback, "test %s failed", test.name)
}
}
})
@@ -105,8 +84,8 @@ func TestNewProtocolSelector(t *testing.T) {
func TestAutoProtocolSelectorRefresh(t *testing.T) {
fetcher := dynamicMockFetcher{}
selector, err := NewProtocolSelector(AutoSelectFlag, testAccountTag, false, false, fetcher.fetch(), testNoTTL, &log)
assert.NoError(t, err)
selector, err := NewProtocolSelector(AutoSelectFlag, testAccountTag, false, fetcher.fetch(), testNoTTL, &log)
require.NoError(t, err)
assert.Equal(t, QUIC, selector.Current())
fetcher.protocolPercents = edgediscovery.ProtocolPercents{edgediscovery.ProtocolPercent{Protocol: "http2", Percentage: 100}}
@@ -135,8 +114,8 @@ func TestAutoProtocolSelectorRefresh(t *testing.T) {
func TestHTTP2ProtocolSelectorRefresh(t *testing.T) {
fetcher := dynamicMockFetcher{}
// Since the user chooses http2 on purpose, we always stick to it.
selector, err := NewProtocolSelector(HTTP2.String(), testAccountTag, false, false, fetcher.fetch(), testNoTTL, &log)
assert.NoError(t, err)
selector, err := NewProtocolSelector(HTTP2.String(), testAccountTag, false, fetcher.fetch(), testNoTTL, &log)
require.NoError(t, err)
assert.Equal(t, HTTP2, selector.Current())
fetcher.protocolPercents = edgediscovery.ProtocolPercents{edgediscovery.ProtocolPercent{Protocol: "http2", Percentage: 100}}
@@ -164,10 +143,51 @@ func TestHTTP2ProtocolSelectorRefresh(t *testing.T) {
func TestAutoProtocolSelectorNoRefreshWithToken(t *testing.T) {
fetcher := dynamicMockFetcher{}
selector, err := NewProtocolSelector(AutoSelectFlag, testAccountTag, true, false, fetcher.fetch(), testNoTTL, &log)
assert.NoError(t, err)
selector, err := NewProtocolSelector(AutoSelectFlag, testAccountTag, true, fetcher.fetch(), testNoTTL, &log)
require.NoError(t, err)
assert.Equal(t, QUIC, selector.Current())
fetcher.protocolPercents = edgediscovery.ProtocolPercents{edgediscovery.ProtocolPercent{Protocol: "http2", Percentage: 100}}
assert.Equal(t, QUIC, selector.Current())
}
func TestProbeTLSSettings(t *testing.T) {
tests := []struct {
name string
protocol Protocol
expectedServer string
expectedProtos []string
expectNil bool
}{
{
name: "HTTP2 returns probe SNI",
protocol: HTTP2,
expectedServer: probeTLSServerName,
expectedProtos: nil,
},
{
name: "QUIC returns probe SNI with alpn",
protocol: QUIC,
expectedServer: probeTLSServerName,
expectedProtos: []string{"argotunnel"},
},
{
name: "Unknown protocol returns nil",
protocol: Protocol(999),
expectNil: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
settings := test.protocol.ProbeTLSSettings()
if test.expectNil {
assert.Nil(t, settings)
} else {
assert.NotNil(t, settings)
assert.Equal(t, test.expectedServer, settings.ServerName)
assert.Equal(t, test.expectedProtos, settings.NextProtos)
}
})
}
}
+20 -26
View File
@@ -11,6 +11,9 @@ import (
"github.com/quic-go/quic-go"
"github.com/rs/zerolog"
"github.com/cloudflare/cloudflared/connection/dialopts"
cfdquic "github.com/cloudflare/cloudflared/quic"
)
var (
@@ -26,8 +29,9 @@ func DialQuic(
localAddr net.IP,
connIndex uint8,
logger *zerolog.Logger,
) (quic.Connection, error) {
udpConn, err := createUDPConnForConnIndex(connIndex, localAddr, edgeAddr, logger)
opts dialopts.DialOpts,
) (cfdquic.QUICConnection, error) {
udpConn, err := createUDPConnForConnIndex(connIndex, localAddr, edgeAddr, opts, logger)
if err != nil {
return nil, err
}
@@ -35,22 +39,15 @@ func DialQuic(
conn, err := quic.Dial(ctx, udpConn, net.UDPAddrFromAddrPort(edgeAddr), tlsConfig, quicConfig)
if err != nil {
// close the udp server socket in case of error connecting to the edge
udpConn.Close()
_ = udpConn.Close()
return nil, &EdgeQuicDialError{Cause: err}
}
// wrap the session, so that the UDPConn is closed after session is closed.
conn = &wrapCloseableConnQuicConnection{
conn,
udpConn,
}
return conn, nil
return cfdquic.NewQUICConnection(conn, udpConn)
}
func createUDPConnForConnIndex(connIndex uint8, localIP net.IP, edgeIP netip.AddrPort, logger *zerolog.Logger) (*net.UDPConn, error) {
portMapMutex.Lock()
defer portMapMutex.Unlock()
func createUDPConnForConnIndex(connIndex uint8, localIP net.IP, edgeIP netip.AddrPort, opts dialopts.DialOpts, logger *zerolog.Logger) (*net.UDPConn, error) {
listenNetwork := "udp"
// https://github.com/quic-go/quic-go/issues/3793 DF bit cannot be set for dual stack listener ("udp") on macOS,
// to set the DF bit properly, the network string needs to be specific to the IP family.
@@ -62,15 +59,24 @@ func createUDPConnForConnIndex(connIndex uint8, localIP net.IP, edgeIP netip.Add
}
}
// Probes skip port reuse entirely to avoid interfering with the main connection flow.
// They use a random ephemeral port for each dial.
if opts.SkipPortReuse {
return net.ListenUDP(listenNetwork, &net.UDPAddr{IP: localIP, Port: 0})
}
portMapMutex.Lock()
defer portMapMutex.Unlock()
// if port was not set yet, it will be zero, so bind will randomly allocate one.
if port, ok := portForConnIndex[connIndex]; ok {
udpConn, err := net.ListenUDP(listenNetwork, &net.UDPAddr{IP: localIP, Port: port})
// if there wasn't an error, or if port was 0 (independently of error or not, just return)
if err == nil {
return udpConn, nil
} else {
logger.Debug().Err(err).Msgf("Unable to reuse port %d for connIndex %d. Falling back to random allocation.", port, connIndex)
}
logger.Debug().Err(err).Msgf("Unable to reuse port %d for connIndex %d. Falling back to random allocation.", port, connIndex)
}
// if we reached here, then there was an error or port as not been allocated it.
@@ -87,15 +93,3 @@ func createUDPConnForConnIndex(connIndex uint8, localIP net.IP, edgeIP netip.Add
return udpConn, err
}
type wrapCloseableConnQuicConnection struct {
quic.Connection
udpConn *net.UDPConn
}
func (w *wrapCloseableConnQuicConnection) CloseWithError(errorCode quic.ApplicationErrorCode, reason string) error {
err := w.Connection.CloseWithError(errorCode, reason)
w.udpConn.Close()
return err
}
+4 -4
View File
@@ -41,7 +41,7 @@ const (
// quicConnection represents the type that facilitates Proxying via QUIC streams.
type quicConnection struct {
conn quic.Connection
conn cfdquic.QUICConnection
logger *zerolog.Logger
orchestrator Orchestrator
datagramHandler DatagramSessionHandler
@@ -54,10 +54,10 @@ type quicConnection struct {
gracePeriod time.Duration
}
// NewTunnelConnection takes a [quic.Connection] to wrap it for use with cloudflared application logic.
// NewTunnelConnection takes a [cfdquic.QUICConnection] to wrap it for use with cloudflared application logic.
func NewTunnelConnection(
ctx context.Context,
conn quic.Connection,
conn cfdquic.QUICConnection,
connIndex uint8,
orchestrator Orchestrator,
datagramSessionHandler DatagramSessionHandler,
@@ -169,7 +169,7 @@ func (q *quicConnection) acceptStream(ctx context.Context) error {
func (q *quicConnection) runStream(quicStream quic.Stream) {
ctx := quicStream.Context()
stream := cfdquic.NewSafeStreamCloser(quicStream, q.streamWriteTimeout, q.logger)
defer stream.Close()
defer func() { _ = stream.Close() }()
// we are going to fuse readers/writers from stream <- cloudflared -> origin, and we want to guarantee that
// code executed in the code path of handleStream don't trigger an earlier close to the downstream write stream.
+102 -11
View File
@@ -29,6 +29,8 @@ import (
"github.com/stretchr/testify/require"
"golang.org/x/net/nettest"
"github.com/cloudflare/cloudflared/connection/dialopts"
"github.com/cloudflare/cloudflared/client"
"github.com/cloudflare/cloudflared/config"
cfdflow "github.com/cloudflare/cloudflared/flow"
@@ -149,7 +151,6 @@ func TestQUICServer(t *testing.T) {
}
for i, test := range tests {
test := test // capture range variable
t.Run(test.desc, func(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
// Start a UDP Listener for QUIC.
@@ -157,7 +158,7 @@ func TestQUICServer(t *testing.T) {
require.NoError(t, err)
udpListener, err := net.ListenUDP(udpAddr.Network(), udpAddr)
require.NoError(t, err)
defer udpListener.Close()
defer func() { _ = udpListener.Close() }()
quicTransport := &quic.Transport{Conn: udpListener, ConnectionIDLength: 16}
quicListener, err := quicTransport.Listen(testTLSServerConfig, testQUICConfig)
require.NoError(t, err)
@@ -499,7 +500,6 @@ func TestBuildHTTPRequest(t *testing.T) {
log := zerolog.Nop()
for _, test := range tests {
test := test // capture range variable
t.Run(test.name, func(t *testing.T) {
req, err := buildHTTPRequest(t.Context(), test.connectRequest, test.body, 0, &log)
require.NoError(t, err)
@@ -525,7 +525,7 @@ func TestServeUDPSession(t *testing.T) {
require.NoError(t, err)
udpListener, err := net.ListenUDP(udpAddr.Network(), udpAddr)
require.NoError(t, err)
defer udpListener.Close()
defer func() { _ = udpListener.Close() }()
ctx, cancel := context.WithCancel(t.Context())
@@ -616,7 +616,7 @@ func TestTCPProxy_FlowRateLimited(t *testing.T) {
udpListener, err := net.ListenUDP(udpAddr.Network(), udpAddr)
require.NoError(t, err)
defer udpListener.Close()
defer func() { _ = udpListener.Close() }()
quicTransport := &quic.Transport{Conn: udpListener, ConnectionIDLength: 16}
quicListener, err := quicTransport.Listen(testTLSServerConfig, testQUICConfig)
@@ -660,7 +660,7 @@ func TestTCPProxy_FlowRateLimited(t *testing.T) {
func testCreateUDPConnReuseSourcePortForEdgeIP(t *testing.T, edgeIP netip.AddrPort) {
logger := zerolog.Nop()
conn, err := createUDPConnForConnIndex(0, nil, edgeIP, &logger)
conn, err := createUDPConnForConnIndex(0, nil, edgeIP, dialopts.DialOpts{}, &logger)
require.NoError(t, err)
getPortFunc := func(conn *net.UDPConn) int {
@@ -671,24 +671,114 @@ func testCreateUDPConnReuseSourcePortForEdgeIP(t *testing.T, edgeIP netip.AddrPo
initialPort := getPortFunc(conn)
// close conn
conn.Close()
_ = conn.Close()
// should get the same port as before.
conn, err = createUDPConnForConnIndex(0, nil, edgeIP, &logger)
conn, err = createUDPConnForConnIndex(0, nil, edgeIP, dialopts.DialOpts{}, &logger)
require.NoError(t, err)
require.Equal(t, initialPort, getPortFunc(conn))
// new index, should get a different port
conn1, err := createUDPConnForConnIndex(1, nil, edgeIP, &logger)
conn1, err := createUDPConnForConnIndex(1, nil, edgeIP, dialopts.DialOpts{}, &logger)
require.NoError(t, err)
require.NotEqual(t, initialPort, getPortFunc(conn1))
// not closing the conn and trying to obtain a new conn for same index should give a different random port
conn, err = createUDPConnForConnIndex(0, nil, edgeIP, &logger)
conn, err = createUDPConnForConnIndex(0, nil, edgeIP, dialopts.DialOpts{}, &logger)
require.NoError(t, err)
require.NotEqual(t, initialPort, getPortFunc(conn))
}
// TestSkipPortReuse tests that skipPortReuse uses a random ephemeral port for each dial.
func TestSkipPortReuse(t *testing.T) {
t.Parallel()
logger := zerolog.Nop()
edgeIP := netip.MustParseAddrPort("127.0.0.1:0")
// First dial with skipPortReuse should allocate a random port
conn1, err := createUDPConnForConnIndex(0, nil, edgeIP, dialopts.DialOpts{SkipPortReuse: true}, &logger)
require.NoError(t, err)
port1 := conn1.LocalAddr().(*net.UDPAddr).Port
// Don't close conn1 yet - keep it open to prevent port reuse
// Second dial with skipPortReuse should allocate a different random port
conn2, err := createUDPConnForConnIndex(0, nil, edgeIP, dialopts.DialOpts{SkipPortReuse: true}, &logger)
require.NoError(t, err)
port2 := conn2.LocalAddr().(*net.UDPAddr).Port
// Now close both connections
_ = conn1.Close()
_ = conn2.Close()
// With skipPortReuse, ports should be different (random allocation)
require.NotEqual(t, port1, port2, "With skipPortReuse, each dial should use a different random port")
}
// TestDialQuicWithSkipPortReuse tests that DialQuic works correctly with the WithSkipPortReuse option.
func TestDialQuicWithSkipPortReuse(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
// Start a mock QUIC server (similar to TestQUICServer)
udpListener, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0})
require.NoError(t, err)
defer func() { _ = udpListener.Close() }()
serverAddr := netip.MustParseAddrPort(udpListener.LocalAddr().String())
quicTransport := &quic.Transport{Conn: udpListener, ConnectionIDLength: 16}
quicListener, err := quicTransport.Listen(testTLSServerConfig, testQUICConfig)
require.NoError(t, err)
serverDone := make(chan struct{})
go func() {
// Accept one connection
session, err := quicListener.Accept(ctx)
if err != nil {
close(serverDone)
return
}
// Keep session open until context is cancelled
<-ctx.Done()
_ = session.CloseWithError(0, "test done")
close(serverDone)
}()
// Test DialQuic with WithSkipPortReuse option
tlsClientConfig := &tls.Config{
// nolint: gosec
InsecureSkipVerify: true,
NextProtos: []string{"argotunnel"},
}
log := zerolog.New(io.Discard)
dialCtx, dialCancel := context.WithTimeout(t.Context(), 5*time.Second)
defer dialCancel()
// Dial with skipPortReuse option - should use a random ephemeral port
conn, err := DialQuic(
dialCtx,
testQUICConfig,
tlsClientConfig,
serverAddr,
nil, // connect on a random port
0,
&log,
dialopts.DialOpts{SkipPortReuse: true},
)
require.NoError(t, err)
require.NotNil(t, conn)
// Verify we can get connection state
_ = conn.ConnectionState()
// Clean up
_ = conn.CloseWithError(0, "test done")
cancel()
<-serverDone
}
func serveSession(ctx context.Context, datagramConn *datagramV2Connection, edgeQUICSession quic.Connection, closeType closeReason, expectedReason string, t *testing.T) {
payload := []byte(t.Name())
sessionID := uuid.New()
@@ -721,7 +811,7 @@ func serveSession(ctx context.Context, datagramConn *datagramV2Connection, edgeQ
// Close connection to terminate session
switch closeType {
case closedByOrigin:
originConn.Close()
_ = originConn.Close()
case closedByRemote:
err = datagramConn.UnregisterUdpSession(ctx, sessionID, expectedReason)
require.NoError(t, err)
@@ -815,6 +905,7 @@ func testTunnelConnection(t *testing.T, serverAddr netip.AddrPort, index uint8)
nil, // connect on a random port
index,
&log,
dialopts.DialOpts{},
)
require.NoError(t, err)
+6 -11
View File
@@ -8,9 +8,7 @@ import (
"time"
"github.com/google/uuid"
"github.com/pkg/errors"
pkgerrors "github.com/pkg/errors"
"github.com/quic-go/quic-go"
"github.com/rs/zerolog"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
@@ -24,7 +22,6 @@ import (
"github.com/cloudflare/cloudflared/packet"
cfdquic "github.com/cloudflare/cloudflared/quic"
"github.com/cloudflare/cloudflared/tracing"
"github.com/cloudflare/cloudflared/tunnelrpc/pogs"
tunnelpogs "github.com/cloudflare/cloudflared/tunnelrpc/pogs"
rpcquic "github.com/cloudflare/cloudflared/tunnelrpc/quic"
)
@@ -34,20 +31,18 @@ const (
demuxChanCapacity = 16
)
var (
errInvalidDestinationIP = errors.New("unable to parse destination IP")
)
var errInvalidDestinationIP = pkgerrors.New("unable to parse destination IP")
// DatagramSessionHandler is a service that can serve datagrams for a connection and handle sessions from incoming
// connection streams.
type DatagramSessionHandler interface {
Serve(context.Context) error
pogs.SessionManager
tunnelpogs.SessionManager
}
type datagramV2Connection struct {
conn quic.Connection
conn cfdquic.QUICConnection
index uint8
// sessionManager tracks active sessions. It receives datagrams from quic connection via datagramMuxer
@@ -69,7 +64,7 @@ type datagramV2Connection struct {
}
func NewDatagramV2Connection(ctx context.Context,
conn quic.Connection,
conn cfdquic.QUICConnection,
originDialer ingress.OriginUDPDialer,
icmpRouter ingress.ICMPRouter,
index uint8,
@@ -166,7 +161,7 @@ func (q *datagramV2Connection) RegisterUdpSession(ctx context.Context, sessionID
session, err := q.sessionManager.RegisterSession(ctx, sessionID, originProxy)
if err != nil {
originProxy.Close()
_ = originProxy.Close()
log.Err(err).Str(datagramsession.LogFieldSessionID, datagramsession.FormatSessionID(sessionID)).Msgf("Failed to register udp session")
tracing.EndWithErrorStatus(registerSpan, err)
q.flowLimiter.Release()
@@ -229,7 +224,7 @@ func (q *datagramV2Connection) closeUDPSession(ctx context.Context, sessionID uu
}
stream := cfdquic.NewSafeStreamCloser(quicStream, q.streamWriteTimeout, q.logger)
defer stream.Close()
defer func() { _ = stream.Close() }()
rpcClientStream, err := rpcquic.NewSessionClient(ctx, stream, q.rpcTimeout)
if err != nil {
// Log this at debug because this is not an error if session was closed due to lost connection
+9 -9
View File
@@ -7,12 +7,12 @@ import (
"github.com/google/uuid"
"github.com/pkg/errors"
"github.com/quic-go/quic-go"
"github.com/rs/zerolog"
"github.com/cloudflare/cloudflared/ingress"
"github.com/cloudflare/cloudflared/management"
cfdquic "github.com/cloudflare/cloudflared/quic/v3"
cfdquic "github.com/cloudflare/cloudflared/quic"
cfdquicv3 "github.com/cloudflare/cloudflared/quic/v3"
"github.com/cloudflare/cloudflared/tunnelrpc/pogs"
)
@@ -22,20 +22,20 @@ var (
)
type datagramV3Connection struct {
conn quic.Connection
conn cfdquic.QUICConnection
index uint8
// datagramMuxer mux/demux datagrams from quic connection
datagramMuxer cfdquic.DatagramConn
metrics cfdquic.Metrics
datagramMuxer cfdquicv3.DatagramConn
metrics cfdquicv3.Metrics
logger *zerolog.Logger
}
func NewDatagramV3Connection(ctx context.Context,
conn quic.Connection,
sessionManager cfdquic.SessionManager,
conn cfdquic.QUICConnection,
sessionManager cfdquicv3.SessionManager,
icmpRouter ingress.ICMPRouter,
index uint8,
metrics cfdquic.Metrics,
metrics cfdquicv3.Metrics,
logger *zerolog.Logger,
) DatagramSessionHandler {
log := logger.
@@ -43,7 +43,7 @@ func NewDatagramV3Connection(ctx context.Context,
Int(management.EventTypeKey, int(management.UDP)).
Uint8(LogFieldConnIndex, index).
Logger()
datagramMuxer := cfdquic.NewDatagramConn(conn, sessionManager, icmpRouter, index, metrics, &log)
datagramMuxer := cfdquicv3.NewDatagramConn(conn, sessionManager, icmpRouter, index, metrics, &log)
return &datagramV3Connection{
conn,
+79
View File
@@ -0,0 +1,79 @@
package crypto
import (
"crypto/tls"
"errors"
"fmt"
"slices"
"github.com/cloudflare/cloudflared/features"
)
// errUnknownPostQuantumMode is returned by GetCurvePreferences when the
// caller passes a features.PostQuantumMode value that is not one of the
// documented constants. It is intentionally unexported: callers should treat
// any non-nil error as a programming mistake rather than inspecting it.
var errUnknownPostQuantumMode = errors.New("the provided post quantum mode is unknown")
// P256Kyber768Draft00 is a post-quantum KEM based on Kyber768.
const P256Kyber768Draft00 = tls.CurveID(0xfe32) // ID 65074
// Canonical curve lists returned by GetCurvePreferences. They are kept
// package-private so that callers cannot accidentally mutate the shared
// slice; GetCurvePreferences always returns a clone.
var (
// postQuantumStrictCurves is used when the caller requires a
// post-quantum handshake. Only PQ curves (X25519MLKEM768 and the
// deprecated P256Kyber768Draft00 for backward compatibility) are
// advertised; no classical-only curve is included.
postQuantumStrictCurves = []tls.CurveID{tls.X25519MLKEM768, P256Kyber768Draft00}
// postQuantumPreferCurves is used for the default "prefer" mode: the PQ
// curve is advertised first and the classical CurveP256 is listed as a
// fallback so peers without PQ support can still negotiate.
postQuantumPreferCurves = []tls.CurveID{tls.X25519MLKEM768, P256Kyber768Draft00, tls.CurveP256}
)
// getCurvePreferences returns the TLS curve preferences that should be
// applied to edge-facing connections for the given post-quantum mode.
//
// The returned slice is the canonical, protocol-agnostic curve list and is
// suitable for direct assignment to tls.Config.CurvePreferences. A fresh
// slice is returned on every call, so callers may mutate it freely without
// affecting other callers.
//
// An error is returned only when profile is not a recognised
// features.PostQuantumMode value, which indicates a programming bug in the
// caller.
func getCurvePreferences(profile features.PostQuantumMode) ([]tls.CurveID, error) {
switch profile {
case features.PostQuantumPrefer:
return slices.Clone(postQuantumPreferCurves), nil
case features.PostQuantumStrict:
return slices.Clone(postQuantumStrictCurves), nil
}
return nil, errUnknownPostQuantumMode
}
// TLSConfigWithCurvePreferences clones the provided tls.Config and applies
// curve preferences based on the given post-quantum mode.
//
// The original tls.Config is never modified; a clone is returned so that
// callers can safely use the same base configuration across multiple
// goroutines without racing on CurvePreferences.
//
// Returns an error only when pqMode is not a recognised
// features.PostQuantumMode value.
func TLSConfigWithCurvePreferences(tlsConfig *tls.Config, pqMode features.PostQuantumMode) (*tls.Config, error) {
// Clone the TLS config before applying per-connection curve
// preferences. The TlsConfig may be shared across goroutines;
// mutating it directly would race with concurrent connection attempts.
config := tlsConfig.Clone()
curvePref, err := getCurvePreferences(pqMode)
if err != nil {
return nil, fmt.Errorf("get curve preferences: %w", err)
}
config.CurvePreferences = curvePref
return config, nil
}
+126
View File
@@ -0,0 +1,126 @@
package crypto
import (
"crypto/tls"
"net/http"
"net/http/httptest"
"runtime"
"slices"
"testing"
"github.com/stretchr/testify/require"
"github.com/cloudflare/cloudflared/features"
)
// TestCurvePreferences verifies that GetCurvePreferences returns the
// documented curve list for each supported PostQuantumMode. The expected
// values correspond to the contract described in the package documentation
// and must be identical under FIPS and non-FIPS builds (see TUN-10413).
func TestCurvePreferences(t *testing.T) {
t.Parallel()
tests := []struct {
name string
expectedCurves []tls.CurveID
pqMode features.PostQuantumMode
}{
{
name: "Prefer PQ",
pqMode: features.PostQuantumPrefer,
expectedCurves: []tls.CurveID{tls.X25519MLKEM768, P256Kyber768Draft00, tls.CurveP256},
},
{
name: "Strict PQ",
pqMode: features.PostQuantumStrict,
expectedCurves: []tls.CurveID{tls.X25519MLKEM768, P256Kyber768Draft00},
},
}
for _, tcase := range tests {
t.Run(tcase.name, func(t *testing.T) {
t.Parallel()
curves, err := getCurvePreferences(tcase.pqMode)
require.NoError(t, err)
require.Equal(t, tcase.expectedCurves, curves)
})
}
}
// TestCurvePreferenceUnknownMode asserts that passing a PostQuantumMode
// value outside of the documented constants produces an error instead of
// silently returning a nil or default curve list. This protects callers
// from accidentally negotiating with an unintended curve set.
func TestCurvePreferenceUnknownMode(t *testing.T) {
t.Parallel()
_, err := getCurvePreferences(features.PostQuantumMode(255))
require.Error(t, err)
}
// TestReturnedSliceIsIndependent ensures GetCurvePreferences returns a
// fresh slice on every call, so that callers cannot corrupt the
// package-level defaults by mutating the result.
func TestReturnedSliceIsIndependent(t *testing.T) {
t.Parallel()
first, err := getCurvePreferences(features.PostQuantumPrefer)
require.NoError(t, err)
// Mutate the returned slice.
first[0] = tls.CurveP521
second, err := getCurvePreferences(features.PostQuantumPrefer)
require.NoError(t, err)
require.Equal(t, tls.X25519MLKEM768, second[0], "package defaults must not be affected by caller mutation")
}
// runClientServerHandshake drives a TLS 1.3 handshake with the given curve
// preferences set on the client and captures the SupportedCurves list
// advertised by the client in its ClientHello. The helper is used by
// TestSupportedCurvesNegotiation to exercise the curves end-to-end against
// the standard library's TLS stack.
func runClientServerHandshake(t *testing.T, curves []tls.CurveID) []tls.CurveID {
var advertisedCurves []tls.CurveID
ts := httptest.NewUnstartedServer(nil)
ts.TLS = &tls.Config{ // nolint: gosec
GetConfigForClient: func(chi *tls.ClientHelloInfo) (*tls.Config, error) {
advertisedCurves = slices.Clone(chi.SupportedCurves)
return nil, nil
},
}
ts.StartTLS()
defer ts.Close()
clientTLSConfig := ts.Client().Transport.(*http.Transport).TLSClientConfig
clientTLSConfig.CurvePreferences = curves
resp, err := ts.Client().Head(ts.URL)
if err != nil {
t.Error(err)
return nil
}
defer func() { _ = resp.Body.Close() }()
return advertisedCurves
}
// TestSupportedCurvesNegotiation verifies that the curves returned by
// GetCurvePreferences survive a real TLS handshake unchanged, i.e. the
// standard library advertises exactly the curves we expect. Currently only
// PostQuantumPrefer is exercised because PostQuantumStrict would cause the
// handshake to fail against httptest servers that do not support
// X25519MLKEM768 server-side.
func TestSupportedCurvesNegotiation(t *testing.T) {
t.Parallel()
for _, tcase := range []features.PostQuantumMode{features.PostQuantumPrefer} {
curves, err := getCurvePreferences(tcase)
require.NoError(t, err)
advertisedCurves := runClientServerHandshake(t, curves)
require.True(t, slices.Contains(advertisedCurves, tls.CurveP256))
require.True(t, slices.Contains(advertisedCurves, tls.X25519MLKEM768))
expectedLength := 2
if runtime.GOOS == "linux" {
// P256Kyber768Draft00 only exists in linux
require.True(t, slices.Contains(advertisedCurves, P256Kyber768Draft00))
expectedLength = 3
}
require.Len(t, advertisedCurves, expectedLength)
}
}
+35
View File
@@ -0,0 +1,35 @@
// Package crypto centralizes the cryptographic primitives and TLS
// configuration used by cloudflared when establishing connections to the
// Cloudflare edge.
//
// The primary responsibility of the package is to expose a single, canonical
// source of TLS curve preferences so that every edge-facing transport (QUIC
// and HTTP/2) negotiates the same key-exchange algorithms regardless of the
// code path that sets up the connection.
//
// # Post-Quantum key exchange
//
// cloudflared supports the X25519MLKEM768 hybrid post-quantum key exchange.
// Two operating modes are exposed via the features.PostQuantumMode flag:
//
// - PostQuantumPrefer: advertise X25519MLKEM768 and the deprecated
// P256Kyber768Draft00 first, then fall back to the classical CurveP256
// if the peer does not support either PQ curve. This is the default
// used for every outbound edge connection.
// - PostQuantumStrict: advertise only the PQ curves (X25519MLKEM768 and
// P256Kyber768Draft00). Activated by the user via the --post-quantum
// CLI flag. No classical fallback is offered, so a peer that does not
// support any PQ curve will fail the handshake.
//
// The resulting curve lists are identical under FIPS and non-FIPS builds,
// which is why GetCurvePreferences does not take a FIPS toggle. If that
// property ever changes (for example, if a curve stops being FIPS-approved),
// the divergence should be expressed inside this package so callers remain
// unchanged.
//
// # Thread-safety
//
// GetCurvePreferences returns a fresh slice on every call. Callers are free
// to mutate the returned slice without affecting the package-level defaults
// or other callers.
package crypto
+1
View File
@@ -34,4 +34,5 @@ const (
cliConfigurationBaseName = "cli-configuration.json"
configurationBaseName = "configuration.json"
taskResultBaseName = "task-result.json"
prechecksBaseName = "prechecks.json"
)
+65 -9
View File
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"io"
"net"
"net/url"
"os"
"path/filepath"
@@ -16,6 +17,8 @@ import (
"github.com/rs/zerolog"
network "github.com/cloudflare/cloudflared/diagnostic/network"
"github.com/cloudflare/cloudflared/edgediscovery/allregions"
"github.com/cloudflare/cloudflared/prechecks"
)
const (
@@ -32,6 +35,7 @@ const (
networkInformationJobName = "network information"
cliConfigurationJobName = "cli configuration"
configurationJobName = "configuration"
prechecksJobName = "connectivity pre-checks"
)
// Struct used to hold the results of different routines executing the network collection.
@@ -92,6 +96,7 @@ type Options struct {
Address string
ContainerID string
PodID string
Region string
Toggles Toggles
}
@@ -126,13 +131,14 @@ func collectLogs(
if err != nil {
return "", fmt.Errorf("error opening log file while collecting logs: %w", err)
}
defer logHandle.Close()
defer func() { _ = logHandle.Close() }()
// nolint: gosec
outputLogHandle, err := os.Create(filepath.Join(os.TempDir(), logFilename))
if err != nil {
return "", ErrCreatingTemporaryFile
}
defer outputLogHandle.Close()
defer func() { _ = outputLogHandle.Close() }()
_, err = io.Copy(outputLogHandle, logHandle)
if err != nil {
@@ -229,12 +235,13 @@ func networkInformationCollectors() (rawNetworkCollector, jsonNetworkCollector c
}
func rawNetworkInformationWriter(resultMap map[string]networkCollectionResult) (string, error) {
// nolint: gosec // Intentionally creating a temporary diagnostic file in the OS temp directory.
networkDumpHandle, err := os.Create(filepath.Join(os.TempDir(), rawNetworkBaseName))
if err != nil {
return "", ErrCreatingTemporaryFile
}
defer networkDumpHandle.Close()
defer func() { _ = networkDumpHandle.Close() }()
var exitErr error
@@ -260,12 +267,13 @@ func rawNetworkInformationWriter(resultMap map[string]networkCollectionResult) (
}
func jsonNetworkInformationWriter(resultMap map[string]networkCollectionResult) (string, error) {
// nolint: gosec
networkDumpHandle, err := os.Create(filepath.Join(os.TempDir(), networkBaseName))
if err != nil {
return "", ErrCreatingTemporaryFile
}
defer networkDumpHandle.Close()
defer func() { _ = networkDumpHandle.Close() }()
encoder := newFormattedEncoder(networkDumpHandle)
@@ -290,11 +298,12 @@ func jsonNetworkInformationWriter(resultMap map[string]networkCollectionResult)
func collectFromEndpointAdapter(collect collectToWriterFunc, fileName string) collectFunc {
return func(ctx context.Context) (string, error) {
// nolint: gosec
dumpHandle, err := os.Create(filepath.Join(os.TempDir(), fileName))
if err != nil {
return "", ErrCreatingTemporaryFile
}
defer dumpHandle.Close()
defer func() { _ = dumpHandle.Close() }()
err = collect(ctx, dumpHandle)
if err != nil {
@@ -349,12 +358,12 @@ func resolveInstanceBaseURL(
if !strings.HasPrefix(metricsServerAddress, "http://") {
metricsServerAddress = "http://" + metricsServerAddress
}
url, err := url.Parse(metricsServerAddress)
baseUrl, err := url.Parse(metricsServerAddress)
if err != nil {
return nil, nil, nil, fmt.Errorf("provided address is not valid: %w", err)
}
return url, nil, nil, nil
return baseUrl, nil, nil, nil
}
tunnelState, foundTunnelStates, err := FindMetricsServer(log, client, addresses)
@@ -368,6 +377,7 @@ func resolveInstanceBaseURL(
func createJobs(
client *httpClient,
tunnel *TunnelState,
region string,
diagContainer string,
diagPod string,
noDiagSystem bool,
@@ -430,17 +440,62 @@ func createJobs(
fn: collectFromEndpointAdapter(client.GetTunnelConfiguration, configurationBaseName),
bypass: false,
},
{
jobName: prechecksJobName,
fn: collectPrechecks(region),
bypass: noDiagNetwork,
},
}
return jobs
}
// collectPrechecks runs connectivity pre-checks and writes the results to a JSON file.
func collectPrechecks(region string) collectFunc {
return func(ctx context.Context) (string, error) {
cfg := prechecks.Config{
Region: region,
IPVersion: allregions.Auto,
Timeout: defaultTimeout,
}
// Create a no-op logger since we don't want to spam logs during diagnostic collection
log := zerolog.New(io.Discard)
dialers := prechecks.RunDialers{
DNSResolver: &prechecks.EdgeDNSResolver{Log: &log},
TCPDialer: &prechecks.EdgeTCPDialer{},
QUICDialer: &prechecks.EdgeQUICDialer{},
ManagementDialer: &prechecks.NetManagementDialer{Dialer: net.Dialer{}},
}
emptyCert := ""
report := prechecks.Run(ctx, emptyCert, cfg, &log, dialers)
// Write the report to a JSON file
// nolint: gosec
dumpHandle, err := os.Create(filepath.Join(os.TempDir(), prechecksBaseName))
if err != nil {
return "", ErrCreatingTemporaryFile
}
defer func() { _ = dumpHandle.Close() }()
encoder := newFormattedEncoder(dumpHandle)
if err := encoder.Encode(report); err != nil {
return dumpHandle.Name(), fmt.Errorf("error encoding prechecks report: %w", err)
}
return dumpHandle.Name(), nil
}
}
func createTaskReport(taskReport map[string]taskResult) (string, error) {
// nolint: gosec
dumpHandle, err := os.Create(filepath.Join(os.TempDir(), taskResultBaseName))
if err != nil {
return "", ErrCreatingTemporaryFile
}
defer dumpHandle.Close()
defer func() { _ = dumpHandle.Close() }()
encoder := newFormattedEncoder(dumpHandle)
@@ -522,6 +577,7 @@ func RunDiagnostic(
jobs := createJobs(
client,
tunnel,
options.Region,
options.ContainerID,
options.PodID,
options.Toggles.NoDiagSystem,
@@ -545,7 +601,7 @@ func RunDiagnostic(
defer func() {
if !errors.Is(v.Err, ErrCreatingTemporaryFile) {
os.Remove(v.path)
_ = os.Remove(v.path)
}
}()
}
+4 -4
View File
@@ -20,18 +20,18 @@ func NewDockerLogCollector(containerID string) *DockerLogCollector {
}
func (collector *DockerLogCollector) Collect(ctx context.Context) (*LogInformation, error) {
tmp := os.TempDir()
outputHandle, err := os.Create(filepath.Join(tmp, logFilename))
// nolint: gosec
outputHandle, err := os.Create(filepath.Join(os.TempDir(), logFilename))
if err != nil {
return nil, fmt.Errorf("error opening output file: %w", err)
}
defer outputHandle.Close()
defer func() { _ = outputHandle.Close() }()
// Calculate 2 weeks ago
since := time.Now().Add(twoWeeksOffset).Format(time.RFC3339)
// nolint: gosec
command := exec.CommandContext(
ctx,
"docker",
+3 -5
View File
@@ -13,7 +13,6 @@ const (
linuxManagedLogsPath = "/var/log/cloudflared.err"
darwinManagedLogsPath = "/Library/Logs/com.cloudflare.cloudflared.err.log"
linuxServiceConfigurationPath = "/etc/systemd/system/cloudflared.service"
linuxSystemdPath = "/run/systemd/system"
)
type HostLogCollector struct {
@@ -27,14 +26,13 @@ func NewHostLogCollector(client HTTPClient) *HostLogCollector {
}
func extractLogsFromJournalCtl(ctx context.Context) (*LogInformation, error) {
tmp := os.TempDir()
outputHandle, err := os.Create(filepath.Join(tmp, logFilename))
// nolint: gosec
outputHandle, err := os.Create(filepath.Join(os.TempDir(), logFilename))
if err != nil {
return nil, fmt.Errorf("error opening output file: %w", err)
}
defer outputHandle.Close()
defer func() { _ = outputHandle.Close() }()
command := exec.CommandContext(
ctx,
+5 -3
View File
@@ -22,18 +22,19 @@ func NewKubernetesLogCollector(containerID, pod string) *KubernetesLogCollector
}
func (collector *KubernetesLogCollector) Collect(ctx context.Context) (*LogInformation, error) {
tmp := os.TempDir()
outputHandle, err := os.Create(filepath.Join(tmp, logFilename))
// nolint: gosec
outputHandle, err := os.Create(filepath.Join(os.TempDir(), logFilename))
if err != nil {
return nil, fmt.Errorf("error opening output file: %w", err)
}
defer outputHandle.Close()
defer func() { _ = outputHandle.Close() }()
var command *exec.Cmd
// Calculate 2 weeks ago
since := time.Now().Add(twoWeeksOffset).Format(time.RFC3339)
if collector.containerID != "" {
// nolint: gosec
command = exec.CommandContext(
ctx,
"kubectl",
@@ -47,6 +48,7 @@ func (collector *KubernetesLogCollector) Collect(ctx context.Context) (*LogInfor
collector.containerID,
)
} else {
// nolint: gosec
command = exec.CommandContext(
ctx,
"kubectl",
+13 -9
View File
@@ -67,6 +67,8 @@ func PipeCommandOutputToFile(command *exec.Cmd, outputHandle *os.File) (*LogInfo
}
func CopyFilesFromDirectory(path string) (string, error) {
const defaultLogFilename = "cloudflared.log"
// rolling logs have as suffix the current date thus
// when iterating the path files they are already in
// chronological order
@@ -75,30 +77,32 @@ func CopyFilesFromDirectory(path string) (string, error) {
return "", fmt.Errorf("error reading directory %s: %w", path, err)
}
// nolint: gosec
outputHandle, err := os.Create(filepath.Join(os.TempDir(), logFilename))
if err != nil {
return "", fmt.Errorf("creating file %s: %w", outputHandle.Name(), err)
return "", fmt.Errorf("creating temporary log file %s: %w", logFilename, err)
}
defer outputHandle.Close()
defer func() { _ = outputHandle.Close() }()
for _, file := range files {
// nolint: gosec
logHandle, err := os.Open(filepath.Join(path, file.Name()))
if err != nil {
return "", fmt.Errorf("error opening file %s:%w", file.Name(), err)
return "", fmt.Errorf("error opening file %s: %w", file.Name(), err)
}
defer logHandle.Close()
_, err = io.Copy(outputHandle, logHandle)
_ = logHandle.Close()
if err != nil {
return "", fmt.Errorf("error copying file %s:%w", logHandle.Name(), err)
return "", fmt.Errorf("error copying file %s: %w", file.Name(), err)
}
}
logHandle, err := os.Open(filepath.Join(path, "cloudflared.log"))
// nolint: gosec
logHandle, err := os.Open(filepath.Join(path, defaultLogFilename))
if err != nil {
return "", fmt.Errorf("error opening file %s:%w", logHandle.Name(), err)
return "", fmt.Errorf("error opening file %s:%w", defaultLogFilename, err)
}
defer logHandle.Close()
defer func() { _ = logHandle.Close() }()
_, err = io.Copy(outputHandle, logHandle)
if err != nil {
+1 -1
View File
@@ -109,7 +109,7 @@ var friendlyDNSErrorLines = []string{
}
// EdgeDiscovery implements HA service discovery lookup.
func edgeDiscovery(log *zerolog.Logger, srvService string) ([][]*EdgeAddr, error) {
func EdgeDiscovery(log *zerolog.Logger, srvService string) ([][]*EdgeAddr, error) {
logger := log.With().Int(management.EventTypeKey, int(management.Cloudflared)).Logger()
logger.Debug().
Int(management.EventTypeKey, int(management.Cloudflared)).
+3 -2
View File
@@ -6,6 +6,7 @@ import (
"github.com/rs/zerolog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func (ea *EdgeAddr) String() string {
@@ -25,8 +26,8 @@ func TestEdgeDiscovery(t *testing.T) {
}
l := zerolog.Nop()
addrLists, err := edgeDiscovery(&l, "")
assert.NoError(t, err)
addrLists, err := EdgeDiscovery(&l, "")
require.NoError(t, err)
actualAddrSet := map[string]bool{}
for _, addrs := range addrLists {
for _, addr := range addrs {
+8 -5
View File
@@ -20,7 +20,7 @@ type Regions struct {
// ResolveEdge resolves the Cloudflare edge, returning all regions discovered.
func ResolveEdge(log *zerolog.Logger, region string, overrideIPVersion ConfigIPVersion) (*Regions, error) {
edgeAddrs, err := edgeDiscovery(log, getRegionalServiceName(region))
edgeAddrs, err := EdgeDiscovery(log, RegionalServiceName(region))
if err != nil {
return nil, err
}
@@ -91,6 +91,7 @@ func (rs *Regions) GetUnusedAddr(excluding *EdgeAddr, connID int) *EdgeAddr {
// evenly across both regions.
if rs.region1.AvailableAddrs() == rs.region2.AvailableAddrs() {
regions := []Region{rs.region1, rs.region2}
//nolint:gosec
firstChoice := rand.Intn(2)
return getAddrs(excluding, connID, &regions[firstChoice], &regions[1-firstChoice])
}
@@ -131,11 +132,13 @@ func (rs *Regions) GiveBack(addr *EdgeAddr, hasConnectivityError bool) bool {
return rs.region2.GiveBack(addr, hasConnectivityError)
}
// Return regionalized service name if `region` isn't empty, otherwise return the global service name for origintunneld
func getRegionalServiceName(region string) string {
// RegionalServiceName returns the SRV service name for the given region.
// When region is empty it returns the global service name ("v2-origintunneld").
// Otherwise, it prepends the region, e.g. "us-v2-origintunneld".
func RegionalServiceName(region string) string {
if region != "" {
return region + "-" + srvService // Example: `us-v2-origintunneld`
return region + "-" + srvService
}
return srvService // Global service is just `v2-origintunneld`
return srvService
}
+4 -6
View File
@@ -237,21 +237,19 @@ func TestNewNoResolveBalancesRegions(t *testing.T) {
}
}
func TestGetRegionalServiceName(t *testing.T) {
func TestRegionalServiceName(t *testing.T) {
// Empty region should just go to origintunneld
globalServiceName := getRegionalServiceName("")
assert.Equal(t, srvService, globalServiceName)
assert.Equal(t, srvService, RegionalServiceName(""))
// Non-empty region should go to the regional origintunneld variant
for _, region := range []string{"us", "pt", "am"} {
regionalServiceName := getRegionalServiceName(region)
assert.Equal(t, region+"-"+srvService, regionalServiceName)
assert.Equal(t, region+"-"+srvService, RegionalServiceName(region))
}
}
func RegionsIsBalanced(t *testing.T, rs *Regions) {
delta := rs.region1.AvailableAddrs() - rs.region2.AvailableAddrs()
assert.True(t, abs(delta) <= 1)
assert.LessOrEqual(t, abs(delta), 1)
}
func abs(x int) int {
+4
View File
@@ -42,6 +42,10 @@ type FeatureSnapshot struct {
// We provide the list of features since we need it to send in the ConnectionOptions during connection
// registrations.
FeaturesList []string
// SkipPrechecks indicates when to skip connectivity pre-checks at startup.
// Controlled via DNS TXT record to allow remote kill-switch in case of issues.
SkipPrechecks bool
}
type PostQuantumMode uint8
+9 -1
View File
@@ -24,6 +24,7 @@ const (
type featuresRecord struct {
DatagramV3Percentage uint32 `json:"dv3_2"`
SkipPrechecks bool `json:"skip_prechecks"`
// DatagramV3Percentage int32 `json:"dv3"` // Removed in TUN-9291
// DatagramV3Percentage uint32 `json:"dv3_1"` // Removed in TUN-9883
@@ -89,6 +90,7 @@ func (fs *featureSelector) Snapshot() FeatureSnapshot {
PostQuantum: fs.postQuantumMode(),
DatagramVersion: fs.datagramVersion(),
FeaturesList: fs.clientFeatures(),
SkipPrechecks: fs.prechecksSkip(),
}
}
@@ -121,6 +123,12 @@ func (fs *featureSelector) datagramVersion() DatagramVersion {
return DatagramV2
}
// prechecksSkip returns whether prechecks are enabled via DNS flag.
// Defaults to false if not set in the DNS TXT record.
func (fs *featureSelector) prechecksSkip() bool {
return fs.remoteFeatures.SkipPrechecks
}
// clientFeatures will return the list of currently available features that cloudflared should provide to the edge.
func (fs *featureSelector) clientFeatures() []string {
// Evaluate any remote features along with static feature list to construct the list of features
@@ -186,7 +194,7 @@ func (dr *dnsResolver) lookupRecord(ctx context.Context) ([]byte, error) {
}
if len(records) == 0 {
return nil, fmt.Errorf("No TXT record found for %s to determine which features to opt-in", featureSelectorHostname)
return nil, fmt.Errorf("no TXT record found for %s to determine which features to opt-in", featureSelectorHostname)
}
return []byte(records[0]), nil
+36 -28
View File
@@ -1,17 +1,18 @@
module github.com/cloudflare/cloudflared
go 1.24
go 1.26
require (
github.com/coreos/go-oidc/v3 v3.10.0
github.com/cloudflare/backoff v0.0.0-20240920015135-e46b80a3a7d0
github.com/coreos/go-oidc/v3 v3.17.0
github.com/coreos/go-systemd/v22 v22.5.0
github.com/facebookgo/grace v0.0.0-20180706040059-75cf19382434
github.com/fortytw2/leaktest v1.3.0
github.com/fsnotify/fsnotify v1.4.9
github.com/getsentry/sentry-go v0.16.0
github.com/go-chi/chi/v5 v5.2.2
github.com/getsentry/sentry-go v0.43.0
github.com/go-chi/chi/v5 v5.3.1
github.com/go-chi/cors v1.2.1
github.com/go-jose/go-jose/v4 v4.1.0
github.com/go-jose/go-jose/v4 v4.1.4
github.com/gobwas/ws v1.2.1
github.com/google/gopacket v1.1.19
github.com/google/uuid v1.6.0
@@ -24,22 +25,23 @@ require (
github.com/prometheus/client_model v0.6.2
github.com/quic-go/quic-go v0.52.0
github.com/rs/zerolog v1.20.0
github.com/stretchr/testify v1.10.0
github.com/shirou/gopsutil/v4 v4.26.3
github.com/stretchr/testify v1.11.1
github.com/urfave/cli/v2 v2.3.0
go.opentelemetry.io/contrib/propagators v0.22.0
go.opentelemetry.io/otel v1.35.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.26.0
go.opentelemetry.io/otel/sdk v1.35.0
go.opentelemetry.io/otel/trace v1.35.0
go.opentelemetry.io/proto/otlp v1.2.0
go.opentelemetry.io/otel v1.43.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0
go.opentelemetry.io/otel/sdk v1.43.0
go.opentelemetry.io/otel/trace v1.43.0
go.opentelemetry.io/proto/otlp v1.10.0
go.uber.org/automaxprocs v1.6.0
go.uber.org/mock v0.5.1
golang.org/x/crypto v0.38.0
golang.org/x/net v0.40.0
golang.org/x/sync v0.14.0
golang.org/x/sys v0.33.0
golang.org/x/term v0.32.0
google.golang.org/protobuf v1.36.6
golang.org/x/crypto v0.52.0
golang.org/x/net v0.55.0
golang.org/x/sync v0.20.0
golang.org/x/sys v0.45.0
golang.org/x/term v0.43.0
google.golang.org/protobuf v1.36.11
gopkg.in/natefinch/lumberjack.v2 v2.0.0
gopkg.in/yaml.v3 v3.0.1
nhooyr.io/websocket v1.8.7
@@ -53,6 +55,7 @@ require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/ebitengine/purego v0.10.0 // indirect
github.com/facebookgo/ensure v0.0.0-20160127193407-b4ab57deab51 // indirect
github.com/facebookgo/freeport v0.0.0-20150612182905-d4adf43b75b9 // indirect
github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 // indirect
@@ -60,14 +63,16 @@ require (
github.com/gin-gonic/gin v1.9.1 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-playground/validator/v10 v10.15.1 // indirect
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/google/pprof v0.0.0-20250418163039-24c5476c6587 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.5 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
@@ -75,21 +80,24 @@ require (
github.com/onsi/ginkgo/v2 v2.23.4 // indirect
github.com/pelletier/go-toml/v2 v2.0.9 // 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/prometheus/common v0.64.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/tinylib/msgp v1.6.3 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/otel/metric v1.35.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.35.0 // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
golang.org/x/arch v0.4.0 // indirect
golang.org/x/mod v0.24.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/text v0.25.0 // indirect
golang.org/x/tools v0.32.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250512202823-5a2f75b736a9 // indirect
google.golang.org/grpc v1.72.2 // indirect
golang.org/x/mod v0.35.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/tools v0.44.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
google.golang.org/grpc v1.81.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)
+78 -56
View File
@@ -11,12 +11,14 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chungthuang/quic-go v0.45.1-0.20250428085412-43229ad201fd h1:VdYI5zFQ2h1/qzoC6rhyPx479bkF8i177Qpg4Q2n1vk=
github.com/chungthuang/quic-go v0.45.1-0.20250428085412-43229ad201fd/go.mod h1:MFlGGpcpJqRAfmYi6NC2cptDPSxRWTOGNuP4wqrWmzQ=
github.com/cloudflare/backoff v0.0.0-20240920015135-e46b80a3a7d0 h1:pRcxfaAlK0vR6nOeQs7eAEvjJzdGXl8+KaBlcvpQTyQ=
github.com/cloudflare/backoff v0.0.0-20240920015135-e46b80a3a7d0/go.mod h1:rzgs2ZOiguV6/NpiDgADjRLPNyZlApIWxKpkT+X8SdY=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/coreos/go-oidc/v3 v3.10.0 h1:tDnXHnLyiTVyT/2zLDGj09pFPkhND8Gl8lnTRhoEaJU=
github.com/coreos/go-oidc/v3 v3.10.0/go.mod h1:5j11xcw0D3+SGxn6Z/WFADsgcWVMyNAlSQupk0KK3ac=
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
@@ -27,6 +29,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/facebookgo/ensure v0.0.0-20160127193407-b4ab57deab51 h1:0JZ+dUmQeA8IIVUMzysrX4/AKuQwWhV2dYQuPZdvdSQ=
github.com/facebookgo/ensure v0.0.0-20160127193407-b4ab57deab51/go.mod h1:Yg+htXGokKKdzcwhuNDwVvN+uBxDGXJ7G/VN1d8fa64=
github.com/facebookgo/freeport v0.0.0-20150612182905-d4adf43b75b9 h1:wWke/RUCl7VRjQhwPlR/v0glZXNYzBHdNUzf/Am2Nmg=
@@ -43,26 +47,28 @@ github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWo
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/getsentry/sentry-go v0.16.0 h1:owk+S+5XcgJLlGR/3+3s6N4d+uKwqYvh/eS0AIMjPWo=
github.com/getsentry/sentry-go v0.16.0/go.mod h1:ZXCloQLj0pG7mja5NK6NPf2V4A88YJ4pNlc2mOHwh6Y=
github.com/getsentry/sentry-go v0.43.0 h1:XbXLpFicpo8HmBDaInk7dum18G9KSLcjZiyUKS+hLW4=
github.com/getsentry/sentry-go v0.43.0/go.mod h1:XDotiNZbgf5U8bPDUAfvcFmOnMQQceESxyKaObSssW0=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618=
github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-jose/go-jose/v4 v4.1.0 h1:cYSYxd3pw5zd2FSXk2vGdn9igQU2PS8MuxrCOCl0FdY=
github.com/go-jose/go-jose/v4 v4.1.0/go.mod h1:GG/vqmYm3Von2nYiB2vGTXzdoNKE5tix5tuc6iAd+sw=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -105,8 +111,8 @@ github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+
github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/ipostelnik/cli/v2 v2.3.1-0.20210324024421-b6ea8234fe3d h1:PRDnysJ9dF1vUMmEzBu6aHQeUluSQy4eWH3RsSSy/vI=
github.com/ipostelnik/cli/v2 v2.3.1-0.20210324024421-b6ea8234fe3d/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
@@ -126,6 +132,8 @@ github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
@@ -158,6 +166,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
@@ -168,14 +178,16 @@ github.com/prometheus/common v0.64.0 h1:pdZeA+g617P7oGv1CzdTzyeShxAGrTBsolKNOLQP
github.com/prometheus/common v0.64.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
github.com/rs/zerolog v1.20.0 h1:38k9hgtUBdxFwE34yS8rTHmHBa4eN16E4DJlv177LNs=
github.com/rs/zerolog v1.20.0/go.mod h1:IzD0RJ65iWH0w97OQQebJEvTZYvsCUm9WVLWBQrJRjo=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc=
github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
@@ -186,10 +198,14 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s=
github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo=
@@ -197,26 +213,28 @@ github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVM
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/propagators v0.22.0 h1:KGdv58M2//veiYLIhb31mofaI2LgkIPXXAZVeYVyfd8=
go.opentelemetry.io/contrib/propagators v0.22.0/go.mod h1:xGOuXr6lLIF9BXipA4pm6UuOSI0M98U6tsI3khbOiwU=
go.opentelemetry.io/otel v1.0.0-RC2/go.mod h1:w1thVQ7qbAy8MHb0IFj8a5Q2QU0l2ksf8u/CN8m3NOM=
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.26.0 h1:1u/AyyOqAWzy+SkPxDpahCNZParHV8Vid1RnI2clyDE=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.26.0/go.mod h1:z46paqbJ9l7c9fIPCXTqTGwhQZ5XoTIsfeFYWboizjs=
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o=
go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.0.0-RC2/go.mod h1:JPQ+z6nNw9mqEGT8o3eoPTdnNI+Aj5JcxEsVGREIAy4=
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
go.opentelemetry.io/proto/otlp v1.2.0 h1:pVeZGk7nXDC9O2hncA6nHldxEjm6LByfA2aN8IOkz94=
go.opentelemetry.io/proto/otlp v1.2.0/go.mod h1:gGpR8txAl5M03pDhMC79G6SdqNV26naRm/KDsgaHD8A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
@@ -227,53 +245,57 @@ golang.org/x/arch v0.4.0 h1:A8WCeEWhLwPBKNbFi5Wv5UTCBx5zzubnXDlMOFAzFMc=
golang.org/x/arch v0.4.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190828213141-aed303cbaa74/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU=
golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2 h1:vPV0tzlsK6EzEDHNNH5sa7Hs9bd7iXR7B1tSiPepkV0=
google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:pKLAc5OolXC3ViWGI62vvC0n10CpwAtRcTNCFwTKBEw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250512202823-5a2f75b736a9 h1:IkAfh6J/yllPtpYFU0zZN1hUPYdT0ogkBT/9hMxHjvg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250512202823-5a2f75b736a9/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/grpc v1.72.2 h1:TdbGzwb82ty4OusHWepvFWGLgIbNo1/SUynEN0ssqv8=
google.golang.org/grpc v1.72.2/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 h1:tu/dtnW1o3wfaxCOjSLn5IRX4YDcJrtlpzYkhHhGaC4=
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+1 -1
View File
@@ -43,7 +43,7 @@ func (tc *tcpConnection) Stream(_ context.Context, tunnelConn io.ReadWriter, _ *
func (tc *tcpConnection) Write(b []byte) (int, error) {
if tc.writeTimeout > 0 {
if err := tc.Conn.SetWriteDeadline(time.Now().Add(tc.writeTimeout)); err != nil {
if err := tc.SetWriteDeadline(time.Now().Add(tc.writeTimeout)); err != nil {
tc.logger.Err(err).Msg("Error setting write deadline for TCP connection")
}
}
+23 -62
View File
@@ -13,7 +13,6 @@ import (
"time"
"github.com/gobwas/ws/wsutil"
gorillaWS "github.com/gorilla/websocket"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/net/proxy"
@@ -61,7 +60,7 @@ func TestStreamTCPConnection(t *testing.T) {
})
errGroup.Go(func() error {
echoTCPOrigin(t, originConn)
originConn.Close()
_ = originConn.Close()
return nil
})
@@ -88,7 +87,7 @@ func TestDefaultStreamWSOverTCPConnection(t *testing.T) {
})
errGroup.Go(func() error {
echoTCPOrigin(t, originConn)
originConn.Close()
_ = originConn.Close()
return nil
})
@@ -117,14 +116,14 @@ func TestSocksStreamWSOverTCPConnection(t *testing.T) {
for _, status := range statusCodes {
handler := func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
require.NoError(t, err)
require.Equal(t, []byte(sendMessage), body)
assert.NoError(t, err)
assert.Equal(t, []byte(sendMessage), body)
require.Equal(t, echoHeaderIncomingValue, r.Header.Get(echoHeaderName))
assert.Equal(t, echoHeaderIncomingValue, r.Header.Get(echoHeaderName))
w.Header().Set(echoHeaderName, echoHeaderReturnValue)
w.WriteHeader(status)
w.Write([]byte(echoMessage))
_, _ = w.Write([]byte(echoMessage))
}
origin := httptest.NewServer(http.HandlerFunc(handler))
defer origin.Close()
@@ -156,7 +155,7 @@ func TestSocksStreamWSOverTCPConnection(t *testing.T) {
errGroup.Go(func() error {
wsForwarderInConn, err := wsForwarderListener.Accept()
require.NoError(t, err)
defer wsForwarderInConn.Close()
defer func() { _ = wsForwarderInConn.Close() }()
stream.Pipe(wsForwarderInConn, &wsEyeball{wsForwarderOutConn}, TestLogger)
return nil
@@ -171,20 +170,22 @@ func TestSocksStreamWSOverTCPConnection(t *testing.T) {
// Request URL doesn't matter because the transport is using eyeballDialer to connectq
req, err := http.NewRequestWithContext(ctx, "GET", "http://test-socks-stream.com", bytes.NewBuffer([]byte(sendMessage)))
assert.NoError(t, err)
require.NoError(t, err)
defer func() { _ = req.Body.Close() }()
req.Header.Set(echoHeaderName, echoHeaderIncomingValue)
resp, err := transport.RoundTrip(req)
assert.NoError(t, err)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
assert.Equal(t, status, resp.StatusCode)
require.Equal(t, echoHeaderReturnValue, resp.Header.Get(echoHeaderName))
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, []byte(echoMessage), body)
wsForwarderOutConn.Close()
edgeConn.Close()
tcpOverWSConn.Close()
_ = wsForwarderOutConn.Close()
_ = edgeConn.Close()
_ = tcpOverWSConn.Close()
require.NoError(t, errGroup.Wait())
}
@@ -205,7 +206,7 @@ func TestWsConnReturnsBeforeStreamReturns(t *testing.T) {
go func() {
time.Sleep(time.Millisecond * 10)
// Simulate losing connection to origin
originConn.Close()
_ = originConn.Close()
}()
ctx := context.WithValue(r.Context(), websocket.PingPeriodContextKey, time.Microsecond)
tcpOverWSConn.Stream(ctx, eyeballConn, TestLogger)
@@ -221,11 +222,13 @@ func TestWsConnReturnsBeforeStreamReturns(t *testing.T) {
for i := 0; i < 50; i++ {
eyeballConn, edgeConn := net.Pipe()
req, err := http.NewRequestWithContext(ctx, http.MethodConnect, server.URL, edgeConn)
assert.NoError(t, err)
require.NoError(t, err)
defer func() { _ = req.Body.Close() }()
resp, err := client.Transport.RoundTrip(req)
assert.NoError(t, err)
assert.Equal(t, resp.StatusCode, http.StatusOK)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
defer func() { _ = resp.Body.Close() }()
errGroup.Go(func() error {
for {
@@ -261,60 +264,18 @@ func echoWSEyeball(t *testing.T, conn net.Conn) {
assert.NoError(t, conn.Close())
}()
if !assert.NoError(t, wsutil.WriteClientBinary(conn, testMessage)) {
return
}
require.NoError(t, wsutil.WriteClientBinary(conn, testMessage))
readMsg, err := wsutil.ReadServerBinary(conn)
if !assert.NoError(t, err) {
return
}
require.NoError(t, err)
assert.Equal(t, testResponse, readMsg)
}
func echoWSOrigin(t *testing.T, expectMessages bool) *httptest.Server {
var upgrader = gorillaWS.Upgrader{
ReadBufferSize: 10,
WriteBufferSize: 10,
}
ws := func(w http.ResponseWriter, r *http.Request) {
header := make(http.Header)
for k, vs := range r.Header {
if k == "Test-Cloudflared-Echo" {
header[k] = vs
}
}
conn, err := upgrader.Upgrade(w, r, header)
require.NoError(t, err)
defer conn.Close()
sawMessage := false
for {
messageType, p, err := conn.ReadMessage()
if err != nil {
if expectMessages && !sawMessage {
t.Errorf("unexpected error: %v", err)
}
return
}
assert.Equal(t, testMessage, p)
sawMessage = true
if err := conn.WriteMessage(messageType, testResponse); err != nil {
return
}
}
}
// NewTLSServer starts the server in another thread
return httptest.NewTLSServer(http.HandlerFunc(ws))
}
func echoTCPOrigin(t *testing.T, conn net.Conn) {
readBuffer := make([]byte, len(testMessage))
_, err := conn.Read(readBuffer)
assert.NoError(t, err)
require.NoError(t, err)
assert.Equal(t, testMessage, readBuffer)
+6 -1
View File
@@ -5,7 +5,7 @@ import (
"fmt"
"net"
"net/http"
_ "net/http/pprof"
_ "net/http/pprof" //nolint:gosec // G108: the sensitive /debug/pprof/cmdline endpoint is explicitly blocked in newMetricsHandler
"runtime"
"sync"
"time"
@@ -70,6 +70,11 @@ func newMetricsHandler(
log *zerolog.Logger,
) *http.ServeMux {
router := http.NewServeMux()
// Block /debug/pprof/cmdline to prevent leaking secret command-line arguments
// (e.g. tunnel tokens) that are exposed via os.Args.
router.HandleFunc("/debug/pprof/cmdline", func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "forbidden", http.StatusForbidden)
})
router.Handle("/debug/", http.DefaultServeMux)
router.Handle("/metrics", promhttp.Handler())
router.HandleFunc("/healthcheck", func(w http.ResponseWriter, r *http.Request) {
+48
View File
@@ -0,0 +1,48 @@
package metrics
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/google/uuid"
"github.com/rs/zerolog"
"github.com/stretchr/testify/assert"
"github.com/cloudflare/cloudflared/diagnostic"
)
func testHandler(t *testing.T) *http.ServeMux {
t.Helper()
log := zerolog.Nop()
return newMetricsHandler(Config{
DiagnosticHandler: diagnostic.NewDiagnosticHandler(
&log, 0, nil, uuid.Nil, uuid.Nil, nil, map[string]string{}, nil,
),
}, &log)
}
func TestPprofCmdlineEndpointIsBlocked(t *testing.T) {
t.Parallel()
handler := testHandler(t)
req := httptest.NewRequest(http.MethodGet, "/debug/pprof/cmdline", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
}
func TestOtherPprofEndpointsStillWork(t *testing.T) {
t.Parallel()
handler := testHandler(t)
// /debug/pprof/ index should still be served by DefaultServeMux
req := httptest.NewRequest(http.MethodGet, "/debug/pprof/", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
+279
View File
@@ -0,0 +1,279 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: ../prechecks/resolvers.go
//
// Generated by this command:
//
// mockgen -typed -build_flags=-tags=gomock -package mocks -destination mock_resolvers.go -source=../prechecks/resolvers.go
//
// Package mocks is a generated GoMock package.
package mocks
import (
context "context"
tls "crypto/tls"
net "net"
netip "net/netip"
reflect "reflect"
time "time"
quic0 "github.com/quic-go/quic-go"
zerolog "github.com/rs/zerolog"
gomock "go.uber.org/mock/gomock"
dialopts "github.com/cloudflare/cloudflared/connection/dialopts"
allregions "github.com/cloudflare/cloudflared/edgediscovery/allregions"
quic "github.com/cloudflare/cloudflared/quic"
)
// MockDNSResolver is a mock of DNSResolver interface.
type MockDNSResolver struct {
ctrl *gomock.Controller
recorder *MockDNSResolverMockRecorder
isgomock struct{}
}
// MockDNSResolverMockRecorder is the mock recorder for MockDNSResolver.
type MockDNSResolverMockRecorder struct {
mock *MockDNSResolver
}
// NewMockDNSResolver creates a new mock instance.
func NewMockDNSResolver(ctrl *gomock.Controller) *MockDNSResolver {
mock := &MockDNSResolver{ctrl: ctrl}
mock.recorder = &MockDNSResolverMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockDNSResolver) EXPECT() *MockDNSResolverMockRecorder {
return m.recorder
}
// Resolve mocks base method.
func (m *MockDNSResolver) Resolve(region string) ([][]*allregions.EdgeAddr, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Resolve", region)
ret0, _ := ret[0].([][]*allregions.EdgeAddr)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Resolve indicates an expected call of Resolve.
func (mr *MockDNSResolverMockRecorder) Resolve(region any) *MockDNSResolverResolveCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Resolve", reflect.TypeOf((*MockDNSResolver)(nil).Resolve), region)
return &MockDNSResolverResolveCall{Call: call}
}
// MockDNSResolverResolveCall wrap *gomock.Call
type MockDNSResolverResolveCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockDNSResolverResolveCall) Return(arg0 [][]*allregions.EdgeAddr, arg1 error) *MockDNSResolverResolveCall {
c.Call = c.Call.Return(arg0, arg1)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockDNSResolverResolveCall) Do(f func(string) ([][]*allregions.EdgeAddr, error)) *MockDNSResolverResolveCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockDNSResolverResolveCall) DoAndReturn(f func(string) ([][]*allregions.EdgeAddr, error)) *MockDNSResolverResolveCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// MockTCPDialer is a mock of TCPDialer interface.
type MockTCPDialer struct {
ctrl *gomock.Controller
recorder *MockTCPDialerMockRecorder
isgomock struct{}
}
// MockTCPDialerMockRecorder is the mock recorder for MockTCPDialer.
type MockTCPDialerMockRecorder struct {
mock *MockTCPDialer
}
// NewMockTCPDialer creates a new mock instance.
func NewMockTCPDialer(ctrl *gomock.Controller) *MockTCPDialer {
mock := &MockTCPDialer{ctrl: ctrl}
mock.recorder = &MockTCPDialerMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockTCPDialer) EXPECT() *MockTCPDialerMockRecorder {
return m.recorder
}
// DialEdge mocks base method.
func (m *MockTCPDialer) DialEdge(ctx context.Context, timeout time.Duration, tlsConfig *tls.Config, addr *net.TCPAddr, localIP net.IP) (net.Conn, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "DialEdge", ctx, timeout, tlsConfig, addr, localIP)
ret0, _ := ret[0].(net.Conn)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// DialEdge indicates an expected call of DialEdge.
func (mr *MockTCPDialerMockRecorder) DialEdge(ctx, timeout, tlsConfig, addr, localIP any) *MockTCPDialerDialEdgeCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DialEdge", reflect.TypeOf((*MockTCPDialer)(nil).DialEdge), ctx, timeout, tlsConfig, addr, localIP)
return &MockTCPDialerDialEdgeCall{Call: call}
}
// MockTCPDialerDialEdgeCall wrap *gomock.Call
type MockTCPDialerDialEdgeCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockTCPDialerDialEdgeCall) Return(arg0 net.Conn, arg1 error) *MockTCPDialerDialEdgeCall {
c.Call = c.Call.Return(arg0, arg1)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockTCPDialerDialEdgeCall) Do(f func(context.Context, time.Duration, *tls.Config, *net.TCPAddr, net.IP) (net.Conn, error)) *MockTCPDialerDialEdgeCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockTCPDialerDialEdgeCall) DoAndReturn(f func(context.Context, time.Duration, *tls.Config, *net.TCPAddr, net.IP) (net.Conn, error)) *MockTCPDialerDialEdgeCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// MockQUICDialer is a mock of QUICDialer interface.
type MockQUICDialer struct {
ctrl *gomock.Controller
recorder *MockQUICDialerMockRecorder
isgomock struct{}
}
// MockQUICDialerMockRecorder is the mock recorder for MockQUICDialer.
type MockQUICDialerMockRecorder struct {
mock *MockQUICDialer
}
// NewMockQUICDialer creates a new mock instance.
func NewMockQUICDialer(ctrl *gomock.Controller) *MockQUICDialer {
mock := &MockQUICDialer{ctrl: ctrl}
mock.recorder = &MockQUICDialerMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockQUICDialer) EXPECT() *MockQUICDialerMockRecorder {
return m.recorder
}
// DialQuic mocks base method.
func (m *MockQUICDialer) DialQuic(ctx context.Context, quicConfig *quic0.Config, tlsConfig *tls.Config, addr netip.AddrPort, localAddr net.IP, connIndex uint8, logger *zerolog.Logger, opts dialopts.DialOpts) (quic.QUICConnection, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "DialQuic", ctx, quicConfig, tlsConfig, addr, localAddr, connIndex, logger, opts)
ret0, _ := ret[0].(quic.QUICConnection)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// DialQuic indicates an expected call of DialQuic.
func (mr *MockQUICDialerMockRecorder) DialQuic(ctx, quicConfig, tlsConfig, addr, localAddr, connIndex, logger, opts any) *MockQUICDialerDialQuicCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DialQuic", reflect.TypeOf((*MockQUICDialer)(nil).DialQuic), ctx, quicConfig, tlsConfig, addr, localAddr, connIndex, logger, opts)
return &MockQUICDialerDialQuicCall{Call: call}
}
// MockQUICDialerDialQuicCall wrap *gomock.Call
type MockQUICDialerDialQuicCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockQUICDialerDialQuicCall) Return(arg0 quic.QUICConnection, arg1 error) *MockQUICDialerDialQuicCall {
c.Call = c.Call.Return(arg0, arg1)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockQUICDialerDialQuicCall) Do(f func(context.Context, *quic0.Config, *tls.Config, netip.AddrPort, net.IP, uint8, *zerolog.Logger, dialopts.DialOpts) (quic.QUICConnection, error)) *MockQUICDialerDialQuicCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockQUICDialerDialQuicCall) DoAndReturn(f func(context.Context, *quic0.Config, *tls.Config, netip.AddrPort, net.IP, uint8, *zerolog.Logger, dialopts.DialOpts) (quic.QUICConnection, error)) *MockQUICDialerDialQuicCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// MockManagementDialer is a mock of ManagementDialer interface.
type MockManagementDialer struct {
ctrl *gomock.Controller
recorder *MockManagementDialerMockRecorder
isgomock struct{}
}
// MockManagementDialerMockRecorder is the mock recorder for MockManagementDialer.
type MockManagementDialerMockRecorder struct {
mock *MockManagementDialer
}
// NewMockManagementDialer creates a new mock instance.
func NewMockManagementDialer(ctrl *gomock.Controller) *MockManagementDialer {
mock := &MockManagementDialer{ctrl: ctrl}
mock.recorder = &MockManagementDialerMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockManagementDialer) EXPECT() *MockManagementDialerMockRecorder {
return m.recorder
}
// DialContext mocks base method.
func (m *MockManagementDialer) DialContext(ctx context.Context, network, addr string) (net.Conn, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "DialContext", ctx, network, addr)
ret0, _ := ret[0].(net.Conn)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// DialContext indicates an expected call of DialContext.
func (mr *MockManagementDialerMockRecorder) DialContext(ctx, network, addr any) *MockManagementDialerDialContextCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DialContext", reflect.TypeOf((*MockManagementDialer)(nil).DialContext), ctx, network, addr)
return &MockManagementDialerDialContextCall{Call: call}
}
// MockManagementDialerDialContextCall wrap *gomock.Call
type MockManagementDialerDialContextCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockManagementDialerDialContextCall) Return(arg0 net.Conn, arg1 error) *MockManagementDialerDialContextCall {
c.Call = c.Call.Return(arg0, arg1)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockManagementDialerDialContextCall) Do(f func(context.Context, string, string) (net.Conn, error)) *MockManagementDialerDialContextCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockManagementDialerDialContextCall) DoAndReturn(f func(context.Context, string, string) (net.Conn, error)) *MockManagementDialerDialContextCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
+2
View File
@@ -3,3 +3,5 @@
package mocks
//go:generate sh -c "go run go.uber.org/mock/mockgen -typed -build_flags=\"-tags=gomock\" -package mocks -destination mock_limiter.go -source=../flow/limiter.go Limiter"
//go:generate sh -c "go run go.uber.org/mock/mockgen -typed -build_flags=\"-tags=gomock\" -package mocks -destination mock_resolvers.go -source=../prechecks/resolvers.go"
+2 -2
View File
@@ -28,7 +28,7 @@ func FindProtocol(p []byte) (layers.IPProtocol, error) {
// Next header is in the 7th byte of IPv6 header
return layers.IPProtocol(p[6]), nil
default:
return 0, fmt.Errorf("unknow ip version %d", version)
return 0, fmt.Errorf("unknown ip version %d", version)
}
}
@@ -105,7 +105,7 @@ func (pd *IPDecoder) decodeByVersion(packet []byte) ([]gopacket.LayerType, error
case 6:
err = pd.v6parser.DecodeLayers(packet, &decoded)
default:
err = fmt.Errorf("unknow ip version %d", version)
err = fmt.Errorf("unknown ip version %d", version)
}
if err != nil {
return nil, err
+383
View File
@@ -0,0 +1,383 @@
package prechecks
import (
"context"
"fmt"
"slices"
"time"
"github.com/cloudflare/backoff"
"github.com/google/uuid"
"github.com/rs/zerolog"
"github.com/cloudflare/cloudflared/connection"
"github.com/cloudflare/cloudflared/edgediscovery/allregions"
)
const (
defaultTimeout = 10 * time.Second
maxRetries = 2
retryBaseDelay = 1 * time.Second
maxRetryDelay = 16 * time.Second
)
// RunDialers holds the injectable dependencies for Run(). Production callers build
// this with real implementations; tests supply mocks.
type RunDialers struct {
DNSResolver DNSResolver
TCPDialer TCPDialer
QUICDialer QUICDialer
ManagementDialer ManagementDialer
}
// TransportResults holds the per-target results for each transport probe type.
// Each slice has one entry per resolved target group, in the same order as the
// target labels slice.
type TransportResults struct {
QUIC []CheckResult // one per target group
HTTP2 []CheckResult // one per target group
ManagementAPI CheckResult // single target, no groups
}
// Collect returns all results as a slice in a consistent order for reporting:
// all QUIC rows first (one per target), then all HTTP2 rows, then Management API.
func (tr TransportResults) Collect() []CheckResult {
results := make([]CheckResult, 0, len(tr.QUIC)+len(tr.HTTP2)+1)
results = append(results, tr.QUIC...)
results = append(results, tr.HTTP2...)
results = append(results, tr.ManagementAPI)
return results
}
// Run executes the following connectivity pre-checks:
//
// 1. Edge address resolution — either DNS-based SRV discovery (normal path)
// or direct resolution of --edge addresses (static path). The static path
// skips DNS probe rows entirely since there are no SRV records to validate.
// 2. QUIC, HTTP/2, and Management API probes run concurrently against the
// resolved addresses.
//
// Each failed probe is retried up to maxRetries times with exponential backoff.
// The suite is bounded by cfg.Timeout (defaultTimeout if zero).
func Run(ctx context.Context, caCert string, cfg Config, log *zerolog.Logger, runDialers RunDialers) Report {
runID := uuid.New()
if cfg.Timeout <= 0 {
cfg.Timeout = defaultTimeout
}
ctx, cancel := context.WithTimeout(ctx, cfg.Timeout)
defer cancel()
// Build TLS configs once per protocol.
quicTLSConfig, quicTLSErr := probeTLSConfig(caCert, connection.QUIC)
http2TLSConfig, http2TLSErr := probeTLSConfig(caCert, connection.HTTP2)
// 1) Resolve edge addresses. Each ResolvedTarget bundles its addr group
// with the DNS CheckResult that labels it, keeping the two in sync.
var resolvedTargets []ResolvedTarget
if len(cfg.EdgeAddrs) > 0 {
// Static path: explicit --edge addresses, one ResolvedTarget per addr.
resolvedTargets = resolveStaticEdge(cfg.EdgeAddrs, log)
} else {
// Normal path: SRV-based discovery; DNS rows carry Pass or Fail status.
resolvedTargets = runDNSProbe(ctx, runDialers.DNSResolver, cfg.Region)
}
// Extract parallel slices for the transport probe layer.
// nolint:prealloc // False positive. The linter is confused by the append used when producing Report.Results
dnsResults := make([]CheckResult, len(resolvedTargets))
perGroupAddrs := make([][]*allregions.EdgeAddr, len(resolvedTargets))
targetLabels := make([]string, len(resolvedTargets))
for i, rt := range resolvedTargets {
dnsResults[i] = rt.DNSResult
perGroupAddrs[i] = rt.Addrs
targetLabels[i] = rt.DNSResult.Target
}
// dnsOK is true when at least one target has addresses to probe.
dnsOK := slices.ContainsFunc(resolvedTargets, func(r ResolvedTarget) bool {
return len(r.Addrs) > 0
})
// 2) Run transport probes concurrently. Each probe type gets its own
// buffered channel — one send, one receive, no routing required.
var results TransportResults
mgmtCh := make(chan CheckResult)
go func() {
mgmtCh <- probeManagementAPIWithRetry(ctx, runDialers.ManagementDialer)
}()
if !dnsOK {
// No addresses available: emit one skip row per target so the table
// stays consistent with the DNS rows above.
results.QUIC = skipResultsForTargets(dnsResults, ProbeTypeQUIC, componentUDPConnectivity)
results.HTTP2 = skipResultsForTargets(dnsResults, ProbeTypeHTTP2, componentTCPConnectivity)
} else {
filteredAddrs := addrsByGroup(perGroupAddrs, cfg.IPVersion)
quicCh := make(chan []CheckResult, 1)
http2Ch := make(chan []CheckResult, 1)
go func() {
if quicTLSErr != nil {
log.Warn().Err(quicTLSErr).Msg("Failed to build QUIC probe TLS config")
quicCh <- tlsConfigErrResults(ProbeTypeQUIC, componentUDPConnectivity,
targetLabels, fmt.Sprintf("%s: %v", detailsTLSConfigFailed, quicTLSErr), actionQUICBlocked)
return
}
quicCh <- probeAllTargets(ctx, ProbeTypeQUIC, componentUDPConnectivity,
filteredAddrs, targetLabels,
func(addr *allregions.EdgeAddr) CheckResult {
return probeQUIC(ctx, quicTLSConfig, runDialers.QUICDialer, addr, log)
})
}()
go func() {
if http2TLSErr != nil {
log.Warn().Err(http2TLSErr).Msg("Failed to build HTTP/2 probe TLS config")
http2Ch <- tlsConfigErrResults(ProbeTypeHTTP2, componentTCPConnectivity,
targetLabels, fmt.Sprintf("%s: %v", detailsTLSConfigFailed, http2TLSErr), actionHTTP2Blocked)
return
}
http2Ch <- probeAllTargets(ctx, ProbeTypeHTTP2, componentTCPConnectivity,
filteredAddrs, targetLabels,
func(addr *allregions.EdgeAddr) CheckResult {
return probeHTTP2(ctx, http2TLSConfig, runDialers.TCPDialer, addr)
})
}()
results.QUIC = <-quicCh
results.HTTP2 = <-http2Ch
}
results.ManagementAPI = <-mgmtCh
return Report{
RunID: runID,
Results: append(dnsResults, results.Collect()...),
SuggestedProtocol: suggestProtocol(results.QUIC, results.HTTP2, cfg.ProtocolOverride),
}
}
// tlsConfigErrResults returns one Fail CheckResult per target, used when
// TLS config construction fails before any dial is attempted.
func tlsConfigErrResults(probeType ProbeType, component string, targets []string, details, action string) []CheckResult {
results := make([]CheckResult, len(targets))
for i, target := range targets {
results[i] = CheckResult{
Type: probeType,
Component: component,
Target: target,
ProbeStatus: Fail,
Details: details,
Action: action,
}
}
return results
}
// probeAllTargets probes each target group sequentially and returns one
// CheckResult per group. Within each group, all available addresses (V4 and/or
// V6) are tried and the best result is kept.
func probeAllTargets(
ctx context.Context,
probeType ProbeType,
component string,
perGroupAddrs [][]*allregions.EdgeAddr,
targets []string,
probeFn func(*allregions.EdgeAddr) CheckResult,
) []CheckResult {
results := make([]CheckResult, len(perGroupAddrs))
for i, addrs := range perGroupAddrs {
results[i] = probeTarget(ctx, probeType, component, targets[i], addrs, probeFn)
}
return results
}
// probeTarget probes all addresses for a single target group (typically one V4
// and/or one V6) and returns the best result. Any address passing means the
// target is reachable, so Pass beats Fail within a group.
func probeTarget(
ctx context.Context,
probeType ProbeType,
component string,
target string,
addrs []*allregions.EdgeAddr,
probeFn func(*allregions.EdgeAddr) CheckResult,
) CheckResult {
if len(addrs) == 0 {
return CheckResult{
Type: probeType,
Component: component,
Target: target,
ProbeStatus: Skip,
Details: "No suitable address found for configured IP version",
}
}
best := probeWithRetry(ctx, addrs[0], probeFn)
for _, addr := range addrs[1:] {
if r := probeWithRetry(ctx, addr, probeFn); r.ProbeStatus == Pass {
best = r
}
}
best.Target = target
return best
}
// probeManagementAPIWithRetry runs the Cloudflare API reachability probe with retry.
func probeManagementAPIWithRetry(ctx context.Context, dialer ManagementDialer) CheckResult {
var r CheckResult
withRetry(ctx, maxRetries, func() bool {
r = probeManagementAPI(ctx, dialer)
return r.ProbeStatus == Pass
})
return r
}
// probeWithRetry calls probeFn on addr with exponential-backoff retry up to
// maxRetries times, stopping as soon as the probe passes.
func probeWithRetry(ctx context.Context, addr *allregions.EdgeAddr, probeFn func(*allregions.EdgeAddr) CheckResult) CheckResult {
var r CheckResult
withRetry(ctx, maxRetries, func() bool {
r = probeFn(addr)
return r.ProbeStatus == Pass
})
return r
}
// addrsByGroup returns the addresses to probe for each resolved target group,
// preserving the per-group structure. Each inner slice contains at most one V4
// and one V6 address (subject to ipVersion).
func addrsByGroup(addrGroups [][]*allregions.EdgeAddr, ipVersion allregions.ConfigIPVersion) [][]*allregions.EdgeAddr {
perGroup := make([][]*allregions.EdgeAddr, 0, len(addrGroups))
for _, group := range addrGroups {
v4, v6 := addrsByFamily(group, ipVersion)
var addrs []*allregions.EdgeAddr
if v4 != nil {
addrs = append(addrs, v4)
}
if v6 != nil {
addrs = append(addrs, v6)
}
perGroup = append(perGroup, addrs)
}
return perGroup
}
// skipResultsForTargets returns one skip CheckResult per entry in results,
// using each entry's Target label so the transport row aligns with its DNS row.
func skipResultsForTargets(targets []CheckResult, probeType ProbeType, component string) []CheckResult {
results := make([]CheckResult, len(targets))
for i, t := range targets {
results[i] = skipResult(probeType, component, t.Target, detailsDNSPrerequisiteFailed)
}
return results
}
// worstStatus returns the most severe Status across a slice of CheckResults.
// Fail > Pass > Skip. Used to determine whether a transport type as a whole
// should be considered failed (any region failing = transport fails).
func worstStatus(results []CheckResult) Status {
worst := Skip
for _, r := range results {
if severity(r.ProbeStatus) > severity(worst) {
worst = r.ProbeStatus
}
}
return worst
}
// severity maps a Status to a comparable integer so that worse outcomes rank higher.
func severity(s Status) int {
switch s {
case Fail:
return 2
case Pass:
return 1
case Skip:
return 0
default:
return 0
}
}
// parseProtocolOverride converts the raw --protocol flag string into a
// *connection.Protocol. It returns nil when the string is empty, "auto", or
// unrecognised, so the probe heuristic is used in those cases. "h2mux" is
// treated as HTTP/2 because both map to the same transport.
func parseProtocolOverride(flag string) *connection.Protocol {
switch flag {
case connection.QUIC.String():
p := connection.QUIC
return &p
case connection.HTTP2.String(), "h2mux":
p := connection.HTTP2
return &p
default:
// "auto", empty, or unknown — no override; let the heuristic decide.
return nil
}
}
// suggestProtocol determines the protocol to report in the pre-check summary.
//
// When the caller has explicitly overridden the protocol via --protocol, that
// choice is honoured when its transport probes produced evidence and did not
// fail.
//
// When there is no override (auto-selection), precedence is QUIC, HTTP/2,
// and nil. A protocol is only suggested if all probes pass.
//
// Any region failing means the transport is treated as failed (worst wins).
func suggestProtocol(quicResults, http2Results []CheckResult, overrideFlag string) *connection.Protocol {
if override := parseProtocolOverride(overrideFlag); override != nil {
switch *override {
case connection.QUIC:
// Only report QUIC as the suggested protocol if its probes did not
// all fail — if they did, fall through to the heuristic so the
// summary can report a usable fallback or nil.
if len(quicResults) > 0 && worstStatus(quicResults) != Fail {
return new(connection.QUIC)
}
case connection.HTTP2:
// Same logic for an explicit HTTP/2 override.
if len(http2Results) > 0 && worstStatus(http2Results) != Fail {
return new(connection.HTTP2)
}
}
}
if len(quicResults) > 0 && worstStatus(quicResults) == Pass {
quic := connection.QUIC
return &quic
}
if len(http2Results) > 0 && worstStatus(http2Results) == Pass {
http2 := connection.HTTP2
return &http2
}
return nil
}
// withRetry calls fn up to 1+maxAttempts times, stopping as soon as fn returns
// true. Between attempts, it sleeps with exponential backoff bounded by
// maxRetryDelay, and stops early if ctx is done.
func withRetry(ctx context.Context, maxAttempts int, fn func() bool) {
b := backoff.NewWithoutJitter(maxRetryDelay, retryBaseDelay)
for attempt := 0; attempt <= maxAttempts; attempt++ {
if fn() {
return
}
if attempt == maxAttempts {
break
}
timer := time.NewTimer(b.Duration())
select {
case <-ctx.Done():
timer.Stop()
return
case <-timer.C:
}
}
}
+720
View File
@@ -0,0 +1,720 @@
package prechecks
import (
"errors"
"math"
"net"
"testing"
"time"
"github.com/google/uuid"
"github.com/quic-go/quic-go"
"github.com/rs/zerolog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"github.com/cloudflare/cloudflared/connection"
"github.com/cloudflare/cloudflared/edgediscovery/allregions"
"github.com/cloudflare/cloudflared/mocks"
)
const (
emptyCert = ""
)
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// twoRegionAddrs returns a two-group [][]*EdgeAddr with one IPv4 address per
// region. Used by tests that only need to exercise the V4 path.
func twoRegionAddrs() [][]*allregions.EdgeAddr {
makeV4 := func(ip string) *allregions.EdgeAddr {
parsed := net.ParseIP(ip)
return &allregions.EdgeAddr{
TCP: &net.TCPAddr{IP: parsed, Port: 7844},
UDP: &net.UDPAddr{IP: parsed, Port: 7844},
IPVersion: allregions.V4,
}
}
return [][]*allregions.EdgeAddr{
{makeV4("1.2.3.4")},
{makeV4("5.6.7.8")},
}
}
// twoRegionAddrsBothFamilies returns a two-group [][]*EdgeAddr with one IPv4
// and one IPv6 address per region, used by per-family probe tests.
func twoRegionAddrsBothFamilies() [][]*allregions.EdgeAddr {
makeAddr := func(ip string, v allregions.EdgeIPVersion) *allregions.EdgeAddr {
parsed := net.ParseIP(ip)
return &allregions.EdgeAddr{
TCP: &net.TCPAddr{IP: parsed, Port: 7844},
UDP: &net.UDPAddr{IP: parsed, Port: 7844},
IPVersion: v,
}
}
return [][]*allregions.EdgeAddr{
{makeAddr("1.2.3.4", allregions.V4), makeAddr("2001:db8::1", allregions.V6)},
{makeAddr("5.6.7.8", allregions.V4), makeAddr("2001:db8::2", allregions.V6)},
}
}
// nopConn is a net.Conn whose Close() is a no-op, used as the success value
// for TCP and management dial mocks.
type nopConn struct{ net.Conn }
func (nopConn) Close() error { return nil }
// fakeQUICConn satisfies quic.Connection for tests. Only CloseWithError is
// implemented; the pre-check never opens streams so the rest of the interface
// is unused via the embedded nil.
type fakeQUICConn struct {
quic.Connection
}
func (*fakeQUICConn) CloseWithError(_ quic.ApplicationErrorCode, _ string) error { return nil }
// requireStatuses asserts the probe statuses in report.Results match
// expected (in order), failing immediately on length mismatch.
func requireStatuses(t *testing.T, report Report, expected ...Status) {
t.Helper()
require.Len(t, report.Results, len(expected))
for i, want := range expected {
got := report.Results[i].ProbeStatus
assert.Equalf(t, want, got,
"result[%d] (%s/%s): got %s, want %s",
i, report.Results[i].Component, report.Results[i].Target, got, want)
}
}
func nopLogger() *zerolog.Logger {
l := zerolog.Nop()
return &l
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
// TestRun_AllPass verifies that when all probes succeed the report contains
// 7 rows: 2 DNS + 2 QUIC (one per region) + 2 HTTP/2 (one per region) + 1 API.
func TestRun_AllPass(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
dns := mocks.NewMockDNSResolver(ctrl)
tcp := mocks.NewMockTCPDialer(ctrl)
quicD := mocks.NewMockQUICDialer(ctrl)
mgmt := mocks.NewMockManagementDialer(ctrl)
dns.EXPECT().Resolve(gomock.Any()).Return(twoRegionAddrs(), nil)
// twoRegionAddrs has 2 regions × 1 V4 address each = 2 dials per transport.
tcp.EXPECT().DialEdge(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
Return(nopConn{}, nil).Times(2)
quicD.EXPECT().DialQuic(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
Return(&fakeQUICConn{}, nil).Times(2)
mgmt.EXPECT().DialContext(gomock.Any(), gomock.Any(), gomock.Any()).
Return(nopConn{}, nil)
report := Run(t.Context(), emptyCert, Config{Timeout: 2 * time.Second, IPVersion: allregions.Auto},
nopLogger(), RunDialers{DNSResolver: dns, TCPDialer: tcp, QUICDialer: quicD, ManagementDialer: mgmt})
// 2 DNS + 2 QUIC + 2 HTTP2 + 1 API = 7 results.
requireStatuses(t, report, Pass, Pass, Pass, Pass, Pass, Pass, Pass)
assert.NotEqual(t, uuid.Nil, report.RunID, "RunID must be set")
require.NotNil(t, report.SuggestedProtocol)
assert.Equal(t, connection.QUIC, *report.SuggestedProtocol)
assert.False(t, report.hasHardFail())
assert.False(t, report.hasWarn())
}
// TestRun_QUICBlocked verifies that when QUIC is blocked on all regions,
// the report is degraded (warn) and HTTP/2 is the suggested protocol.
func TestRun_QUICBlocked(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
dns := mocks.NewMockDNSResolver(ctrl)
tcp := mocks.NewMockTCPDialer(ctrl)
quicD := mocks.NewMockQUICDialer(ctrl)
mgmt := mocks.NewMockManagementDialer(ctrl)
dns.EXPECT().Resolve(gomock.Any()).Return(twoRegionAddrs(), nil)
tcp.EXPECT().DialEdge(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
Return(nopConn{}, nil).AnyTimes()
quicD.EXPECT().DialQuic(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
Return(nil, errors.New("connection refused")).AnyTimes()
mgmt.EXPECT().DialContext(gomock.Any(), gomock.Any(), gomock.Any()).
Return(nopConn{}, nil)
report := Run(t.Context(), emptyCert, Config{Timeout: 2 * time.Second, IPVersion: allregions.Auto},
nopLogger(), RunDialers{DNSResolver: dns, TCPDialer: tcp, QUICDialer: quicD, ManagementDialer: mgmt})
// 2 DNS Pass + 2 QUIC Fail + 2 HTTP2 Pass + 1 API Pass.
requireStatuses(t, report, Pass, Pass, Fail, Fail, Pass, Pass, Pass)
require.NotNil(t, report.SuggestedProtocol)
assert.Equal(t, connection.HTTP2, *report.SuggestedProtocol)
assert.False(t, report.hasHardFail())
assert.True(t, report.hasWarn())
}
// TestRun_HTTP2Blocked verifies that when HTTP/2 is blocked on all regions,
// the report is degraded (warn) and QUIC is the suggested protocol.
func TestRun_HTTP2Blocked(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
dns := mocks.NewMockDNSResolver(ctrl)
tcp := mocks.NewMockTCPDialer(ctrl)
quicD := mocks.NewMockQUICDialer(ctrl)
mgmt := mocks.NewMockManagementDialer(ctrl)
dns.EXPECT().Resolve(gomock.Any()).Return(twoRegionAddrs(), nil)
tcp.EXPECT().DialEdge(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
Return(nil, errors.New("connection refused")).AnyTimes()
quicD.EXPECT().DialQuic(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
Return(&fakeQUICConn{}, nil).AnyTimes()
mgmt.EXPECT().DialContext(gomock.Any(), gomock.Any(), gomock.Any()).
Return(nopConn{}, nil)
report := Run(t.Context(), emptyCert, Config{Timeout: 2 * time.Second, IPVersion: allregions.Auto},
nopLogger(), RunDialers{DNSResolver: dns, TCPDialer: tcp, QUICDialer: quicD, ManagementDialer: mgmt})
// 2 DNS Pass + 2 QUIC Pass + 2 HTTP2 Fail + 1 API Pass.
requireStatuses(t, report, Pass, Pass, Pass, Pass, Fail, Fail, Pass)
require.NotNil(t, report.SuggestedProtocol)
assert.Equal(t, connection.QUIC, *report.SuggestedProtocol)
assert.False(t, report.hasHardFail())
assert.True(t, report.hasWarn())
}
// TestRun_BothTransportsBlocked verifies that when both QUIC and HTTP/2 are
// blocked on all regions it is a hard fail with no suggested protocol.
func TestRun_BothTransportsBlocked(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
dns := mocks.NewMockDNSResolver(ctrl)
tcp := mocks.NewMockTCPDialer(ctrl)
quicD := mocks.NewMockQUICDialer(ctrl)
mgmt := mocks.NewMockManagementDialer(ctrl)
dns.EXPECT().Resolve(gomock.Any()).Return(twoRegionAddrs(), nil)
tcp.EXPECT().DialEdge(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
Return(nil, errors.New("blocked")).AnyTimes()
quicD.EXPECT().DialQuic(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
Return(nil, errors.New("blocked")).AnyTimes()
mgmt.EXPECT().DialContext(gomock.Any(), gomock.Any(), gomock.Any()).
Return(nopConn{}, nil)
report := Run(t.Context(), emptyCert, Config{Timeout: 2 * time.Second, IPVersion: allregions.Auto},
nopLogger(), RunDialers{DNSResolver: dns, TCPDialer: tcp, QUICDialer: quicD, ManagementDialer: mgmt})
// 2 DNS Pass + 2 QUIC Fail + 2 HTTP2 Fail + 1 API Pass.
requireStatuses(t, report, Pass, Pass, Fail, Fail, Fail, Fail, Pass)
assert.Nil(t, report.SuggestedProtocol)
assert.True(t, report.hasHardFail())
}
// TestRun_PartialRegionQUICFail verifies "worst wins" semantics: when QUIC
// passes for region1 but fails for region2, QUIC is treated as failed and
// HTTP/2 becomes the suggested protocol.
func TestRun_PartialRegionQUICFail(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
dns := mocks.NewMockDNSResolver(ctrl)
tcp := mocks.NewMockTCPDialer(ctrl)
quicD := mocks.NewMockQUICDialer(ctrl)
mgmt := mocks.NewMockManagementDialer(ctrl)
// Two regions: 1.2.3.4 (region1) and 5.6.7.8 (region2).
dns.EXPECT().Resolve(gomock.Any()).Return(twoRegionAddrs(), nil)
// TCP/HTTP2: both regions pass.
tcp.EXPECT().DialEdge(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
Return(nopConn{}, nil).AnyTimes()
// QUIC: region1 (1.2.3.4) passes, region2 (5.6.7.8) fails.
region1Addr := &net.UDPAddr{IP: net.ParseIP("1.2.3.4"), Port: 7844}
region2Addr := &net.UDPAddr{IP: net.ParseIP("5.6.7.8"), Port: 7844}
quicD.EXPECT().DialQuic(gomock.Any(), gomock.Any(), gomock.Any(), region1Addr.AddrPort(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
Return(&fakeQUICConn{}, nil).AnyTimes()
quicD.EXPECT().DialQuic(gomock.Any(), gomock.Any(), gomock.Any(), region2Addr.AddrPort(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
Return(nil, errors.New("connection refused")).AnyTimes()
mgmt.EXPECT().DialContext(gomock.Any(), gomock.Any(), gomock.Any()).
Return(nopConn{}, nil)
report := Run(t.Context(), emptyCert, Config{Timeout: 2 * time.Second, IPVersion: allregions.Auto},
nopLogger(), RunDialers{DNSResolver: dns, TCPDialer: tcp, QUICDialer: quicD, ManagementDialer: mgmt})
// 2 DNS Pass + QUIC-region1 Pass + QUIC-region2 Fail + 2 HTTP2 Pass + 1 API Pass.
requireStatuses(t, report, Pass, Pass, Pass, Fail, Pass, Pass, Pass)
// Worst wins: region2 QUIC failed, so QUIC is treated as failed overall.
// HTTP/2 passes on all regions → HTTP/2 is the suggested protocol.
require.NotNil(t, report.SuggestedProtocol)
assert.Equal(t, connection.HTTP2, *report.SuggestedProtocol)
assert.False(t, report.hasHardFail())
assert.True(t, report.hasWarn())
}
// TestRun_DNSFail_SkipsTransports verifies that when DNS fails, transport rows
// are emitted as Skip (one per DNS region) and no transport dials are made.
func TestRun_DNSFail_SkipsTransports(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
dns := mocks.NewMockDNSResolver(ctrl)
tcp := mocks.NewMockTCPDialer(ctrl)
quicD := mocks.NewMockQUICDialer(ctrl)
mgmt := mocks.NewMockManagementDialer(ctrl)
dns.EXPECT().Resolve(gomock.Any()).
Return(nil, errors.New("no such host")).AnyTimes()
// Transport dialers must NOT be called when DNS fails.
tcp.EXPECT().DialEdge(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0)
quicD.EXPECT().DialQuic(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0)
mgmt.EXPECT().DialContext(gomock.Any(), gomock.Any(), gomock.Any()).
Return(nopConn{}, nil)
report := Run(t.Context(), emptyCert, Config{Timeout: 2 * time.Second, IPVersion: allregions.Auto},
nopLogger(), RunDialers{DNSResolver: dns, TCPDialer: tcp, QUICDialer: quicD, ManagementDialer: mgmt})
// DNS failure emits 2 Fail rows (one per default region).
// Transport rows: one skip per DNS region for QUIC and HTTP/2 = 2 QUIC skips + 2 HTTP2 skips.
// 2 DNS Fail + 2 QUIC Skip + 2 HTTP2 Skip + 1 API Pass = 7 results.
require.Len(t, report.Results, 7)
assert.Equal(t, Fail, report.Results[0].ProbeStatus, "DNS region1")
assert.Equal(t, Fail, report.Results[1].ProbeStatus, "DNS region2")
assert.Equal(t, Skip, report.Results[2].ProbeStatus, "QUIC region1 must be skipped")
assert.Equal(t, Skip, report.Results[3].ProbeStatus, "QUIC region2 must be skipped")
assert.Equal(t, Skip, report.Results[4].ProbeStatus, "HTTP/2 region1 must be skipped")
assert.Equal(t, Skip, report.Results[5].ProbeStatus, "HTTP/2 region2 must be skipped")
assert.Equal(t, Pass, report.Results[6].ProbeStatus, "API still runs")
assert.True(t, report.hasHardFail())
}
// TestRun_ManagementAPIFail verifies that a Management API failure results
// in a warning (not a hard fail) and QUIC remains the suggested protocol.
func TestRun_ManagementAPIFail(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
dns := mocks.NewMockDNSResolver(ctrl)
tcp := mocks.NewMockTCPDialer(ctrl)
quicD := mocks.NewMockQUICDialer(ctrl)
mgmt := mocks.NewMockManagementDialer(ctrl)
dns.EXPECT().Resolve(gomock.Any()).Return(twoRegionAddrs(), nil)
// twoRegionAddrs has 2 regions × 1 V4 address each; each succeeds on first try.
tcp.EXPECT().DialEdge(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
Return(nopConn{}, nil).Times(2)
quicD.EXPECT().DialQuic(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
Return(&fakeQUICConn{}, nil).Times(2)
mgmt.EXPECT().DialContext(gomock.Any(), gomock.Any(), gomock.Any()).
Return(nil, errors.New("connection refused")).AnyTimes()
report := Run(t.Context(), emptyCert, Config{Timeout: 2 * time.Second, IPVersion: allregions.Auto},
nopLogger(), RunDialers{DNSResolver: dns, TCPDialer: tcp, QUICDialer: quicD, ManagementDialer: mgmt})
// 2 DNS Pass + 2 QUIC Pass + 2 HTTP2 Pass + 1 API Fail.
requireStatuses(t, report, Pass, Pass, Pass, Pass, Pass, Pass, Fail)
require.NotNil(t, report.SuggestedProtocol)
assert.Equal(t, connection.QUIC, *report.SuggestedProtocol)
assert.False(t, report.hasHardFail())
assert.True(t, report.hasWarn())
}
// TestRun_RegionFlagForwardedToDNS verifies that the --region flag is passed
// verbatim to the DNS resolver and that regional hostnames appear in the results.
func TestRun_RegionFlagForwardedToDNS(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
dns := mocks.NewMockDNSResolver(ctrl)
tcp := mocks.NewMockTCPDialer(ctrl)
quicD := mocks.NewMockQUICDialer(ctrl)
mgmt := mocks.NewMockManagementDialer(ctrl)
// The region string must be forwarded verbatim to the DNS resolver.
dns.EXPECT().Resolve("us").Return(twoRegionAddrs(), nil)
tcp.EXPECT().DialEdge(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
Return(nopConn{}, nil).Times(2)
quicD.EXPECT().DialQuic(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
Return(&fakeQUICConn{}, nil).Times(2)
mgmt.EXPECT().DialContext(gomock.Any(), gomock.Any(), gomock.Any()).
Return(nopConn{}, nil)
report := Run(t.Context(), emptyCert, Config{Region: "us", Timeout: 2 * time.Second, IPVersion: allregions.Auto},
nopLogger(), RunDialers{DNSResolver: dns, TCPDialer: tcp, QUICDialer: quicD, ManagementDialer: mgmt})
// DNS rows carry regional hostnames (indices 0 and 1).
assert.Equal(t, "us-region1.v2.argotunnel.com", report.Results[0].Target, "DNS region1")
assert.Equal(t, "us-region2.v2.argotunnel.com", report.Results[1].Target, "DNS region2")
// Transport rows reuse the same regional hostnames (QUIC: 2,3 / HTTP2: 4,5).
assert.Equal(t, "us-region1.v2.argotunnel.com", report.Results[2].Target, "QUIC region1")
assert.Equal(t, "us-region2.v2.argotunnel.com", report.Results[3].Target, "QUIC region2")
assert.Equal(t, "us-region1.v2.argotunnel.com", report.Results[4].Target, "HTTP2 region1")
assert.Equal(t, "us-region2.v2.argotunnel.com", report.Results[5].Target, "HTTP2 region2")
}
// TestRun_QUICUsesProbeConnIndex verifies that the QUIC probe always uses the
// reserved sentinel connIndex (math.MaxUint8 = 255) to bypass port-reuse checks.
func TestRun_QUICUsesProbeConnIndex(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
dns := mocks.NewMockDNSResolver(ctrl)
tcp := mocks.NewMockTCPDialer(ctrl)
quicD := mocks.NewMockQUICDialer(ctrl)
mgmt := mocks.NewMockManagementDialer(ctrl)
dns.EXPECT().Resolve(gomock.Any()).Return(twoRegionAddrs(), nil)
tcp.EXPECT().DialEdge(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
Return(nopConn{}, nil).Times(2)
// connIndex must be the reserved sentinel (math.MaxUint8 = 255), never 0.
// twoRegionAddrs has 2 regions × 1 V4 address each → 2 calls.
quicD.EXPECT().DialQuic(
gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(),
gomock.Eq(uint8(math.MaxUint8)),
gomock.Any(), gomock.Any(),
).Return(&fakeQUICConn{}, nil).Times(2)
mgmt.EXPECT().DialContext(gomock.Any(), gomock.Any(), gomock.Any()).
Return(nopConn{}, nil)
Run(t.Context(), emptyCert, Config{Timeout: 2 * time.Second, IPVersion: allregions.Auto},
nopLogger(), RunDialers{DNSResolver: dns, TCPDialer: tcp, QUICDialer: quicD, ManagementDialer: mgmt})
}
// TestRun_BothFamiliesProbed verifies that when both V4 and V6 addresses are
// present in the DNS response, both are probed (2 regions × 2 families = 4 dials).
func TestRun_BothFamiliesProbed(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
dns := mocks.NewMockDNSResolver(ctrl)
tcp := mocks.NewMockTCPDialer(ctrl)
quicD := mocks.NewMockQUICDialer(ctrl)
mgmt := mocks.NewMockManagementDialer(ctrl)
dns.EXPECT().Resolve(gomock.Any()).Return(twoRegionAddrsBothFamilies(), nil)
// 2 regions × 2 families = 4 dial calls each for QUIC and HTTP/2.
tcp.EXPECT().DialEdge(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
Return(nopConn{}, nil).Times(4)
quicD.EXPECT().DialQuic(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
Return(&fakeQUICConn{}, nil).Times(4)
mgmt.EXPECT().DialContext(gomock.Any(), gomock.Any(), gomock.Any()).
Return(nopConn{}, nil)
report := Run(t.Context(), emptyCert, Config{Timeout: 2 * time.Second, IPVersion: allregions.Auto},
nopLogger(), RunDialers{DNSResolver: dns, TCPDialer: tcp, QUICDialer: quicD, ManagementDialer: mgmt})
// 2 DNS + 2 QUIC + 2 HTTP2 + 1 API = 7 results, all passing.
requireStatuses(t, report, Pass, Pass, Pass, Pass, Pass, Pass, Pass)
require.NotNil(t, report.SuggestedProtocol)
assert.Equal(t, connection.QUIC, *report.SuggestedProtocol)
}
// TestRun_IPVersionRestriction verifies that when a single IP family is
// configured, only that family is probed (2 regions × 1 addr = 2 dials per
// transport) and the excluded family is never dialled.
func TestRun_IPVersionRestriction(t *testing.T) {
t.Parallel()
tests := []struct {
name string
ipVersion allregions.ConfigIPVersion
}{
{"IPv4Only skips V6", allregions.IPv4Only},
{"IPv6Only skips V4", allregions.IPv6Only},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
dns := mocks.NewMockDNSResolver(ctrl)
tcp := mocks.NewMockTCPDialer(ctrl)
quicD := mocks.NewMockQUICDialer(ctrl)
mgmt := mocks.NewMockManagementDialer(ctrl)
dns.EXPECT().Resolve(gomock.Any()).Return(twoRegionAddrsBothFamilies(), nil)
// 2 regions × 1 addr per restricted family = 2 dials each.
tcp.EXPECT().DialEdge(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
Return(nopConn{}, nil).Times(2)
quicD.EXPECT().DialQuic(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
Return(&fakeQUICConn{}, nil).Times(2)
mgmt.EXPECT().DialContext(gomock.Any(), gomock.Any(), gomock.Any()).
Return(nopConn{}, nil)
report := Run(t.Context(), emptyCert, Config{Timeout: 2 * time.Second, IPVersion: tt.ipVersion},
nopLogger(), RunDialers{DNSResolver: dns, TCPDialer: tcp, QUICDialer: quicD, ManagementDialer: mgmt})
requireStatuses(t, report, Pass, Pass, Pass, Pass, Pass, Pass, Pass)
})
}
}
// TestRun_EdgeAddrs_SingleAddr verifies that a single --edge addr bypasses DNS
// probing. The report contains one DNS Skip row, transport rows labeled with
// the raw addr string, and the Management API row.
func TestRun_EdgeAddrs_SingleAddr(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
tcp := mocks.NewMockTCPDialer(ctrl)
quicD := mocks.NewMockQUICDialer(ctrl)
mgmt := mocks.NewMockManagementDialer(ctrl)
// DNS resolver must NOT be called when EdgeAddrs is set.
dns := mocks.NewMockDNSResolver(ctrl)
dns.EXPECT().Resolve(gomock.Any()).Times(0)
// One addr resolves to one group → one dial per transport.
tcp.EXPECT().DialEdge(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
Return(nopConn{}, nil).Times(1)
quicD.EXPECT().DialQuic(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
Return(&fakeQUICConn{}, nil).Times(1)
mgmt.EXPECT().DialContext(gomock.Any(), gomock.Any(), gomock.Any()).
Return(nopConn{}, nil)
cfg := Config{
EdgeAddrs: []string{"127.0.0.1:7844"},
Timeout: 2 * time.Second,
IPVersion: allregions.Auto,
}
report := Run(t.Context(), emptyCert, cfg, nopLogger(),
RunDialers{DNSResolver: dns, TCPDialer: tcp, QUICDialer: quicD, ManagementDialer: mgmt})
// 1 DNS Skip + 1 QUIC + 1 HTTP2 + 1 API = 4 results.
requireStatuses(t, report, Pass, Pass, Pass, Pass)
assert.Equal(t, ProbeTypeDNS, report.Results[0].Type, "first row must be DNS skip")
assert.Equal(t, "127.0.0.1:7844", report.Results[1].Target, "QUIC target must be the raw --edge addr")
assert.Equal(t, "127.0.0.1:7844", report.Results[2].Target, "HTTP2 target must be the raw --edge addr")
require.NotNil(t, report.SuggestedProtocol)
assert.Equal(t, connection.QUIC, *report.SuggestedProtocol)
}
// TestRun_EdgeAddrs_MultipleAddrs verifies that multiple --edge addrs produce
// one transport row per addr, each labeled with its original addr string.
func TestRun_EdgeAddrs_MultipleAddrs(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
tcp := mocks.NewMockTCPDialer(ctrl)
quicD := mocks.NewMockQUICDialer(ctrl)
mgmt := mocks.NewMockManagementDialer(ctrl)
dns := mocks.NewMockDNSResolver(ctrl)
dns.EXPECT().Resolve(gomock.Any()).Times(0)
// Two addrs → two groups → two dials per transport.
tcp.EXPECT().DialEdge(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
Return(nopConn{}, nil).Times(2)
quicD.EXPECT().DialQuic(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
Return(&fakeQUICConn{}, nil).Times(2)
mgmt.EXPECT().DialContext(gomock.Any(), gomock.Any(), gomock.Any()).
Return(nopConn{}, nil)
cfg := Config{
EdgeAddrs: []string{"127.0.0.1:7844", "127.0.0.2:7844"},
Timeout: 2 * time.Second,
IPVersion: allregions.Auto,
}
report := Run(t.Context(), emptyCert, cfg, nopLogger(),
RunDialers{DNSResolver: dns, TCPDialer: tcp, QUICDialer: quicD, ManagementDialer: mgmt})
// 2 DNS Pass (one per addr) + 2 QUIC + 2 HTTP2 + 1 API = 7 results.
requireStatuses(t, report, Pass, Pass, Pass, Pass, Pass, Pass, Pass)
assert.Equal(t, ProbeTypeDNS, report.Results[0].Type, "first row must be DNS skip addr1")
assert.Equal(t, "127.0.0.1:7844", report.Results[0].Target, "DNS skip addr1 label")
assert.Equal(t, ProbeTypeDNS, report.Results[1].Type, "second row must be DNS skip addr2")
assert.Equal(t, "127.0.0.2:7844", report.Results[1].Target, "DNS skip addr2 label")
assert.Equal(t, "127.0.0.1:7844", report.Results[2].Target, "QUIC addr1")
assert.Equal(t, "127.0.0.2:7844", report.Results[3].Target, "QUIC addr2")
assert.Equal(t, "127.0.0.1:7844", report.Results[4].Target, "HTTP2 addr1")
assert.Equal(t, "127.0.0.2:7844", report.Results[5].Target, "HTTP2 addr2")
}
// TestRun_EdgeAddrs_UnresolvableAddr verifies that when all --edge addrs fail
// to resolve, the DNS resolver is not called and transport rows are skipped,
// mirroring the DNS skip row.
func TestRun_EdgeAddrs_UnresolvableAddr(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
tcp := mocks.NewMockTCPDialer(ctrl)
quicD := mocks.NewMockQUICDialer(ctrl)
mgmt := mocks.NewMockManagementDialer(ctrl)
dns := mocks.NewMockDNSResolver(ctrl)
dns.EXPECT().Resolve(gomock.Any()).Times(0)
// Unresolvable addr → no groups → no transport dials.
tcp.EXPECT().DialEdge(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0)
quicD.EXPECT().DialQuic(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0)
mgmt.EXPECT().DialContext(gomock.Any(), gomock.Any(), gomock.Any()).
Return(nopConn{}, nil)
cfg := Config{
EdgeAddrs: []string{"not-a-valid-addr"},
Timeout: 2 * time.Second,
IPVersion: allregions.Auto,
}
report := Run(t.Context(), emptyCert, cfg, nopLogger(),
RunDialers{DNSResolver: dns, TCPDialer: tcp, QUICDialer: quicD, ManagementDialer: mgmt})
// 1 DNS Fail + 1 QUIC Skip + 1 HTTP2 Skip + 1 API = 4 results.
requireStatuses(t, report, Fail, Skip, Skip, Pass)
assert.Equal(t, ProbeTypeDNS, report.Results[0].Type)
assert.Equal(t, "not-a-valid-addr", report.Results[0].Target)
assert.Equal(t, ProbeTypeQUIC, report.Results[1].Type)
assert.Equal(t, ProbeTypeHTTP2, report.Results[2].Type)
assert.Nil(t, report.SuggestedProtocol)
assert.True(t, report.hasHardFail())
}
// ---------------------------------------------------------------------------
// Protocol override tests
// ---------------------------------------------------------------------------
// TestRun_ProtocolOverride_HTTP2_BothPass verifies that when --protocol http2
// is set and both transports are reachable, the summary reports HTTP/2 (not
// QUIC, which would otherwise win the heuristic).
func TestRun_ProtocolOverride_HTTP2_BothPass(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
dns := mocks.NewMockDNSResolver(ctrl)
tcp := mocks.NewMockTCPDialer(ctrl)
quicD := mocks.NewMockQUICDialer(ctrl)
mgmt := mocks.NewMockManagementDialer(ctrl)
dns.EXPECT().Resolve(gomock.Any()).Return(twoRegionAddrs(), nil)
tcp.EXPECT().DialEdge(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
Return(nopConn{}, nil).AnyTimes()
quicD.EXPECT().DialQuic(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
Return(&fakeQUICConn{}, nil).AnyTimes()
mgmt.EXPECT().DialContext(gomock.Any(), gomock.Any(), gomock.Any()).
Return(nopConn{}, nil)
cfg := Config{
Timeout: 2 * time.Second,
IPVersion: allregions.Auto,
ProtocolOverride: "http2",
}
report := Run(t.Context(), emptyCert, cfg, nopLogger(),
RunDialers{DNSResolver: dns, TCPDialer: tcp, QUICDialer: quicD, ManagementDialer: mgmt})
// Both transports pass, but the override must win — HTTP/2 is reported.
require.NotNil(t, report.SuggestedProtocol)
assert.Equal(t, connection.HTTP2, *report.SuggestedProtocol,
"override http2 should be reported even though QUIC probes also passed")
assert.False(t, report.hasHardFail())
}
// TestRun_ProtocolOverride_QUIC_BothPass verifies that when --protocol quic is
// set and both transports are reachable, the summary reports QUIC (same as the
// heuristic would choose, but driven by the override).
func TestRun_ProtocolOverride_QUIC_BothPass(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
dns := mocks.NewMockDNSResolver(ctrl)
tcp := mocks.NewMockTCPDialer(ctrl)
quicD := mocks.NewMockQUICDialer(ctrl)
mgmt := mocks.NewMockManagementDialer(ctrl)
dns.EXPECT().Resolve(gomock.Any()).Return(twoRegionAddrs(), nil)
tcp.EXPECT().DialEdge(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
Return(nopConn{}, nil).AnyTimes()
quicD.EXPECT().DialQuic(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
Return(&fakeQUICConn{}, nil).AnyTimes()
mgmt.EXPECT().DialContext(gomock.Any(), gomock.Any(), gomock.Any()).
Return(nopConn{}, nil)
cfg := Config{
Timeout: 2 * time.Second,
IPVersion: allregions.Auto,
ProtocolOverride: "quic",
}
report := Run(t.Context(), emptyCert, cfg, nopLogger(),
RunDialers{DNSResolver: dns, TCPDialer: tcp, QUICDialer: quicD, ManagementDialer: mgmt})
require.NotNil(t, report.SuggestedProtocol)
assert.Equal(t, connection.QUIC, *report.SuggestedProtocol)
assert.False(t, report.hasHardFail())
}
// TestRun_ProtocolOverride_HTTP2_QUICBlocked verifies that when --protocol http2
// is set and QUIC is blocked, we still report HTTP/2 (not a fallback to the
// heuristic, since the overridden transport is healthy).
func TestRun_ProtocolOverride_HTTP2_QUICBlocked(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
dns := mocks.NewMockDNSResolver(ctrl)
tcp := mocks.NewMockTCPDialer(ctrl)
quicD := mocks.NewMockQUICDialer(ctrl)
mgmt := mocks.NewMockManagementDialer(ctrl)
dns.EXPECT().Resolve(gomock.Any()).Return(twoRegionAddrs(), nil)
tcp.EXPECT().DialEdge(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
Return(nopConn{}, nil).AnyTimes()
quicD.EXPECT().DialQuic(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
Return(nil, errors.New("blocked")).AnyTimes()
mgmt.EXPECT().DialContext(gomock.Any(), gomock.Any(), gomock.Any()).
Return(nopConn{}, nil)
cfg := Config{
Timeout: 2 * time.Second,
IPVersion: allregions.Auto,
ProtocolOverride: "http2",
}
report := Run(t.Context(), emptyCert, cfg, nopLogger(),
RunDialers{DNSResolver: dns, TCPDialer: tcp, QUICDialer: quicD, ManagementDialer: mgmt})
require.NotNil(t, report.SuggestedProtocol)
assert.Equal(t, connection.HTTP2, *report.SuggestedProtocol)
assert.False(t, report.hasHardFail())
}
// TestRun_ProtocolOverride_HTTP2_BothBlocked verifies that when --protocol http2
// is set but the HTTP/2 transport itself also fails (hard fail), the override
// falls through to the heuristic which returns nil — there is no usable protocol.
func TestRun_ProtocolOverride_HTTP2_BothBlocked(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
dns := mocks.NewMockDNSResolver(ctrl)
tcp := mocks.NewMockTCPDialer(ctrl)
quicD := mocks.NewMockQUICDialer(ctrl)
mgmt := mocks.NewMockManagementDialer(ctrl)
dns.EXPECT().Resolve(gomock.Any()).Return(twoRegionAddrs(), nil)
tcp.EXPECT().DialEdge(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
Return(nil, errors.New("blocked")).AnyTimes()
quicD.EXPECT().DialQuic(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
Return(nil, errors.New("blocked")).AnyTimes()
mgmt.EXPECT().DialContext(gomock.Any(), gomock.Any(), gomock.Any()).
Return(nopConn{}, nil)
cfg := Config{
Timeout: 2 * time.Second,
IPVersion: allregions.Auto,
ProtocolOverride: "http2",
}
report := Run(t.Context(), emptyCert, cfg, nopLogger(),
RunDialers{DNSResolver: dns, TCPDialer: tcp, QUICDialer: quicD, ManagementDialer: mgmt})
// The overridden transport (HTTP/2) is blocked, so the override cannot be
// honoured and the hard-fail path reports no suggested protocol.
assert.Nil(t, report.SuggestedProtocol)
assert.True(t, report.hasHardFail())
}
+376
View File
@@ -0,0 +1,376 @@
package prechecks
import (
"context"
"crypto/tls"
"fmt"
"math"
"net"
"net/netip"
"time"
"github.com/quic-go/quic-go"
"github.com/rs/zerolog"
"github.com/cloudflare/cloudflared/connection/dialopts"
"github.com/cloudflare/cloudflared/connection"
edgedial "github.com/cloudflare/cloudflared/edgediscovery"
"github.com/cloudflare/cloudflared/edgediscovery/allregions"
cfdquic "github.com/cloudflare/cloudflared/quic"
"github.com/cloudflare/cloudflared/tlsconfig"
)
const (
perProbeDialTimeout = 5 * time.Second
// Action messages for each probe outcome.
actionDNSFail = "Ensure your DNS resolver can resolve '%s'. Run: dig A %s @1.1.1.1. If that fails, contact your network administrator."
actionQUICBlocked = "Allow outbound QUIC traffic on port 7844 or use HTTP2."
actionHTTP2Blocked = "Allow outbound TCP on port 7844."
actionAPIUnreachable = "cloudflared will still run, but automatic software updates are unavailable. " +
"Ensure port 443 TCP to api.cloudflare.com is open if you want auto-updates."
// Component names for CheckResult.
componentDNSResolution = "DNS Resolution"
componentUDPConnectivity = "UDP Connectivity"
componentTCPConnectivity = "TCP Connectivity"
componentCloudflareAPI = "Cloudflare API"
// Target identifiers for CheckResult.
targetPortQUIC = "Port 7844 (QUIC)"
targetPortHTTP2 = "Port 7844 (HTTP/2)"
targetAPI = "api.cloudflare.com:443"
noDNSTarget = "No DNS target (Using edge flag)"
// Details messages for CheckResult.
dnsNoAddressesReturned = "No addresses returned"
dnsResolvedSuccessfully = "DNS Resolved successfully"
detailsQUICHandshakeFailed = "QUIC connection failed"
detailsQUICHandshakeSuccessful = "QUIC connection successful"
detailsHTTP2BlockedOrUnreachable = "HTTP/2 connection is blocked or unreachable"
detailsHTTP2HandshakeSuccessful = "HTTP/2 connection successful"
detailsAPIConnectionFailed = "API Connection failed"
detailsApiReachable = "API is reachable"
detailsDNSPrerequisiteFailed = "DNS prerequisite failed"
detailsTLSConfigFailed = "TLS configuration failed"
// Region hostname templates.
region1Global = "region1.v2.argotunnel.com"
region2Global = "region2.v2.argotunnel.com"
region1US = "us-region1.v2.argotunnel.com"
region2US = "us-region2.v2.argotunnel.com"
region1Fed = "fed-region1.v2.argotunnel.com"
region2Fed = "fed-region2.v2.argotunnel.com"
)
// EdgeDNSResolver implements DNSResolver for the standard DNS-based edge
// discovery path.
type EdgeDNSResolver struct {
Log *zerolog.Logger
}
func (r *EdgeDNSResolver) Resolve(region string) ([][]*allregions.EdgeAddr, error) {
return allregions.EdgeDiscovery(r.Log, allregions.RegionalServiceName(region))
}
type EdgeTCPDialer struct{}
func (d *EdgeTCPDialer) DialEdge(
ctx context.Context,
timeout time.Duration,
tlsConfig *tls.Config,
addr *net.TCPAddr,
localIP net.IP,
) (net.Conn, error) {
return edgedial.DialEdge(ctx, timeout, tlsConfig, addr, localIP)
}
type EdgeQUICDialer struct{}
func (d *EdgeQUICDialer) DialQuic(
ctx context.Context,
quicConfig *quic.Config,
tlsConfig *tls.Config,
addr netip.AddrPort,
localAddr net.IP,
connIndex uint8,
logger *zerolog.Logger,
opts dialopts.DialOpts,
) (cfdquic.QUICConnection, error) {
return connection.DialQuic(ctx, quicConfig, tlsConfig, addr, localAddr, connIndex, logger, opts)
}
type NetManagementDialer struct {
Dialer net.Dialer
}
func (d *NetManagementDialer) DialContext(ctx context.Context, network, addr string) (net.Conn, error) {
return d.Dialer.DialContext(ctx, network, addr)
}
// probeTLSConfig builds a *tls.Config for a pre-check probe using the same
// certificate pool as the production tunnel. The SNI and NextProtos are taken from
// p.ProbeTLSSettings() so that the probe SNI is used instead of the production SNI,
// which avoids noisy logs in origintunneld.
func probeTLSConfig(caCert string, p connection.Protocol) (*tls.Config, error) {
settings := p.ProbeTLSSettings()
if settings == nil {
return nil, fmt.Errorf("no probe TLS settings for protocol %s", p)
}
cfg, err := tlsconfig.CreateTunnelConfig(caCert, settings.ServerName)
if err != nil {
return nil, err
}
if len(settings.NextProtos) > 0 {
cfg.NextProtos = settings.NextProtos
}
return cfg, nil
}
// probeDNS resolves edge addresses for the given region via the supplied
// DNSResolver and returns one ResolvedTarget per discovered region. If
// resolution fails entirely, every ResolvedTarget will carry a Fail DNSResult
// and nil Addrs.
func probeDNS(
resolver DNSResolver,
region string,
) []ResolvedTarget {
region1Target, region2Target := regionTargets(region)
targets := []string{region1Target, region2Target}
addrGroups, err := resolver.Resolve(region)
if err != nil || len(addrGroups) == 0 {
detail := dnsNoAddressesReturned
if err != nil {
detail = err.Error()
}
return []ResolvedTarget{
{DNSResult: newDNSCheckResult(region1Target, Fail, detail, fmt.Sprintf(actionDNSFail, region1Target, region1Target))},
{DNSResult: newDNSCheckResult(region2Target, Fail, detail, fmt.Sprintf(actionDNSFail, region2Target, region2Target))},
}
}
resolved := make([]ResolvedTarget, 0, len(addrGroups))
for i, target := range targets {
if i >= len(addrGroups) {
break
}
group := addrGroups[i]
if len(group) == 0 {
resolved = append(resolved, ResolvedTarget{
DNSResult: newDNSCheckResult(target, Fail, dnsNoAddressesReturned, fmt.Sprintf(actionDNSFail, target, target)),
})
} else {
resolved = append(resolved, ResolvedTarget{
Addrs: group,
DNSResult: newDNSCheckResult(target, Pass, dnsResolvedSuccessfully, ""),
})
}
}
return resolved
}
// probeQUIC performs a QUIC handshake to a single edge address and returns a
// CheckResult. The connection is closed immediately after the handshake no
// streams are opened and no RPC frames are sent to avoid triggering the OTD
// registration timeout (TUN-6732). The probe SNI (probe.cftunnel.com) is used
// instead of the production quic.cftunnel.com to prevent OTD log noise.
//
// A per-probe deadline (perProbeDialTimeout) is applied on top of the parent
// context so that a single blocked handshake cannot consume the entire suite
// budget.
func probeQUIC(
ctx context.Context,
tlsConfig *tls.Config,
dialer QUICDialer,
addr *allregions.EdgeAddr,
logger *zerolog.Logger,
) CheckResult {
dialCtx, cancel := context.WithTimeout(ctx, perProbeDialTimeout)
defer cancel()
// We call dialer.DialQuic with isProbe = true, which bypasses connIndex check.
// Therefore, whatever we add to connIndex will not be relevant.
edgeAddrPort := addr.UDP.AddrPort()
conn, err := dialer.DialQuic(
dialCtx,
&quic.Config{},
tlsConfig,
edgeAddrPort,
nil,
math.MaxUint8,
logger,
dialopts.DialOpts{SkipPortReuse: true},
)
if err != nil {
return CheckResult{
Type: ProbeTypeQUIC,
Component: componentUDPConnectivity,
Target: targetPortQUIC,
ProbeStatus: Fail,
Details: detailsQUICHandshakeFailed,
Action: actionQUICBlocked,
}
}
if err := conn.CloseWithError(0, "precheck complete"); err != nil {
logger.Debug().Err(err).Msg("Failed to close QUIC connection after successful handshake")
}
return CheckResult{
Type: ProbeTypeQUIC,
Component: componentUDPConnectivity,
Target: targetPortQUIC,
ProbeStatus: Pass,
Details: detailsQUICHandshakeSuccessful,
}
}
// probeHTTP2 performs a TCP + TLS handshake to a single edge address and
// returns a CheckResult. The connection is closed immediately after the
// handshake no HTTP/2 frames are sent to keep the probe minimal. The probe
// SNI (probe.cftunnel.com) is used instead of the production h2.cftunnel.com
// to prevent OTD log noise.
//
// The dial timeout is capped at perProbeDialTimeout so that a single blocked
// dial cannot exhaust the entire suite budget.
func probeHTTP2(ctx context.Context, tlsConfig *tls.Config, dialer TCPDialer, addr *allregions.EdgeAddr) CheckResult {
conn, err := dialer.DialEdge(ctx, perProbeDialTimeout, tlsConfig, addr.TCP, nil)
if err != nil {
return CheckResult{
Type: ProbeTypeHTTP2,
Component: componentTCPConnectivity,
Target: targetPortHTTP2,
ProbeStatus: Fail,
Details: detailsHTTP2BlockedOrUnreachable,
Action: actionHTTP2Blocked,
}
}
_ = conn.Close()
return CheckResult{
Type: ProbeTypeHTTP2,
Component: componentTCPConnectivity,
Target: targetPortHTTP2,
ProbeStatus: Pass,
Details: detailsHTTP2HandshakeSuccessful,
}
}
// probeManagementAPI tests TCP connectivity to api.cloudflare.com:443. A
// successful TCP connection (no TLS handshake required) confirms the port is
// reachable. This probe is always a soft failure: the tunnel can run without
// it, but automatic software updates will be unavailable.
func probeManagementAPI(ctx context.Context, dialer ManagementDialer) CheckResult {
dialCtx, cancel := context.WithTimeout(ctx, perProbeDialTimeout)
defer cancel()
conn, err := dialer.DialContext(dialCtx, "tcp", targetAPI)
if err != nil {
return CheckResult{
Type: ProbeTypeManagementAPI,
Component: componentCloudflareAPI,
Target: targetAPI,
ProbeStatus: Fail,
Details: detailsAPIConnectionFailed,
Action: actionAPIUnreachable,
}
}
_ = conn.Close()
return CheckResult{
Type: ProbeTypeManagementAPI,
Component: componentCloudflareAPI,
Target: targetAPI,
ProbeStatus: Pass,
Details: detailsApiReachable,
}
}
func skipResult(probeType ProbeType, component, target string, details string) CheckResult {
return CheckResult{
Type: probeType,
Component: component,
Target: target,
ProbeStatus: Skip,
Details: details,
}
}
// newDNSCheckResult creates a DNS CheckResult with the given fields.
// Type and Component are always ProbeTypeDNS and componentDNSResolution.
func newDNSCheckResult(target string, status Status, details, action string) CheckResult {
return CheckResult{
Type: ProbeTypeDNS,
Component: componentDNSResolution,
Target: target,
ProbeStatus: status,
Details: details,
Action: action,
}
}
// regionTargets returns the human-readable hostnames for region1 and region2
// based on the optional region flag value.
func regionTargets(region string) (string, string) {
switch region {
case "us":
return region1US, region2US
case "fed":
return region1Fed, region2Fed
default:
return region1Global, region2Global
}
}
// addrsByFamily extracts one V4 and one V6 address from a resolved CNAME group
// using allregions.NewRegion so that the IP-version preference logic matches
// production exactly. When cfg.IPVersion restricts to a single family the
// excluded family's pointer is nil.
func addrsByFamily(group []*allregions.EdgeAddr, ipVersion allregions.ConfigIPVersion) (v4, v6 *allregions.EdgeAddr) {
if ipVersion != allregions.IPv6Only {
v4 = allregions.NewRegion(group, allregions.IPv4Only).GetAnyAddress()
}
if ipVersion != allregions.IPv4Only {
v6 = allregions.NewRegion(group, allregions.IPv6Only).GetAnyAddress()
}
return
}
// runDNSProbe runs probeDNS with retry and returns []ResolvedTarget.
func runDNSProbe(ctx context.Context, resolver DNSResolver, region string) []ResolvedTarget {
var targets []ResolvedTarget
withRetry(ctx, maxRetries, func() bool {
targets = probeDNS(resolver, region)
for _, t := range targets {
if t.DNSResult.ProbeStatus == Fail {
return false
}
}
return len(targets) > 0
})
return targets
}
// resolveStaticEdge resolves each --edge addr individually, returning one
// ResolvedTarget per addr. Unresolvable addrs produce a Fail ResolvedTarget
// with nil Addrs so the report shows which addresses could not be reached.
func resolveStaticEdge(addrs []string, log *zerolog.Logger) []ResolvedTarget {
targets := make([]ResolvedTarget, 0, len(addrs))
for _, addr := range addrs {
resolved := allregions.ResolveAddrs([]string{addr}, log)
if len(resolved) > 0 {
targets = append(targets, ResolvedTarget{
Addrs: resolved,
DNSResult: newDNSCheckResult(addr, Pass, dnsResolvedSuccessfully, ""),
})
} else {
targets = append(targets, ResolvedTarget{
DNSResult: newDNSCheckResult(addr, Fail, dnsNoAddressesReturned, fmt.Sprintf(actionDNSFail, addr, addr)),
})
}
}
return targets
}
+595
View File
@@ -0,0 +1,595 @@
package prechecks
import (
"context"
"crypto/tls"
"errors"
"net"
"testing"
"github.com/quic-go/quic-go"
"github.com/rs/zerolog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"github.com/cloudflare/cloudflared/edgediscovery/allregions"
"github.com/cloudflare/cloudflared/mocks"
)
// Test constants for repeated string values.
const (
testRegion1Global = region1Global
testRegion2Global = region2Global
testRegion1US = region1US
testRegion2US = region2US
testRegion1Fed = region1Fed
testRegion2Fed = region2Fed
testEdgePort = 7844
)
// testTLSConfig is a minimal *tls.Config for tests. Mock dialers never
// perform a real TLS handshake, so an empty config is sufficient.
var testTLSConfig = &tls.Config{} //nolint:gosec
// mockQuicConnection is a minimal test double for quic.Connection.
type mockQuicConnection struct {
closeErr error
}
func (m *mockQuicConnection) AcceptStream(_ context.Context) (quic.Stream, error) {
return nil, nil
}
func (m *mockQuicConnection) AcceptUniStream(_ context.Context) (quic.ReceiveStream, error) {
return nil, nil
}
func (m *mockQuicConnection) OpenStream() (quic.Stream, error) {
return nil, nil
}
func (m *mockQuicConnection) OpenStreamSync(_ context.Context) (quic.Stream, error) {
return nil, nil
}
func (m *mockQuicConnection) OpenUniStream() (quic.SendStream, error) {
return nil, nil
}
func (m *mockQuicConnection) OpenUniStreamSync(_ context.Context) (quic.SendStream, error) {
return nil, nil
}
func (m *mockQuicConnection) LocalAddr() net.Addr {
return nil
}
func (m *mockQuicConnection) RemoteAddr() net.Addr {
return nil
}
func (m *mockQuicConnection) CloseWithError(_ quic.ApplicationErrorCode, _ string) error {
return m.closeErr
}
func (m *mockQuicConnection) Context() context.Context {
return context.Background()
}
func (m *mockQuicConnection) ConnectionState() quic.ConnectionState {
return quic.ConnectionState{}
}
func (m *mockQuicConnection) SendDatagram(_ []byte) error {
return nil
}
func (m *mockQuicConnection) ReceiveDatagram(_ context.Context) ([]byte, error) {
return nil, nil
}
func (m *mockQuicConnection) AddPath(*quic.Transport) (*quic.Path, error) {
return nil, nil
}
// Helper to create test edge addresses.
func createTestEdgeAddr(ip string, port int, version allregions.EdgeIPVersion) *allregions.EdgeAddr {
parsedIP := net.ParseIP(ip)
return &allregions.EdgeAddr{
TCP: &net.TCPAddr{IP: parsedIP, Port: port},
UDP: &net.UDPAddr{IP: parsedIP, Port: port},
IPVersion: version,
}
}
// probeDNS tests.
func TestProbeDNS_Success(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
v4Addr := createTestEdgeAddr("192.0.2.1", testEdgePort, allregions.V4)
v6Addr := createTestEdgeAddr("2001:db8::1", testEdgePort, allregions.V6)
resolver := mocks.NewMockDNSResolver(ctrl)
resolver.EXPECT().Resolve("").Return([][]*allregions.EdgeAddr{{v4Addr, v6Addr}}, nil)
targets := probeDNS(resolver, "")
require.Len(t, targets, 1)
assert.NotEmpty(t, targets[0].Addrs)
assert.Equal(t, ProbeTypeDNS, targets[0].DNSResult.Type)
assert.Equal(t, testRegion1Global, targets[0].DNSResult.Target)
assert.Equal(t, Pass, targets[0].DNSResult.ProbeStatus)
assert.Equal(t, dnsResolvedSuccessfully, targets[0].DNSResult.Details)
}
func TestProbeDNS_MultipleRegions(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
v4Addr1 := createTestEdgeAddr("192.0.2.1", testEdgePort, allregions.V4)
v4Addr2 := createTestEdgeAddr("192.0.2.2", testEdgePort, allregions.V4)
resolver := mocks.NewMockDNSResolver(ctrl)
resolver.EXPECT().Resolve("").Return([][]*allregions.EdgeAddr{{v4Addr1}, {v4Addr2}}, nil)
targets := probeDNS(resolver, "")
require.Len(t, targets, 2)
assert.Equal(t, testRegion1Global, targets[0].DNSResult.Target)
assert.Equal(t, Pass, targets[0].DNSResult.ProbeStatus)
assert.NotEmpty(t, targets[0].Addrs)
assert.Equal(t, testRegion2Global, targets[1].DNSResult.Target)
assert.Equal(t, Pass, targets[1].DNSResult.ProbeStatus)
assert.NotEmpty(t, targets[1].Addrs)
}
func TestProbeDNS_ResolverError(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
resolver := mocks.NewMockDNSResolver(ctrl)
resolver.EXPECT().Resolve("").Return(nil, errors.New("DNS lookup failed"))
targets := probeDNS(resolver, "")
require.Len(t, targets, 2)
assert.Empty(t, targets[0].Addrs)
assert.Equal(t, Fail, targets[0].DNSResult.ProbeStatus)
assert.Equal(t, "DNS lookup failed", targets[0].DNSResult.Details)
assert.Contains(t, targets[0].DNSResult.Action, testRegion1Global)
assert.Empty(t, targets[1].Addrs)
assert.Equal(t, Fail, targets[1].DNSResult.ProbeStatus)
assert.Contains(t, targets[1].DNSResult.Action, testRegion2Global)
}
func TestProbeDNS_EmptyResults(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
resolver := mocks.NewMockDNSResolver(ctrl)
resolver.EXPECT().Resolve("").Return([][]*allregions.EdgeAddr{}, nil)
targets := probeDNS(resolver, "")
require.Len(t, targets, 2)
assert.Empty(t, targets[0].Addrs)
assert.Equal(t, Fail, targets[0].DNSResult.ProbeStatus)
assert.Equal(t, dnsNoAddressesReturned, targets[0].DNSResult.Details)
}
func TestProbeDNS_EmptyGroup(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
resolver := mocks.NewMockDNSResolver(ctrl)
resolver.EXPECT().Resolve("").Return([][]*allregions.EdgeAddr{{}}, nil)
targets := probeDNS(resolver, "")
require.Len(t, targets, 1)
assert.Empty(t, targets[0].Addrs)
assert.Equal(t, Fail, targets[0].DNSResult.ProbeStatus)
assert.Equal(t, dnsNoAddressesReturned, targets[0].DNSResult.Details)
}
func TestProbeDNS_RegionFlag(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
v4Addr := createTestEdgeAddr("192.0.2.1", testEdgePort, allregions.V4)
resolver := mocks.NewMockDNSResolver(ctrl)
resolver.EXPECT().Resolve("us").Return([][]*allregions.EdgeAddr{{v4Addr}}, nil)
targets := probeDNS(resolver, "us")
require.Len(t, targets, 1)
assert.Equal(t, testRegion1US, targets[0].DNSResult.Target)
}
// probeQUIC tests.
func TestProbeQUIC_Success(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockConn := &mockQuicConnection{}
dialer := mocks.NewMockQUICDialer(ctrl)
dialer.EXPECT().DialQuic(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(mockConn, nil)
addr := createTestEdgeAddr("192.0.2.1", testEdgePort, allregions.V4)
logger := zerolog.New(nil)
result := probeQUIC(context.Background(), testTLSConfig, dialer, addr, &logger)
assert.Equal(t, ProbeTypeQUIC, result.Type)
assert.Equal(t, Pass, result.ProbeStatus)
assert.Equal(t, detailsQUICHandshakeSuccessful, result.Details)
}
func TestProbeQUIC_DialError(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
dialer := mocks.NewMockQUICDialer(ctrl)
dialer.EXPECT().DialQuic(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, errors.New("connection refused"))
addr := createTestEdgeAddr("192.0.2.1", testEdgePort, allregions.V4)
logger := zerolog.New(nil)
result := probeQUIC(context.Background(), testTLSConfig, dialer, addr, &logger)
assert.Equal(t, ProbeTypeQUIC, result.Type)
assert.Equal(t, Fail, result.ProbeStatus)
assert.Equal(t, detailsQUICHandshakeFailed, result.Details)
assert.Equal(t, actionQUICBlocked, result.Action)
}
func TestProbeQUIC_CloseErrorDoesNotAffectResult(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockConn := &mockQuicConnection{closeErr: errors.New("close failed")}
dialer := mocks.NewMockQUICDialer(ctrl)
dialer.EXPECT().DialQuic(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(mockConn, nil)
addr := createTestEdgeAddr("192.0.2.1", testEdgePort, allregions.V4)
logger := zerolog.New(nil)
result := probeQUIC(context.Background(), testTLSConfig, dialer, addr, &logger)
assert.Equal(t, ProbeTypeQUIC, result.Type)
assert.Equal(t, Pass, result.ProbeStatus)
assert.Equal(t, detailsQUICHandshakeSuccessful, result.Details)
}
func TestProbeQUIC_ContextTimeout(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
dialer := mocks.NewMockQUICDialer(ctrl)
dialer.EXPECT().DialQuic(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, context.DeadlineExceeded)
addr := createTestEdgeAddr("192.0.2.1", testEdgePort, allregions.V4)
logger := zerolog.New(nil)
result := probeQUIC(context.Background(), testTLSConfig, dialer, addr, &logger)
assert.Equal(t, Fail, result.ProbeStatus)
assert.Equal(t, detailsQUICHandshakeFailed, result.Details)
}
// probeHTTP2 tests.
func TestProbeHTTP2_Success(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
dialer := mocks.NewMockTCPDialer(ctrl)
dialer.EXPECT().DialEdge(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(&net.TCPConn{}, nil)
addr := createTestEdgeAddr("192.0.2.1", testEdgePort, allregions.V4)
result := probeHTTP2(context.Background(), testTLSConfig, dialer, addr)
assert.Equal(t, ProbeTypeHTTP2, result.Type)
assert.Equal(t, Pass, result.ProbeStatus)
assert.Equal(t, detailsHTTP2HandshakeSuccessful, result.Details)
}
func TestProbeHTTP2_DialError(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
dialer := mocks.NewMockTCPDialer(ctrl)
dialer.EXPECT().DialEdge(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, errors.New("connection refused"))
addr := createTestEdgeAddr("192.0.2.1", testEdgePort, allregions.V4)
result := probeHTTP2(context.Background(), testTLSConfig, dialer, addr)
assert.Equal(t, ProbeTypeHTTP2, result.Type)
assert.Equal(t, Fail, result.ProbeStatus)
assert.Equal(t, detailsHTTP2BlockedOrUnreachable, result.Details)
assert.Equal(t, actionHTTP2Blocked, result.Action)
}
// probeManagementAPI tests.
func TestProbeManagementAPI_Success(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
dialer := mocks.NewMockManagementDialer(ctrl)
dialer.EXPECT().DialContext(gomock.Any(), "tcp", "api.cloudflare.com:443").Return(&net.TCPConn{}, nil)
result := probeManagementAPI(context.Background(), dialer)
assert.Equal(t, ProbeTypeManagementAPI, result.Type)
assert.Equal(t, "Cloudflare API", result.Component)
assert.Equal(t, "api.cloudflare.com:443", result.Target)
assert.Equal(t, Pass, result.ProbeStatus)
assert.Equal(t, detailsApiReachable, result.Details)
}
func TestProbeManagementAPI_DialError(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
dialer := mocks.NewMockManagementDialer(ctrl)
dialer.EXPECT().DialContext(gomock.Any(), "tcp", "api.cloudflare.com:443").Return(nil, errors.New("connection refused"))
result := probeManagementAPI(context.Background(), dialer)
assert.Equal(t, ProbeTypeManagementAPI, result.Type)
assert.Equal(t, Fail, result.ProbeStatus)
assert.Equal(t, detailsAPIConnectionFailed, result.Details)
assert.Equal(t, actionAPIUnreachable, result.Action)
}
// skipResult tests.
func TestSkipResult(t *testing.T) {
t.Parallel()
result := skipResult(ProbeTypeQUIC, "UDP Connectivity", "Port 7844 (QUIC)", detailsDNSPrerequisiteFailed)
assert.Equal(t, ProbeTypeQUIC, result.Type)
assert.Equal(t, "UDP Connectivity", result.Component)
assert.Equal(t, "Port 7844 (QUIC)", result.Target)
assert.Equal(t, Skip, result.ProbeStatus)
assert.Equal(t, detailsDNSPrerequisiteFailed, result.Details)
}
// regionTargets tests.
func TestRegionTargets(t *testing.T) {
t.Parallel()
tests := []struct {
name string
region string
wantRegion1 string
wantRegion2 string
description string
}{
{
name: "empty region returns global hostnames",
region: "",
wantRegion1: testRegion1Global,
wantRegion2: testRegion2Global,
},
{
name: "us region returns US hostnames",
region: "us",
wantRegion1: testRegion1US,
wantRegion2: testRegion2US,
},
{
name: "fed region returns fed hostnames",
region: "fed",
wantRegion1: testRegion1Fed,
wantRegion2: testRegion2Fed,
},
{
name: "unknown region defaults to global hostnames",
region: "eu",
wantRegion1: testRegion1Global,
wantRegion2: testRegion2Global,
description: "Unknown regions should default to global hostnames",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
gotR1, gotR2 := regionTargets(tt.region)
assert.Equal(t, tt.wantRegion1, gotR1)
assert.Equal(t, tt.wantRegion2, gotR2)
})
}
}
// addrsByFamily tests.
func TestAddrsByFamily(t *testing.T) {
t.Parallel()
v4Addr := createTestEdgeAddr("192.0.2.1", testEdgePort, allregions.V4)
v6Addr := createTestEdgeAddr("2001:db8::1", testEdgePort, allregions.V6)
tests := []struct {
name string
group []*allregions.EdgeAddr
ipVersion allregions.ConfigIPVersion
wantV4 bool
wantV6 bool
}{
{
name: "auto returns both v4 and v6",
group: []*allregions.EdgeAddr{v4Addr, v6Addr},
ipVersion: allregions.Auto,
wantV4: true,
wantV6: true,
},
{
name: "ipv4 only returns v4 and nil v6",
group: []*allregions.EdgeAddr{v4Addr, v6Addr},
ipVersion: allregions.IPv4Only,
wantV4: true,
wantV6: false,
},
{
name: "ipv6 only returns nil v4 and v6",
group: []*allregions.EdgeAddr{v4Addr, v6Addr},
ipVersion: allregions.IPv6Only,
wantV4: false,
wantV6: true,
},
{
name: "empty group returns nil for both",
group: []*allregions.EdgeAddr{},
ipVersion: allregions.Auto,
wantV4: false,
wantV6: false,
},
{
name: "only v4 available returns v4 and nil v6",
group: []*allregions.EdgeAddr{v4Addr},
ipVersion: allregions.Auto,
wantV4: true,
wantV6: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
gotV4, gotV6 := addrsByFamily(tt.group, tt.ipVersion)
if tt.wantV4 {
assert.NotNil(t, gotV4)
} else {
assert.Nil(t, gotV4)
}
if tt.wantV6 {
assert.NotNil(t, gotV6)
} else {
assert.Nil(t, gotV6)
}
})
}
}
// IPv6 address tests for probeQUIC.
func TestProbeQUIC_IPv6Address(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockConn := &mockQuicConnection{}
dialer := mocks.NewMockQUICDialer(ctrl)
dialer.EXPECT().DialQuic(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(mockConn, nil)
addr := createTestEdgeAddr("2001:db8::1", testEdgePort, allregions.V6)
logger := zerolog.New(nil)
result := probeQUIC(context.Background(), testTLSConfig, dialer, addr, &logger)
assert.Equal(t, Pass, result.ProbeStatus)
assert.Equal(t, detailsQUICHandshakeSuccessful, result.Details)
}
// IPv6 address tests for probeHTTP2.
func TestProbeHTTP2_IPv6Address(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
dialer := mocks.NewMockTCPDialer(ctrl)
dialer.EXPECT().DialEdge(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(&net.TCPConn{}, nil)
addr := createTestEdgeAddr("2001:db8::1", testEdgePort, allregions.V6)
result := probeHTTP2(context.Background(), testTLSConfig, dialer, addr)
assert.Equal(t, Pass, result.ProbeStatus)
}
// resolveStaticEdge tests.
// TestResolveStaticEdge_SingleAddr verifies that a single resolvable --edge
// addr produces one group labeled with the original addr string.
func TestResolveStaticEdge_SingleAddr(t *testing.T) {
t.Parallel()
logger := zerolog.Nop()
targets := resolveStaticEdge([]string{"127.0.0.1:7844"}, &logger)
require.Len(t, targets, 1)
assert.Equal(t, "127.0.0.1:7844", targets[0].DNSResult.Target)
assert.Equal(t, Pass, targets[0].DNSResult.ProbeStatus)
assert.NotEmpty(t, targets[0].Addrs)
}
// TestResolveStaticEdge_MultipleAddrs verifies that multiple --edge addrs each
// produce their own ResolvedTarget, preserving per-addr structure and label order.
func TestResolveStaticEdge_MultipleAddrs(t *testing.T) {
t.Parallel()
logger := zerolog.Nop()
targets := resolveStaticEdge([]string{"127.0.0.1:7844", "127.0.0.2:7844"}, &logger)
require.Len(t, targets, 2)
assert.Equal(t, "127.0.0.1:7844", targets[0].DNSResult.Target)
assert.Equal(t, "127.0.0.2:7844", targets[1].DNSResult.Target)
}
// TestResolveStaticEdge_InvalidAddr verifies that an unresolvable addr is
// silently skipped and does not appear in the output.
func TestResolveStaticEdge_InvalidAddr(t *testing.T) {
t.Parallel()
logger := zerolog.Nop()
// "not-a-valid-addr" has no port — ResolveTCPAddr will fail.
targets := resolveStaticEdge([]string{"not-a-valid-addr"}, &logger)
require.Len(t, targets, 1)
assert.Equal(t, "not-a-valid-addr", targets[0].DNSResult.Target)
assert.Equal(t, Fail, targets[0].DNSResult.ProbeStatus)
assert.Equal(t, dnsNoAddressesReturned, targets[0].DNSResult.Details)
assert.Empty(t, targets[0].Addrs)
}
// TestResolveStaticEdge_PartiallyValid verifies that a mix of valid and invalid
// addrs produces one ResolvedTarget per addr — valid ones with Addrs and a Skip
// DNSResult, invalid ones with nil Addrs and a Fail DNSResult.
func TestResolveStaticEdge_PartiallyValid(t *testing.T) {
t.Parallel()
logger := zerolog.Nop()
targets := resolveStaticEdge([]string{"127.0.0.1:7844", "not-a-valid-addr", "127.0.0.2:7844"}, &logger)
require.Len(t, targets, 3)
assert.Equal(t, "127.0.0.1:7844", targets[0].DNSResult.Target)
assert.Equal(t, Pass, targets[0].DNSResult.ProbeStatus)
assert.NotEmpty(t, targets[0].Addrs)
assert.Equal(t, "not-a-valid-addr", targets[1].DNSResult.Target)
assert.Equal(t, Fail, targets[1].DNSResult.ProbeStatus)
assert.Empty(t, targets[1].Addrs)
assert.Equal(t, "127.0.0.2:7844", targets[2].DNSResult.Target)
assert.Equal(t, Pass, targets[2].DNSResult.ProbeStatus)
assert.NotEmpty(t, targets[2].Addrs)
}
+57
View File
@@ -0,0 +1,57 @@
package prechecks
import (
"context"
"crypto/tls"
"net"
"net/netip"
"time"
"github.com/quic-go/quic-go"
"github.com/rs/zerolog"
"github.com/cloudflare/cloudflared/connection/dialopts"
cfdquic "github.com/cloudflare/cloudflared/quic"
"github.com/cloudflare/cloudflared/edgediscovery/allregions"
)
// DNSResolver abstracts edge DNS discovery used by DNS probes.
type DNSResolver interface {
// Resolve performs edge discovery for the given region string and returns
// the resolved edge regions.
Resolve(region string) ([][]*allregions.EdgeAddr, error)
}
// TCPDialer abstracts the TCP + TLS handshake used by HTTP/2 connectivity probes.
type TCPDialer interface {
// DialEdge dials the given edge TCP address with TLS and returns the
// established connection. The caller is responsible for closing the connection.
DialEdge(ctx context.Context, timeout time.Duration, tlsConfig *tls.Config, addr *net.TCPAddr, localIP net.IP) (net.Conn, error)
}
// QUICDialer abstracts the UDP + QUIC handshake used by QUIC connectivity probes.
type QUICDialer interface {
// DialQuic performs a QUIC handshake to the given edge address and returns
// the established connection. The caller is responsible for closing the
// connection. connIndex is used for UDP port reuse bookkeeping consistent
// with the production dial path.
DialQuic(
ctx context.Context,
quicConfig *quic.Config,
tlsConfig *tls.Config,
addr netip.AddrPort,
localAddr net.IP,
connIndex uint8,
logger *zerolog.Logger,
opts dialopts.DialOpts,
) (cfdquic.QUICConnection, error)
}
// ManagementDialer abstracts the TCP dial to api.cloudflare.com:443 used by
// the Management API probe.
type ManagementDialer interface {
// DialContext opens a TCP connection to the given network address. The
// caller is responsible for closing the connection.
DialContext(ctx context.Context, network, addr string) (net.Conn, error)
}
+198
View File
@@ -0,0 +1,198 @@
package prechecks
import (
"bytes"
"fmt"
"strings"
"text/tabwriter"
"github.com/rs/zerolog"
)
const (
// Status names.
passStatus = "PASS"
failStatus = "FAIL"
skipStatus = "SKIP"
unknownStatus = "UNKNOWN"
// Log message constants.
logMsgPrecheck = "precheck"
logMsgPrecheckComplete = "precheck complete"
// Log field names.
logFieldRunID = "run_id"
logFieldComponent = "component"
logFieldTarget = "target"
logFieldStatus = "status"
logFieldDetails = "details"
logFieldHardFail = "hard_fail"
logFieldSuggestedProtocol = "suggested_protocol"
)
// statusLabel returns the display label for a given Status.
func (s Status) statusLabel() string {
switch s {
case Pass:
return passStatus
case Fail:
return failStatus
case Skip:
return skipStatus
default:
return unknownStatus
}
}
// logString returns the lowercase string used in structured log fields.
func (s Status) logString() string {
return strings.ToLower(s.String())
}
// renderTable uses text/tabwriter to format the results rows with
// automatically aligned columns, returning the rendered lines.
func renderTable(results []CheckResult) []string {
var buf bytes.Buffer
// minwidth=0, tabwidth=8, padding=2, padchar=' ', flags=0
w := tabwriter.NewWriter(&buf, 0, 8, 2, ' ', 0)
_, _ = fmt.Fprintln(w, "COMPONENT\tTARGET\tSTATUS\tDETAILS")
for _, r := range results {
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", r.Component, r.Target, r.ProbeStatus.statusLabel(), r.Details)
}
_ = w.Flush()
return strings.Split(strings.TrimSuffix(buf.String(), "\n"), "\n")
}
// renderActions collects all non-empty Action strings from results and returns
// the formatted warning/error block that appears between the table and SUMMARY.
// A Fail result is rendered as ERROR when the report is a hard fail, and as
// WARNING otherwise (degraded but tunnel can still run).
func renderActions(r Report) []string {
hardFail := r.hasHardFail()
actions := make([]string, 0)
for _, res := range r.Results {
if res.Action == "" || res.ProbeStatus != Fail {
continue
}
if hardFail {
actions = append(actions, fmt.Sprintf("ERROR: %s", res.Action))
} else {
actions = append(actions, fmt.Sprintf("WARNING: %s", res.Action))
}
}
return actions
}
// summaryLine builds the SUMMARY: line based on the Report state.
func summaryLine(r Report) string {
switch {
case r.hasHardFail():
return "SUMMARY: Environment has critical failures. cloudflared may not be able to establish a tunnel."
case r.hasWarn():
if r.SuggestedProtocol == nil {
return "SUMMARY: Environment ready with degraded transport."
}
protocol := r.SuggestedProtocol.String()
return fmt.Sprintf("SUMMARY: Environment ready with degraded transport. cloudflared will proceed using '%s'.", protocol)
default:
if r.SuggestedProtocol == nil {
return "SUMMARY: Environment is healthy."
}
protocol := r.SuggestedProtocol.String()
return fmt.Sprintf("SUMMARY: Environment is healthy. cloudflared will use '%s' as primary protocol.", protocol)
}
}
// hasHardFail returns true when the environment cannot establish a tunnel:
// - Any DNS probe failed, OR
// - Both the QUIC and HTTP/2 transport probes failed.
//
// A single transport failing is not a hard fail — the other transport can be
// used as a fallback (degraded state, reported via hasWarn).
func (r Report) hasHardFail() bool {
var quicFail, http2Fail bool
for _, res := range r.Results {
if res.ProbeStatus != Fail {
continue
}
switch res.Type {
case ProbeTypeDNS:
return true
case ProbeTypeQUIC:
quicFail = true
case ProbeTypeHTTP2:
http2Fail = true
case ProbeTypeManagementAPI:
// Management API failure is not a hard fail
}
}
return quicFail && http2Fail
}
// hasWarn returns true when the environment is degraded but functional:
// - Exactly one transport (QUIC or HTTP/2) failed, OR
// - The Management API is unreachable (auto-updates unavailable).
//
// Hard-fail conditions (DNS down, both transports blocked) take precedence
// and will cause hasWarn to return false.
func (r Report) hasWarn() bool {
if r.hasHardFail() {
return false
}
var quicFail, http2Fail, apiFail bool
for _, res := range r.Results {
if res.ProbeStatus != Fail {
continue
}
switch res.Type {
case ProbeTypeQUIC:
quicFail = true
case ProbeTypeHTTP2:
http2Fail = true
case ProbeTypeManagementAPI:
apiFail = true
case ProbeTypeDNS:
// DNS failures are only relevant for hard failures
}
}
return (quicFail != http2Fail) || apiFail
}
// String renders the Report as human-readable table lines suitable for logging.
func (r Report) String() []string {
lines := renderTable(r.Results)
lines = append(lines, renderActions(r)...)
lines = append(lines, "", summaryLine(r))
return lines
}
// LogEvent emits each CheckResult as a structured zerolog log line, followed by
// a final summary event. This is the JSON-logging equivalent of String().
// Every line carries run_id so all results from a single invocation can be correlated.
func (r Report) LogEvent(logger *zerolog.Logger) {
runID := r.RunID.String()
for _, res := range r.Results {
logger.Info().
Str(logFieldRunID, runID).
Str(logFieldComponent, res.Component).
Str(logFieldTarget, res.Target).
Str(logFieldStatus, res.ProbeStatus.logString()).
Str(logFieldDetails, res.Details).
Msg(logMsgPrecheck)
}
if r.SuggestedProtocol != nil {
logger.Info().
Str(logFieldRunID, runID).
Bool(logFieldHardFail, r.hasHardFail()).
Str(logFieldSuggestedProtocol, r.SuggestedProtocol.String()).
Msg(logMsgPrecheckComplete)
} else {
logger.Info().
Str(logFieldRunID, runID).
Bool(logFieldHardFail, r.hasHardFail()).
Msg(logMsgPrecheckComplete)
}
}
+417
View File
@@ -0,0 +1,417 @@
package prechecks
import (
"bytes"
"encoding/json"
"strings"
"testing"
"github.com/google/uuid"
"github.com/rs/zerolog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/cloudflare/cloudflared/connection"
)
// fixedRunID is used in all fixtures so golden strings are deterministic.
var fixedRunID = uuid.MustParse("00000000-0000-0000-0000-000000000001")
// Fixtures
// allPassReport is the all-checks-pass scenario: QUIC is the suggested protocol.
func allPassReport() Report {
return Report{
RunID: fixedRunID,
SuggestedProtocol: new(connection.QUIC),
Results: []CheckResult{
{Type: ProbeTypeDNS, Component: "DNS Resolution", Target: "region1.v2.argotunnel.com", ProbeStatus: Pass, Details: dnsResolvedSuccessfully},
{Type: ProbeTypeDNS, Component: "DNS Resolution", Target: "region2.v2.argotunnel.com", ProbeStatus: Pass, Details: dnsResolvedSuccessfully},
{Type: ProbeTypeQUIC, Component: "UDP Connectivity", Target: "Port 7844 (QUIC)", ProbeStatus: Pass, Details: detailsQUICHandshakeSuccessful},
{Type: ProbeTypeHTTP2, Component: "TCP Connectivity", Target: "Port 7844 (HTTP/2)", ProbeStatus: Pass, Details: detailsHTTP2HandshakeSuccessful},
{Type: ProbeTypeManagementAPI, Component: "Cloudflare API", Target: "api.cloudflare.com:443", ProbeStatus: Pass, Details: detailsApiReachable},
},
}
}
// quicBlockedReport is the degraded scenario: QUIC is blocked, HTTP/2 is the fallback.
// Only one transport failed so this is a warning, not a hard fail.
func quicBlockedReport() Report {
return Report{
RunID: fixedRunID,
SuggestedProtocol: new(connection.HTTP2),
Results: []CheckResult{
{Type: ProbeTypeDNS, Component: "DNS Resolution", Target: "region1.v2.argotunnel.com", ProbeStatus: Pass, Details: dnsResolvedSuccessfully},
{Type: ProbeTypeDNS, Component: "DNS Resolution", Target: "region2.v2.argotunnel.com", ProbeStatus: Pass, Details: dnsResolvedSuccessfully},
{
Type: ProbeTypeQUIC,
Component: "UDP Connectivity",
Target: "Port 7844 (QUIC)",
ProbeStatus: Fail,
Details: detailsQUICHandshakeFailed,
Action: actionQUICBlocked,
},
{Type: ProbeTypeHTTP2, Component: "TCP Connectivity", Target: "Port 7844 (HTTP/2)", ProbeStatus: Pass, Details: detailsHTTP2HandshakeSuccessful},
{Type: ProbeTypeManagementAPI, Component: "Cloudflare API", Target: "api.cloudflare.com:443", ProbeStatus: Pass, Details: detailsApiReachable},
},
}
}
// apiFailReport is the degraded scenario: all connectivity passes but the Cloudflare
// API is unreachable. The tunnel can still run; only automatic updates are unavailable.
func apiFailReport() Report {
return Report{
RunID: fixedRunID,
SuggestedProtocol: new(connection.QUIC),
Results: []CheckResult{
{Type: ProbeTypeDNS, Component: "DNS Resolution", Target: "region1.v2.argotunnel.com", ProbeStatus: Pass, Details: dnsResolvedSuccessfully},
{Type: ProbeTypeDNS, Component: "DNS Resolution", Target: "region2.v2.argotunnel.com", ProbeStatus: Pass, Details: dnsResolvedSuccessfully},
{Type: ProbeTypeQUIC, Component: "UDP Connectivity", Target: "Port 7844 (QUIC)", ProbeStatus: Pass, Details: detailsQUICHandshakeSuccessful},
{Type: ProbeTypeHTTP2, Component: "TCP Connectivity", Target: "Port 7844 (HTTP/2)", ProbeStatus: Pass, Details: detailsHTTP2HandshakeSuccessful},
{
Type: ProbeTypeManagementAPI,
Component: "Cloudflare API",
Target: "api.cloudflare.com:443",
ProbeStatus: Fail,
Details: "Connection refused",
Action: "cloudflared will still run, but automatic software updates are unavailable. Ensure port 443 TCP to api.cloudflare.com is open if you want auto-updates.",
},
},
}
}
// bothTransportsBlockedReport is the hard-fail scenario: both QUIC and HTTP/2 are blocked.
func bothTransportsBlockedReport() Report {
return Report{
RunID: fixedRunID,
SuggestedProtocol: nil,
Results: []CheckResult{
{Type: ProbeTypeDNS, Component: "DNS Resolution", Target: "region1.v2.argotunnel.com", ProbeStatus: Pass, Details: dnsResolvedSuccessfully},
{Type: ProbeTypeDNS, Component: "DNS Resolution", Target: "region2.v2.argotunnel.com", ProbeStatus: Pass, Details: dnsResolvedSuccessfully},
{
Type: ProbeTypeQUIC,
Component: "UDP Connectivity",
Target: "Port 7844 (QUIC)",
ProbeStatus: Fail,
Details: detailsQUICHandshakeFailed,
Action: "Allow outbound QUIC and/or TCP on port 7844 to the Cloudflare edge.",
},
{
Type: ProbeTypeHTTP2,
Component: "TCP Connectivity",
Target: "Port 7844 (HTTP/2)",
ProbeStatus: Fail,
Details: detailsHTTP2BlockedOrUnreachable,
},
{Type: ProbeTypeManagementAPI, Component: "Cloudflare API", Target: "api.cloudflare.com:443", ProbeStatus: Pass, Details: detailsApiReachable},
},
}
}
// dnsFailReport is the hard-fail scenario: DNS is unresolvable, transports are skipped.
func dnsFailReport() Report {
return Report{
RunID: fixedRunID,
SuggestedProtocol: nil,
Results: []CheckResult{
{
Type: ProbeTypeDNS,
Component: "DNS Resolution",
Target: "region1.v2.argotunnel.com",
ProbeStatus: Fail,
Details: "No addresses returned",
Action: "Ensure your DNS resolver can resolve 'region1.v2.argotunnel.com'. Run: dig A region1.v2.argotunnel.com @1.1.1.1. If that fails, contact your network administrator.",
},
{Type: ProbeTypeDNS, Component: "DNS Resolution", Target: "region2.v2.argotunnel.com", ProbeStatus: Fail, Details: "No addresses returned"},
{Type: ProbeTypeQUIC, Component: "UDP Connectivity", Target: "Port 7844 (QUIC)", ProbeStatus: Skip, Details: "DNS prerequisite failed"},
{Type: ProbeTypeHTTP2, Component: "TCP Connectivity", Target: "Port 7844 (HTTP/2)", ProbeStatus: Skip, Details: "DNS prerequisite failed"},
{Type: ProbeTypeManagementAPI, Component: "Cloudflare API", Target: "api.cloudflare.com:443", ProbeStatus: Fail, Details: "Connection refused"},
},
}
}
// String() / table renderer tests
func TestString_AllPass(t *testing.T) {
t.Parallel()
want := []string{
"COMPONENT TARGET STATUS DETAILS",
"DNS Resolution region1.v2.argotunnel.com PASS DNS Resolved successfully",
"DNS Resolution region2.v2.argotunnel.com PASS DNS Resolved successfully",
"UDP Connectivity Port 7844 (QUIC) PASS QUIC connection successful",
"TCP Connectivity Port 7844 (HTTP/2) PASS HTTP/2 connection successful",
"Cloudflare API api.cloudflare.com:443 PASS API is reachable",
"",
"SUMMARY: Environment is healthy. cloudflared will use 'quic' as primary protocol.",
}
assert.Equal(t, want, allPassReport().String())
}
func TestString_QuicBlocked(t *testing.T) {
t.Parallel()
want := []string{
"COMPONENT TARGET STATUS DETAILS",
"DNS Resolution region1.v2.argotunnel.com PASS DNS Resolved successfully",
"DNS Resolution region2.v2.argotunnel.com PASS DNS Resolved successfully",
"UDP Connectivity Port 7844 (QUIC) FAIL QUIC connection failed",
"TCP Connectivity Port 7844 (HTTP/2) PASS HTTP/2 connection successful",
"Cloudflare API api.cloudflare.com:443 PASS API is reachable",
"WARNING: Allow outbound QUIC traffic on port 7844 or use HTTP2.",
"",
"SUMMARY: Environment ready with degraded transport. cloudflared will proceed using 'http2'.",
}
assert.Equal(t, want, quicBlockedReport().String())
}
func TestString_APIFail(t *testing.T) {
t.Parallel()
want := []string{
"COMPONENT TARGET STATUS DETAILS",
"DNS Resolution region1.v2.argotunnel.com PASS DNS Resolved successfully",
"DNS Resolution region2.v2.argotunnel.com PASS DNS Resolved successfully",
"UDP Connectivity Port 7844 (QUIC) PASS QUIC connection successful",
"TCP Connectivity Port 7844 (HTTP/2) PASS HTTP/2 connection successful",
"Cloudflare API api.cloudflare.com:443 FAIL Connection refused",
"WARNING: cloudflared will still run, but automatic software updates are unavailable. Ensure port 443 TCP to api.cloudflare.com is open if you want auto-updates.",
"",
"SUMMARY: Environment ready with degraded transport. cloudflared will proceed using 'quic'.",
}
assert.Equal(t, want, apiFailReport().String())
}
func TestString_BothTransportsBlocked(t *testing.T) {
t.Parallel()
want := []string{
"COMPONENT TARGET STATUS DETAILS",
"DNS Resolution region1.v2.argotunnel.com PASS DNS Resolved successfully",
"DNS Resolution region2.v2.argotunnel.com PASS DNS Resolved successfully",
"UDP Connectivity Port 7844 (QUIC) FAIL QUIC connection failed",
"TCP Connectivity Port 7844 (HTTP/2) FAIL HTTP/2 connection is blocked or unreachable",
"Cloudflare API api.cloudflare.com:443 PASS API is reachable",
"ERROR: Allow outbound QUIC and/or TCP on port 7844 to the Cloudflare edge.",
"",
"SUMMARY: Environment has critical failures. cloudflared may not be able to establish a tunnel.",
}
assert.Equal(t, want, bothTransportsBlockedReport().String())
}
func TestString_DNSFail(t *testing.T) {
t.Parallel()
want := []string{
"COMPONENT TARGET STATUS DETAILS",
"DNS Resolution region1.v2.argotunnel.com FAIL No addresses returned",
"DNS Resolution region2.v2.argotunnel.com FAIL No addresses returned",
"UDP Connectivity Port 7844 (QUIC) SKIP DNS prerequisite failed",
"TCP Connectivity Port 7844 (HTTP/2) SKIP DNS prerequisite failed",
"Cloudflare API api.cloudflare.com:443 FAIL Connection refused",
"ERROR: Ensure your DNS resolver can resolve 'region1.v2.argotunnel.com'. Run: dig A region1.v2.argotunnel.com @1.1.1.1. If that fails, contact your network administrator.",
"",
"SUMMARY: Environment has critical failures. cloudflared may not be able to establish a tunnel.",
}
assert.Equal(t, want, dnsFailReport().String())
}
func TestString_EmptyResults(t *testing.T) {
t.Parallel()
r := Report{RunID: fixedRunID, SuggestedProtocol: new(connection.QUIC)}
out := r.String()
// Must not panic and must still emit a valid skeleton.
require.Len(t, out, 3)
assert.Contains(t, out[0], "COMPONENT")
assert.Contains(t, out[2], "SUMMARY:")
}
// LogEvent() / structured log renderer tests
// logEntry is a helper struct to unmarshal a single JSON log line emitted by LogEvent.
type logEntry struct {
Level string `json:"level"`
RunID string `json:"run_id"`
Component string `json:"component"`
Target string `json:"target"`
Status string `json:"status"`
Details string `json:"details"`
Message string `json:"message"`
HardFail *bool `json:"hard_fail"`
SuggestedProtocol *string `json:"suggested_protocol"`
}
// captureLogLines runs LogEvent against a buffer-backed zerolog logger and
// returns the parsed JSON entries, one per emitted line.
func captureLogLines(t *testing.T, r Report) []logEntry {
t.Helper()
var buf bytes.Buffer
logger := zerolog.New(&buf)
r.LogEvent(&logger)
var entries []logEntry
for _, line := range strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") {
if line == "" {
continue
}
var e logEntry
require.NoError(t, json.Unmarshal([]byte(line), &e), "failed to parse log line: %s", line)
entries = append(entries, e)
}
return entries
}
func TestLogEvent_AllPass(t *testing.T) {
t.Parallel()
r := allPassReport()
entries := captureLogLines(t, r)
// One line per result plus one summary line.
require.Len(t, entries, len(r.Results)+1)
// Each result line carries the right fields.
expected := []struct {
component string
target string
status string
details string
}{
{"DNS Resolution", "region1.v2.argotunnel.com", "pass", dnsResolvedSuccessfully},
{"DNS Resolution", "region2.v2.argotunnel.com", "pass", dnsResolvedSuccessfully},
{"UDP Connectivity", "Port 7844 (QUIC)", "pass", detailsQUICHandshakeSuccessful},
{"TCP Connectivity", "Port 7844 (HTTP/2)", "pass", detailsHTTP2HandshakeSuccessful},
{"Cloudflare API", "api.cloudflare.com:443", "pass", detailsApiReachable},
}
for i, exp := range expected {
e := entries[i]
assert.Equal(t, "info", e.Level, "entry %d: level", i)
assert.Equal(t, fixedRunID.String(), e.RunID, "entry %d: run_id", i)
assert.Equal(t, exp.component, e.Component, "entry %d: component", i)
assert.Equal(t, exp.target, e.Target, "entry %d: target", i)
assert.Equal(t, exp.status, e.Status, "entry %d: status", i)
assert.Equal(t, exp.details, e.Details, "entry %d: details", i)
assert.Equal(t, logMsgPrecheck, e.Message, "entry %d: message", i)
}
// Summary line.
summary := entries[len(entries)-1]
assert.Equal(t, logMsgPrecheckComplete, summary.Message)
assert.Equal(t, fixedRunID.String(), summary.RunID)
require.NotNil(t, summary.HardFail)
assert.False(t, *summary.HardFail)
require.NotNil(t, summary.SuggestedProtocol)
assert.Equal(t, "quic", *summary.SuggestedProtocol)
}
func TestLogEvent_QuicBlocked(t *testing.T) {
t.Parallel()
entries := captureLogLines(t, quicBlockedReport())
// QUIC row (index 2) must carry status=fail and the right details.
quic := entries[2]
assert.Equal(t, "fail", quic.Status)
assert.Equal(t, "UDP Connectivity", quic.Component)
assert.Equal(t, "Port 7844 (QUIC)", quic.Target)
assert.Equal(t, "QUIC connection failed", quic.Details)
assert.Equal(t, fixedRunID.String(), quic.RunID)
// Summary: not a hard fail (HTTP/2 still works), protocol falls back to http2.
summary := entries[len(entries)-1]
require.NotNil(t, summary.HardFail)
assert.False(t, *summary.HardFail)
require.NotNil(t, summary.SuggestedProtocol)
assert.Equal(t, "http2", *summary.SuggestedProtocol)
assert.Equal(t, fixedRunID.String(), summary.RunID)
}
func TestLogEvent_APIFail(t *testing.T) {
t.Parallel()
entries := captureLogLines(t, apiFailReport())
// API row (index 4) carries status=fail and the expected details.
api := entries[4]
assert.Equal(t, "fail", api.Status)
assert.Equal(t, "Cloudflare API", api.Component)
assert.Equal(t, "api.cloudflare.com:443", api.Target)
assert.Equal(t, "Connection refused", api.Details)
assert.Equal(t, fixedRunID.String(), api.RunID)
// All transport rows pass.
assert.Equal(t, "pass", entries[2].Status)
assert.Equal(t, "pass", entries[3].Status)
// Summary: not a hard fail, QUIC is still the suggested protocol.
summary := entries[len(entries)-1]
require.NotNil(t, summary.HardFail)
assert.False(t, *summary.HardFail)
require.NotNil(t, summary.SuggestedProtocol)
assert.Equal(t, "quic", *summary.SuggestedProtocol)
}
func TestLogEvent_BothTransportsBlocked(t *testing.T) {
t.Parallel()
entries := captureLogLines(t, bothTransportsBlockedReport())
// Both transport rows carry status=fail.
assert.Equal(t, "fail", entries[2].Status)
assert.Equal(t, "QUIC connection failed", entries[2].Details)
assert.Equal(t, "fail", entries[3].Status)
assert.Equal(t, "HTTP/2 connection is blocked or unreachable", entries[3].Details)
summary := entries[len(entries)-1]
require.NotNil(t, summary.HardFail)
assert.True(t, *summary.HardFail)
assert.Nil(t, summary.SuggestedProtocol)
}
func TestLogEvent_DNSFail(t *testing.T) {
t.Parallel()
entries := captureLogLines(t, dnsFailReport())
// Both DNS rows carry status=fail and the expected details.
assert.Equal(t, "fail", entries[0].Status)
assert.Equal(t, "No addresses returned", entries[0].Details)
assert.Equal(t, "fail", entries[1].Status)
assert.Equal(t, "No addresses returned", entries[1].Details)
// Transport rows are skipped.
assert.Equal(t, "skip", entries[2].Status)
assert.Equal(t, "DNS prerequisite failed", entries[2].Details)
assert.Equal(t, "skip", entries[3].Status)
assert.Equal(t, "DNS prerequisite failed", entries[3].Details)
summary := entries[len(entries)-1]
require.NotNil(t, summary.HardFail)
assert.True(t, *summary.HardFail)
assert.Nil(t, summary.SuggestedProtocol)
}
func TestLogEvent_EmptyReport(t *testing.T) {
t.Parallel()
r := Report{RunID: fixedRunID, SuggestedProtocol: new(connection.HTTP2)}
entries := captureLogLines(t, r)
// No result lines, only the summary.
require.Len(t, entries, 1)
assert.Equal(t, logMsgPrecheckComplete, entries[0].Message)
assert.Equal(t, fixedRunID.String(), entries[0].RunID)
require.NotNil(t, entries[0].HardFail)
assert.False(t, *entries[0].HardFail)
require.NotNil(t, entries[0].SuggestedProtocol)
assert.Equal(t, "http2", *entries[0].SuggestedProtocol)
}
// hasHardFail / hasWarn helper tests
func TestHasHardFail(t *testing.T) {
t.Parallel()
assert.False(t, allPassReport().hasHardFail())
assert.False(t, quicBlockedReport().hasHardFail())
assert.False(t, apiFailReport().hasHardFail())
assert.True(t, bothTransportsBlockedReport().hasHardFail())
assert.True(t, dnsFailReport().hasHardFail())
}
func TestHasWarn(t *testing.T) {
t.Parallel()
assert.False(t, allPassReport().hasWarn())
assert.True(t, quicBlockedReport().hasWarn())
assert.True(t, apiFailReport().hasWarn())
assert.False(t, bothTransportsBlockedReport().hasWarn())
assert.False(t, dnsFailReport().hasWarn())
}
+138
View File
@@ -0,0 +1,138 @@
package prechecks
import (
"time"
"github.com/google/uuid"
"github.com/cloudflare/cloudflared/connection"
"github.com/cloudflare/cloudflared/edgediscovery/allregions"
)
// Status represents the outcome of a single connectivity pre-check.
type Status int
const (
// Pass indicates the check completed successfully.
Pass Status = iota
// Fail indicates the check did not succeed. Whether this is a hard failure
// or a degraded-but-functional state depends on which probe(s) failed — see
// Report.hasHardFail and Report.hasWarn.
Fail
// Skip indicates the check was not executed because a prerequisite
// check (typically DNS) failed first.
Skip
)
// String returns the canonical display name for a Status value.
func (s Status) String() string {
switch s {
case Pass:
return "PASS"
case Fail:
return "FAIL"
case Skip:
return "SKIP"
default:
return "UNKNOWN"
}
}
// ProbeType identifies which connectivity probe produced a CheckResult.
// It is used by hasHardFail and hasWarn to evaluate severity without
// matching against human-readable strings.
type ProbeType int
const (
ProbeTypeDNS ProbeType = iota // DNS resolution
ProbeTypeQUIC // UDP/QUIC transport
ProbeTypeHTTP2 // TCP/HTTP2 transport
ProbeTypeManagementAPI // Cloudflare management API
)
// CheckResult holds the outcome of one individual connectivity probe.
type CheckResult struct {
// Type identifies which probe produced this result. Used for severity
// classification in hasHardFail and hasWarn.
Type ProbeType
// Component is the human-readable probe category shown in the table header
// column
Component string
// Target is the address or resource that was probed
Target string
// ProbeStatus is the outcome of the probe.
ProbeStatus Status
// Details is a short description of the result shown in the table
Details string
// Action is non-empty when ProbeStatus is Fail and contains a human-readable
// remediation instruction
Action string
}
// ResolvedTarget bundles a resolved edge target's addresses with the DNS
// CheckResult that describes it. This keeps addr groups and their report rows
// together as a single unit, avoiding parallel-slice synchronization.
type ResolvedTarget struct {
// Addrs holds the resolved edge addresses for this target. May be empty
// when DNS resolution succeeded structurally but returned no IPs.
Addrs []*allregions.EdgeAddr
// DNSResult is the CheckResult representing DNS resolution for this target.
// Its Target field is the human-readable label used across all probe rows.
DNSResult CheckResult
}
// Report aggregates all CheckResults produced by a single Run() invocation.
// Pre-checks run in parallel with tunnel initialization and are purely
// diagnostic: the Report is displayed to the user but never gates startup.
type Report struct {
// RunID is a unique identifier for this pre-check run. It is included in
// every structured log line so that all results from a single invocation
// can be correlated across log aggregation systems.
RunID uuid.UUID
// Results contains one entry per executed probe, in the order they were
// collected.
Results []CheckResult
// SuggestedProtocol is the connection protocol the pre-checks recommend
// based on transport probe results. Nil when no valid protocol is available
// (e.g., when both transports fail or DNS is unresolvable).
SuggestedProtocol *connection.Protocol
}
// Config controls the behavior of a pre-check Run().
type Config struct {
// Region is the optional cloudflared --region flag value. When non-empty
// the pre-check probes the regional edge hostnames instead of the global ones.
Region string
// Timeout is the maximum wall-clock duration allowed for the entire
// pre-check suite to complete.
Timeout time.Duration
// IPVersion controls which address families are probed for transport
// checks. It mirrors the --edge-ip-version CLI flag so that the pre-check
// exercises the same code paths the tunnel itself will use.
IPVersion allregions.ConfigIPVersion
// EdgeAddrs, when non-empty, contains the --edge flag values (explicit
// edge addresses). When set, DNS probing is skipped entirely — there are
// no SRV records to validate — and transport probes target each addr
// individually, labeled with the original addr string.
EdgeAddrs []string
// ProtocolOverride is the raw --protocol flag value (e.g. "quic",
// "http2", "h2mux"). When non-empty and not "auto", the pre-checks still
// probe both transports for diagnostic completeness, but the reported
// SuggestedProtocol honours the override so that the summary message
// reflects what cloudflared will actually use — not what the probe
// heuristic would recommend on its own. Parsing happens inside the
// prechecks package.
ProtocolOverride string
}
+8 -8
View File
@@ -137,7 +137,7 @@ func (p *Proxy) ProxyHTTP(
p.proxyLocalRequest(originProxy, w, req, isWebsocket)
return nil
default:
return fmt.Errorf("Unrecognized service: %s, %t", rule.Service, originProxy)
return fmt.Errorf("unrecognized service: %s, %t", rule.Service, originProxy)
}
}
@@ -193,7 +193,7 @@ func (p *Proxy) proxyHTTPRequest(
) error {
roundTripReq := tr.Request
if isWebsocket {
roundTripReq = tr.Clone(tr.Request.Context())
roundTripReq = tr.Clone(tr.Context())
roundTripReq.Header.Set("Connection", "Upgrade")
roundTripReq.Header.Set("Upgrade", "websocket")
roundTripReq.Header.Set("Sec-Websocket-Version", "13")
@@ -203,7 +203,7 @@ func (p *Proxy) proxyHTTPRequest(
// Support for WSGI Servers by switching transfer encoding from chunked to gzip/deflate
if disableChunkedEncoding {
roundTripReq.TransferEncoding = []string{"gzip", "deflate"}
cLength, err := strconv.Atoi(tr.Request.Header.Get("Content-Length"))
cLength, err := strconv.Atoi(tr.Header.Get("Content-Length"))
if err == nil {
roundTripReq.ContentLength = int64(cLength)
}
@@ -228,7 +228,7 @@ func (p *Proxy) proxyHTTPRequest(
}
tracing.EndWithStatusCode(ttfbSpan, resp.StatusCode)
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
headers := make(http.Header, len(resp.Header))
// copy headers
@@ -249,11 +249,11 @@ func (p *Proxy) proxyHTTPRequest(
if !ok {
return errors.New("internal error: unsupported connection type")
}
defer rwc.Close()
defer func() { _ = rwc.Close() }()
eyeballStream := &bidirectionalStream{
writer: w,
reader: tr.Request.Body,
reader: tr.Body,
}
stream.Pipe(eyeballStream, rwc, logger)
@@ -292,7 +292,7 @@ func (p *Proxy) proxyStream(
return err
}
connectSpan.End()
defer originConn.Close()
defer func() { _ = originConn.Close() }()
logger.Debug().Msg("origin connection established")
encodedSpans := tr.GetSpans()
@@ -331,7 +331,7 @@ func (p *Proxy) proxyTCPStream(
return err
}
connectSpan.End()
defer originConn.Close()
defer func() { _ = originConn.Close() }()
logger.Debug().Msg("origin connection established")
encodedSpans := tr.GetSpans()
+10 -6
View File
@@ -5,7 +5,6 @@ import (
"fmt"
"github.com/pkg/errors"
"github.com/quic-go/quic-go"
"github.com/rs/zerolog"
"github.com/cloudflare/cloudflared/packet"
@@ -51,14 +50,14 @@ func (dm *DatagramMuxerV2) mtu() int {
}
type DatagramMuxerV2 struct {
session quic.Connection
session QUICConnection
logger *zerolog.Logger
sessionDemuxChan chan<- *packet.Session
packetDemuxChan chan Packet
}
func NewDatagramMuxerV2(
quicSession quic.Connection,
quicSession QUICConnection,
log *zerolog.Logger,
sessionDemuxChan chan<- *packet.Session,
) *DatagramMuxerV2 {
@@ -110,7 +109,8 @@ func (dm *DatagramMuxerV2) SendPacket(pk Packet) error {
return nil
}
// Demux reads datagrams from the QUIC connection and demuxes depending on whether it's a session or packet
// ServeReceive reads datagrams from the QUIC connection and demuxes them
// depending on whether it's a session or packet
func (dm *DatagramMuxerV2) ServeReceive(ctx context.Context) error {
for {
msg, err := dm.session.ReceiveDatagram(ctx)
@@ -144,8 +144,10 @@ func (dm *DatagramMuxerV2) demux(ctx context.Context, msgWithType []byte) error
switch msgType {
case DatagramTypeUDP:
return dm.handleSession(ctx, msg)
default:
case DatagramTypeIP, DatagramTypeIPWithTrace, DatagramTypeTracingSpan:
return dm.handlePacket(ctx, msg, msgType)
default:
return fmt.Errorf("unexpected datagram type %d", msgType)
}
}
@@ -189,8 +191,10 @@ func (dm *DatagramMuxerV2) handlePacket(ctx context.Context, pk []byte, msgType
Spans: spans,
TracingIdentity: tracingIdentity,
}
case DatagramTypeUDP:
return fmt.Errorf("unexpected datagram type %d in handlePacket", msgType)
default:
return fmt.Errorf("Unexpected datagram type %d", msgType)
return fmt.Errorf("unexpected datagram type %d", msgType)
}
select {
case <-ctx.Done():
+105
View File
@@ -0,0 +1,105 @@
package quic
import (
"context"
"errors"
"io"
"net"
"github.com/quic-go/quic-go"
)
// QUICConnection defines the subset of [quic.Connection] methods used by cloudflared.
// Consumers should accept this interface; producers should return [*ConnWithCloser].
type QUICConnection interface {
AcceptStream(ctx context.Context) (quic.Stream, error)
OpenStream() (quic.Stream, error)
OpenStreamSync(ctx context.Context) (quic.Stream, error)
CloseWithError(code quic.ApplicationErrorCode, reason string) error
Context() context.Context
SendDatagram(payload []byte) error
ReceiveDatagram(ctx context.Context) ([]byte, error)
LocalAddr() net.Addr
RemoteAddr() net.Addr
ConnectionState() quic.ConnectionState
}
// Compile-time assertion that *ConnWithCloser implements QUICConnection.
var _ QUICConnection = (*ConnWithCloser)(nil)
var (
// error returned when the [NewConnWithCloser] is called with a nil conn argument
ErrNilQuicConnection = errors.New("the provided quic connection is nil")
// error returned when the [NewConnWithCloser] is called with a nil closer argument
ErrNilCloser = errors.New("the provided closer is nil")
)
// ConnWithCloser wraps a [quic.Connection] and an [io.Closer] (typically the
// underlying [*net.UDPConn]). When [CloseWithError] is called the QUIC
// connection is closed first, then the closer is closed deterministically.
//
// A nil conn is only safe for [CloseWithError] (used in tests). All other
// delegated methods will panic on a nil conn.
type ConnWithCloser struct {
conn quic.Connection
closer io.Closer
}
// NewQUICConnection returns a [*ConnWithCloser] that will close closer after
// the QUIC connection is closed.
func NewQUICConnection(conn quic.Connection, closer io.Closer) (*ConnWithCloser, error) {
if conn == nil {
return nil, ErrNilQuicConnection
}
if closer == nil {
return nil, ErrNilCloser
}
return &ConnWithCloser{conn: conn, closer: closer}, nil
}
// CloseWithError closes the QUIC connection and then closes the underlying
// [io.Closer]. If both operations return errors, the errors are joined so that
// the closer error is no longer silently discarded.
func (c *ConnWithCloser) CloseWithError(code quic.ApplicationErrorCode, reason string) error {
connErr := c.conn.CloseWithError(code, reason)
closerErr := c.closer.Close()
return errors.Join(connErr, closerErr)
}
func (c *ConnWithCloser) AcceptStream(ctx context.Context) (quic.Stream, error) {
return c.conn.AcceptStream(ctx)
}
func (c *ConnWithCloser) OpenStream() (quic.Stream, error) {
return c.conn.OpenStream()
}
func (c *ConnWithCloser) OpenStreamSync(ctx context.Context) (quic.Stream, error) {
return c.conn.OpenStreamSync(ctx)
}
func (c *ConnWithCloser) Context() context.Context {
return c.conn.Context()
}
func (c *ConnWithCloser) SendDatagram(payload []byte) error {
return c.conn.SendDatagram(payload)
}
func (c *ConnWithCloser) ReceiveDatagram(ctx context.Context) ([]byte, error) {
return c.conn.ReceiveDatagram(ctx)
}
func (c *ConnWithCloser) LocalAddr() net.Addr {
return c.conn.LocalAddr()
}
func (c *ConnWithCloser) RemoteAddr() net.Addr {
return c.conn.RemoteAddr()
}
func (c *ConnWithCloser) ConnectionState() quic.ConnectionState {
return c.conn.ConnectionState()
}
+108
View File
@@ -0,0 +1,108 @@
package quic
import (
"errors"
"testing"
"github.com/quic-go/quic-go"
"github.com/stretchr/testify/require"
)
// mockCloser is an [io.Closer] that returns a configurable error.
type mockCloser struct {
closeErr error
}
func (m *mockCloser) Close() error {
return m.closeErr
}
// mockQuicConnection is a minimal test double for [quic.Connection].
type mockQuicConnection struct {
quic.Connection
closeWithErrorErr error
}
func (m *mockQuicConnection) CloseWithError(_ quic.ApplicationErrorCode, _ string) error {
return m.closeWithErrorErr
}
func TestNewConnWithCloser_NilConn(t *testing.T) {
t.Parallel()
conn, err := NewQUICConnection(nil, &mockCloser{})
require.ErrorIs(t, err, ErrNilQuicConnection)
require.Nil(t, conn)
}
func TestNewConnWithCloser_NilCloser(t *testing.T) {
t.Parallel()
conn, err := NewQUICConnection(&mockQuicConnection{}, nil)
require.ErrorIs(t, err, ErrNilCloser)
require.Nil(t, conn)
}
func TestNewConnWithCloser_Success(t *testing.T) {
t.Parallel()
qc := &mockQuicConnection{}
cl := &mockCloser{}
conn, err := NewQUICConnection(qc, cl)
require.NoError(t, err)
require.NotNil(t, conn)
}
func TestConnWithCloser_CloseWithError_BothSucceed(t *testing.T) {
t.Parallel()
qc := &mockQuicConnection{}
cl := &mockCloser{}
conn, err := NewQUICConnection(qc, cl)
require.NoError(t, err)
err = conn.CloseWithError(0, "test")
require.NoError(t, err)
}
func TestConnWithCloser_CloseWithError_QuicFails(t *testing.T) {
t.Parallel()
quicErr := errors.New("quic close failed")
qc := &mockQuicConnection{closeWithErrorErr: quicErr}
cl := &mockCloser{}
conn, err := NewQUICConnection(qc, cl)
require.NoError(t, err)
err = conn.CloseWithError(0, "test")
require.ErrorIs(t, err, quicErr)
}
func TestConnWithCloser_CloseWithError_CloserFails(t *testing.T) {
t.Parallel()
closerErr := errors.New("closer failed")
qc := &mockQuicConnection{}
cl := &mockCloser{closeErr: closerErr}
conn, err := NewQUICConnection(qc, cl)
require.NoError(t, err)
err = conn.CloseWithError(0, "test")
require.ErrorIs(t, err, closerErr)
}
func TestConnWithCloser_CloseWithError_BothFail(t *testing.T) {
t.Parallel()
quicErr := errors.New("quic close failed")
closerErr := errors.New("closer failed")
qc := &mockQuicConnection{closeWithErrorErr: quicErr}
cl := &mockCloser{closeErr: closerErr}
conn, err := NewQUICConnection(qc, cl)
require.NoError(t, err)
err = conn.CloseWithError(0, "test")
require.ErrorIs(t, err, quicErr)
require.ErrorIs(t, err, closerErr)
}
// TestConnWithCloser_ImplementsInterface is a runtime assertion that
// *ConnWithCloser satisfies QUICConnection. The compile-time assertion is in
// quic_connection.go.
func TestConnWithCloser_ImplementsInterface(t *testing.T) {
t.Parallel()
var _ QUICConnection = (*ConnWithCloser)(nil)
}

Some files were not shown because too many files have changed in this diff Show More