cloudflare-tunnel 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +22 -0
- data/LICENSE.txt +21 -0
- data/README.md +160 -0
- data/lib/cloudflare/tasks/tunnel.rake +15 -0
- data/lib/cloudflare/tunnel/check.rb +58 -0
- data/lib/cloudflare/tunnel/configuration.rb +64 -0
- data/lib/cloudflare/tunnel/probe.rb +33 -0
- data/lib/cloudflare/tunnel/railtie.rb +25 -0
- data/lib/cloudflare/tunnel/runner.rb +106 -0
- data/lib/cloudflare/tunnel/version.rb +7 -0
- data/lib/cloudflare/tunnel.rb +23 -0
- data/lib/cloudflare-tunnel.rb +3 -0
- data/lib/generators/cloudflare_tunnel/install_generator.rb +45 -0
- data/lib/generators/cloudflare_tunnel/templates/initializer.rb.tt +15 -0
- metadata +117 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: c80792cd4e7e48be6c97df5edf07c554d6285394607f70926e4e2a0d019572c7
|
|
4
|
+
data.tar.gz: b0bd9ffc636ec2029a83234d8172d5c8fba76ac20ba8b4e53b42e56c5df4b831
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: d91a8ac77dd2452910e816080413197b61c4d4b1a6353aa7a75dc9f2e2270ed4f24c14e17163aa00b11b999555ea2df0a535de36891d9b3004bf52c0dfb46c40
|
|
7
|
+
data.tar.gz: 6150c30d7d44ab48f3ecfe78fc2e9d192ebd4e41da51b4c31f7c77622a2f5465849923753d110c39fdcca0bee337e6a6722f218c4431e3c6be97e0be8832ca8b
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
## [0.1.0] - 2026-11-04
|
|
2
|
+
|
|
3
|
+
### Added
|
|
4
|
+
|
|
5
|
+
- Rails configuration generator and explicit development tunnel tasks.
|
|
6
|
+
- Quiet offline retries, configurable cloudflared log levels and separate logs.
|
|
7
|
+
- Installation guidance without automatic executable installation.
|
|
8
|
+
- Connector readiness and signed local/public application diagnostics.
|
|
9
|
+
- Secure temporary tokens, per-app locking and bounded child shutdown.
|
|
10
|
+
- Local quality checks and tag-triggered GitHub Actions publishing.
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Release preparation runs from main, checks for duplicate tags, runs local
|
|
15
|
+
checks, commits version and changelog updates, and pushes the branch and tag.
|
|
16
|
+
- The first release can use the existing version and changelog without a version
|
|
17
|
+
bump or empty commit. Publishing runs in GitHub Actions after the tag is pushed.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- Use a shared credentials action with an explicit RubyGems owner for trusted
|
|
22
|
+
publishing of both first and subsequent releases.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Paulo Fidalgo and Ethos Link
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# Cloudflare Tunnel
|
|
2
|
+
|
|
3
|
+
Run a remotely-managed Cloudflare tunnel alongside a Rails development server.
|
|
4
|
+
Offline startup and connection loss stay quiet and do not stop the server or
|
|
5
|
+
jobs. The gem supervises an existing `cloudflared` executable; it never installs,
|
|
6
|
+
downloads or updates it, creates a tunnel, or changes DNS.
|
|
7
|
+
|
|
8
|
+
Requires Ruby 4.0+, Rails 8.1 and a POSIX host (Linux or macOS).
|
|
9
|
+
MIT licensed; maintained by [Ethos Link](https://www.ethos-link.com).
|
|
10
|
+
This is an independent integration.
|
|
11
|
+
|
|
12
|
+
## Rails setup
|
|
13
|
+
|
|
14
|
+
Add to the development group:
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
group :development do
|
|
18
|
+
gem "cloudflare-tunnel", "~> 0.1"
|
|
19
|
+
end
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
bundle install
|
|
24
|
+
bin/rails generate cloudflare_tunnel:install \
|
|
25
|
+
--hostname=devel.example.com --port=3000 --metrics-port=20241
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The generator creates `config/initializers/cloudflare_tunnel.rb` and adds
|
|
29
|
+
`tunnel: bin/rails cloudflare_tunnel:start` to `Procfile.dev`.
|
|
30
|
+
|
|
31
|
+
Install the executable yourself using [Cloudflare's instructions](https://developers.cloudflare.com/tunnel/downloads/).
|
|
32
|
+
When missing, the start task prints that link once and stays idle so Foreman
|
|
33
|
+
keeps other services running. The check task reports it and exits with failure.
|
|
34
|
+
|
|
35
|
+
In Cloudflare, configure a remotely-managed tunnel's published application
|
|
36
|
+
hostname to use `http://localhost:3000` (match your Rails port). Store its
|
|
37
|
+
connector token with `bin/rails credentials:edit`:
|
|
38
|
+
|
|
39
|
+
```yaml
|
|
40
|
+
cloudflare:
|
|
41
|
+
tunnel:
|
|
42
|
+
token: YOUR_TUNNEL_TOKEN
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`TUNNEL_TOKEN` overrides credentials. Tokens use temporary files with mode `0600`,
|
|
46
|
+
stay out of command arguments and diagnostics, and are removed on normal exit,
|
|
47
|
+
INT and TERM. A forced OS kill cannot run cleanup; remove abandoned
|
|
48
|
+
`cloudflare-tunnel-*.token` files from your OS temporary directory if needed.
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
bin/dev
|
|
52
|
+
# In a second terminal, after Rails and the tunnel are running:
|
|
53
|
+
bin/rails cloudflare_tunnel:check
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Adding the gem or booting Rails never starts a tunnel. Only the explicit start
|
|
57
|
+
task does. Tasks refuse to run outside development. The diagnostic middleware
|
|
58
|
+
is installed only in development. The generated initializer guards against the
|
|
59
|
+
gem being excluded from other Bundler groups.
|
|
60
|
+
|
|
61
|
+
## Configuration
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
if Rails.env.development?
|
|
65
|
+
Cloudflare::Tunnel.configure do |config|
|
|
66
|
+
config.hostname = "devel.example.com"
|
|
67
|
+
config.origin = "http://localhost:#{ENV.fetch('PORT', '3000')}"
|
|
68
|
+
config.metrics_port = 20241
|
|
69
|
+
config.log_level = :error
|
|
70
|
+
config.log_path = "log/cloudflare-tunnel.log"
|
|
71
|
+
config.retry_interval = 30
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
| Option | Default | Purpose |
|
|
77
|
+
| --- | --- | --- |
|
|
78
|
+
| `hostname` | required | Bare public hostname configured in Cloudflare |
|
|
79
|
+
| `origin` | `http://localhost:$PORT` (`3000` otherwise) | Expected Rails origin; change Cloudflare's origin setting separately |
|
|
80
|
+
| `metrics_port` | `20241` | Readiness/metrics port bound to `127.0.0.1`; choose a distinct port for every concurrent app |
|
|
81
|
+
| `log_level` | `:error` | Native cloudflared level: `:debug`, `:info`, `:warn`, `:error`, `:fatal` |
|
|
82
|
+
| `log_path` | `log/cloudflare-tunnel.log` | Append connector output here, relative to Rails root; `nil` discards it |
|
|
83
|
+
| `retry_interval` | `30` | Seconds before restarting an exited connector |
|
|
84
|
+
| `shutdown_timeout` | `5` | Seconds after TERM before killing a stuck child |
|
|
85
|
+
| `check_timeout` | `5` | HTTP connection/read/write timeouts, per diagnostic request |
|
|
86
|
+
| `executable` | `cloudflared` | Name on PATH or an explicit filesystem path |
|
|
87
|
+
| `enabled` | true | False when `TUNNEL` is `0`, `false`, `off` or `no`; configurable explicitly |
|
|
88
|
+
| `token` | lazy Rails credentials / `TUNNEL_TOKEN` | String or callable; keep real tokens out of initializers |
|
|
89
|
+
|
|
90
|
+
The connector handles its own reconnections. If it exits, the supervisor retries
|
|
91
|
+
after `retry_interval`, without recurring terminal messages. Disabled tunnels,
|
|
92
|
+
missing credentials and invalid configurations print one actionable message and
|
|
93
|
+
stay idle. Fix configuration and restart. The check fails for those states.
|
|
94
|
+
|
|
95
|
+
Use `TUNNEL=0 bin/dev` to disable the connector. Use your local Rails URL offline.
|
|
96
|
+
OAuth callbacks and provider requests still need internet. Turnstile behavior
|
|
97
|
+
remains application-owned.
|
|
98
|
+
|
|
99
|
+
The native log level filters cloudflared messages. Connector output stays out of
|
|
100
|
+
Rails logs and recurring Foreman output. Set `log_path = nil` to discard it.
|
|
101
|
+
Ignore `log/` and `tmp/` in Git; appended logs need normal local cleanup. Debug
|
|
102
|
+
logs may include request details; enable that level only while diagnosing.
|
|
103
|
+
|
|
104
|
+
A per-app file lock prevents two gem-managed connectors in the same working
|
|
105
|
+
directory. Stop older Docker or system-service connectors for that tunnel
|
|
106
|
+
yourself. Other working directories can still run replicas and receive traffic.
|
|
107
|
+
|
|
108
|
+
## Diagnostics
|
|
109
|
+
|
|
110
|
+
`bin/rails cloudflare_tunnel:check` returns `0` only when all checks pass:
|
|
111
|
+
|
|
112
|
+
1. Configuration, executable availability and token presence.
|
|
113
|
+
2. The local Rails origin returns a fresh signed probe response.
|
|
114
|
+
3. The connector's `/ready` endpoint reports an active Cloudflare connection.
|
|
115
|
+
4. HTTPS on the public hostname returns the expected response from this app.
|
|
116
|
+
|
|
117
|
+
The development-only `/.well-known/cloudflare-tunnel?nonce=…` probe uses an HMAC
|
|
118
|
+
with a purpose-specific key derived from Rails' secret key base and returns
|
|
119
|
+
`Cache-Control: no-store`. It creates no records and exposes no credentials.
|
|
120
|
+
Fresh nonces prevent stale cached health pages passing the check. The signature
|
|
121
|
+
identifies the application's key, not a particular machine sharing that key.
|
|
122
|
+
The configured metrics port must belong to this connector.
|
|
123
|
+
|
|
124
|
+
Failures identify the layer and return `1`, including offline failures. The check
|
|
125
|
+
does not start a connector or change configuration. Redirects, Access login pages
|
|
126
|
+
and incorrect origins/app keys fail. Executable availability alone does not
|
|
127
|
+
validate the token; the running connector and end-to-end checks establish that.
|
|
128
|
+
Authentication errors remain in the separate connector log. No Cloudflare
|
|
129
|
+
management API token is required.
|
|
130
|
+
|
|
131
|
+
References: [run parameters](https://developers.cloudflare.com/tunnel/reference/run-parameters/)
|
|
132
|
+
and [readiness example](https://developers.cloudflare.com/tunnel/guides/kubernetes/).
|
|
133
|
+
|
|
134
|
+
## Development
|
|
135
|
+
|
|
136
|
+
Run `bin/setup` to install development dependencies, then `bin/ci` to run
|
|
137
|
+
Minitest, Standard Ruby, the gem build and changelog generation. Tests control
|
|
138
|
+
connector executables and HTTP boundaries; they need no Cloudflare account or
|
|
139
|
+
internet once dependencies are installed.
|
|
140
|
+
|
|
141
|
+
Use `bundle exec rake -T` to list available tasks. See the
|
|
142
|
+
[changelog](CHANGELOG.md) for version history.
|
|
143
|
+
|
|
144
|
+
## More from Ethos Link
|
|
145
|
+
|
|
146
|
+
Cloudflare Tunnel is maintained by [Ethos Link](https://www.ethos-link.com),
|
|
147
|
+
the software company behind Reviato and Nuntio. We build business products and
|
|
148
|
+
share open-source software.
|
|
149
|
+
|
|
150
|
+
- [Reviato — customer feedback and review analytics](https://www.reviato.com):
|
|
151
|
+
Collect private guest feedback and explore recurring themes in public reviews.
|
|
152
|
+
Hotels, restaurants and other local businesses can read the original customer
|
|
153
|
+
comments, track feedback and decide what needs attention.
|
|
154
|
+
- [Nuntio — social media scheduling and publishing](https://www.nuntiohq.com):
|
|
155
|
+
Prepare social posts ahead of time, adapt captions and media for each channel,
|
|
156
|
+
and bring drafts, team review, scheduling and publishing outcomes into one
|
|
157
|
+
workspace.
|
|
158
|
+
|
|
159
|
+
Explore [Ethos Link's products](https://www.ethos-link.com/products) and
|
|
160
|
+
[open-source projects](https://www.ethos-link.com/opensource).
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :cloudflare_tunnel do
|
|
4
|
+
desc "Run the optional development tunnel with quiet offline retries"
|
|
5
|
+
task start: :environment do
|
|
6
|
+
abort "cloudflare_tunnel:start is only available in development" unless Rails.env.development?
|
|
7
|
+
Cloudflare::Tunnel::Runner.new(Cloudflare::Tunnel.configuration).run
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
desc "Check configuration, connector, local origin and public app identity"
|
|
11
|
+
task check: :environment do
|
|
12
|
+
abort "cloudflare_tunnel:check is only available in development" unless Rails.env.development?
|
|
13
|
+
exit(Cloudflare::Tunnel::Check.new(Cloudflare::Tunnel.configuration).run ? 0 : 1)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module Cloudflare
|
|
7
|
+
module Tunnel
|
|
8
|
+
class Check
|
|
9
|
+
NETWORK_ERRORS = [SocketError, SystemCallError, Timeout::Error, IOError, OpenSSL::SSL::SSLError].freeze
|
|
10
|
+
|
|
11
|
+
def initialize(configuration, output: $stdout)
|
|
12
|
+
@configuration = configuration
|
|
13
|
+
@output = output
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def run
|
|
17
|
+
issues = @configuration.errors
|
|
18
|
+
issues << "tunnel is disabled" unless @configuration.enabled
|
|
19
|
+
issues << "cloudflared not found. Install it yourself: #{INSTALL_URL}" unless @configuration.executable_path
|
|
20
|
+
issues << "token missing; set cloudflare.tunnel.token in Rails credentials" if @configuration.token_value.empty?
|
|
21
|
+
if issues.any?
|
|
22
|
+
issues.each { |issue| @output.puts("FAIL: #{issue}") }
|
|
23
|
+
return false
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
nonce = SecureRandom.hex(32)
|
|
27
|
+
expected = Probe.signature(@configuration.probe_secret.call, nonce)
|
|
28
|
+
local = verify("local origin", "#{@configuration.origin.to_s.chomp("/")}#{Probe::PATH}?nonce=#{nonce}", expected)
|
|
29
|
+
connector = verify("Cloudflare connection", "http://127.0.0.1:#{@configuration.metrics_port}/ready")
|
|
30
|
+
public_app = verify("public hostname", "https://#{@configuration.hostname}#{Probe::PATH}?nonce=#{nonce}", expected)
|
|
31
|
+
@output.puts("Origin must be #{@configuration.origin} in Cloudflare's published application settings.") unless local && connector && public_app
|
|
32
|
+
local && connector && public_app
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def verify(label, url, expected = nil)
|
|
38
|
+
uri = URI(url)
|
|
39
|
+
# Ignore proxy environment variables for loopback diagnostics.
|
|
40
|
+
http = Net::HTTP.new(uri.hostname, uri.port, (uri.hostname == @configuration.hostname) ? :ENV : nil)
|
|
41
|
+
http.use_ssl = uri.scheme == "https"
|
|
42
|
+
http.open_timeout = http.read_timeout = http.write_timeout = @configuration.check_timeout
|
|
43
|
+
http.max_retries = 0
|
|
44
|
+
response = http.get(uri.request_uri, {"Cache-Control" => "no-cache"})
|
|
45
|
+
if response.code == "200" && (!expected || response.body == expected)
|
|
46
|
+
@output.puts("OK: #{label}")
|
|
47
|
+
true
|
|
48
|
+
else
|
|
49
|
+
@output.puts("FAIL: #{label} (HTTP #{response.code}; expected #{expected ? "this Rails application" : "an active connector"}). Check DNS, Cloudflare Access, origin and #{@configuration.log_path || "logging configuration"}.")
|
|
50
|
+
false
|
|
51
|
+
end
|
|
52
|
+
rescue *NETWORK_ERRORS => error
|
|
53
|
+
@output.puts("FAIL: #{label} (#{error.class}); network or service unavailable")
|
|
54
|
+
false
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Cloudflare
|
|
6
|
+
module Tunnel
|
|
7
|
+
class Configuration
|
|
8
|
+
LOG_LEVELS = %w[debug info warn error fatal].freeze
|
|
9
|
+
attr_accessor :enabled, :executable, :token, :hostname, :origin, :metrics_port,
|
|
10
|
+
:log_level, :log_path, :retry_interval, :shutdown_timeout, :check_timeout,
|
|
11
|
+
:root, :probe_secret
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@enabled = !%w[0 false off no].include?(ENV.fetch("TUNNEL", "1").downcase)
|
|
15
|
+
@executable = "cloudflared"
|
|
16
|
+
@root = Dir.pwd
|
|
17
|
+
@origin = "http://localhost:#{ENV.fetch("PORT", "3000")}"
|
|
18
|
+
@metrics_port = 20241
|
|
19
|
+
@log_level = :error
|
|
20
|
+
@log_path = "log/cloudflare-tunnel.log"
|
|
21
|
+
@retry_interval = 30
|
|
22
|
+
@shutdown_timeout = 5
|
|
23
|
+
@check_timeout = 5
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def token_value
|
|
27
|
+
(token.respond_to?(:call) ? token.call : token).to_s.strip
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def executable_path
|
|
31
|
+
paths = executable.to_s.include?(File::SEPARATOR) ? [File.expand_path(executable, root)] :
|
|
32
|
+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).map { |directory| File.join(directory, executable) }
|
|
33
|
+
paths.find { |path| File.file?(path) && File.executable?(path) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def path(value)
|
|
37
|
+
File.expand_path(value, root)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def errors
|
|
41
|
+
messages = []
|
|
42
|
+
messages << "hostname must be a bare public hostname" unless hostname.to_s.match?(/\A[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?\z/i)
|
|
43
|
+
uri = URI.parse(origin.to_s)
|
|
44
|
+
unless uri.is_a?(URI::HTTP) && %w[localhost 127.0.0.1 ::1].include?(uri.hostname) &&
|
|
45
|
+
uri.userinfo.nil? && [nil, "", "/"].include?(uri.path) && uri.query.nil? && uri.fragment.nil?
|
|
46
|
+
messages << "origin must be an HTTP(S) loopback URL without a path or credentials"
|
|
47
|
+
end
|
|
48
|
+
messages << "metrics_port must be between 1 and 65535" unless metrics_port.is_a?(Integer) && (1..65535).cover?(metrics_port)
|
|
49
|
+
messages << "log_level must be one of #{LOG_LEVELS.join(", ")}" unless LOG_LEVELS.include?(log_level.to_s)
|
|
50
|
+
%i[retry_interval shutdown_timeout check_timeout].each do |name|
|
|
51
|
+
value = case name
|
|
52
|
+
when :retry_interval then retry_interval
|
|
53
|
+
when :shutdown_timeout then shutdown_timeout
|
|
54
|
+
when :check_timeout then check_timeout
|
|
55
|
+
end
|
|
56
|
+
messages << "#{name} must be a positive finite number" unless value.is_a?(Numeric) && value.finite? && value.positive?
|
|
57
|
+
end
|
|
58
|
+
messages
|
|
59
|
+
rescue URI::InvalidURIError
|
|
60
|
+
messages << "origin must be a valid loopback URL"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "rack/request"
|
|
5
|
+
|
|
6
|
+
module Cloudflare
|
|
7
|
+
module Tunnel
|
|
8
|
+
class Probe
|
|
9
|
+
PATH = "/.well-known/cloudflare-tunnel"
|
|
10
|
+
|
|
11
|
+
def self.signature(secret, nonce)
|
|
12
|
+
OpenSSL::HMAC.hexdigest("SHA256", secret, nonce)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize(app, secret:)
|
|
16
|
+
@app = app
|
|
17
|
+
@secret = secret
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call(env)
|
|
21
|
+
request = Rack::Request.new(env)
|
|
22
|
+
return @app.call(env) unless request.path_info == PATH
|
|
23
|
+
|
|
24
|
+
nonce = request.GET["nonce"].to_s
|
|
25
|
+
if request.get? && nonce.match?(/\A[0-9a-f]{64}\z/)
|
|
26
|
+
[200, {"content-type" => "text/plain", "cache-control" => "no-store"}, [self.class.signature(@secret.call, nonce)]]
|
|
27
|
+
else
|
|
28
|
+
[400, {"content-type" => "text/plain", "cache-control" => "no-store"}, ["Invalid probe"]]
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cloudflare
|
|
4
|
+
module Tunnel
|
|
5
|
+
class Railtie < Rails::Railtie
|
|
6
|
+
initializer "cloudflare_tunnel.defaults", before: :load_config_initializers do |app|
|
|
7
|
+
Cloudflare::Tunnel.configure do |config|
|
|
8
|
+
config.root = app.root
|
|
9
|
+
config.token = -> { ENV["TUNNEL_TOKEN"] || app.credentials.dig(:cloudflare, :tunnel, :token) }
|
|
10
|
+
config.probe_secret = -> { app.key_generator.generate_key("cloudflare-tunnel/probe", 32) }
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
initializer "cloudflare_tunnel.probe", after: :load_config_initializers do |app|
|
|
15
|
+
if Rails.env.development?
|
|
16
|
+
app.middleware.use Probe, secret: Cloudflare::Tunnel.configuration.probe_secret
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
rake_tasks do
|
|
21
|
+
load File.expand_path("../tasks/tunnel.rake", __dir__)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "tempfile"
|
|
5
|
+
|
|
6
|
+
module Cloudflare
|
|
7
|
+
module Tunnel
|
|
8
|
+
class Runner
|
|
9
|
+
def initialize(configuration, output: $stdout)
|
|
10
|
+
@configuration = configuration
|
|
11
|
+
@output = output
|
|
12
|
+
@stopping = false
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def run
|
|
16
|
+
@reader, @writer = IO.pipe
|
|
17
|
+
previous = %w[INT TERM].to_h { |signal| [signal, Signal.trap(signal) { stop }] }
|
|
18
|
+
if (reason = unavailable)
|
|
19
|
+
@output.puts("[tunnel] #{reason}")
|
|
20
|
+
pause
|
|
21
|
+
else
|
|
22
|
+
FileUtils.mkdir_p(@configuration.path("tmp/pids"))
|
|
23
|
+
File.open(@configuration.path("tmp/pids/cloudflare-tunnel.lock"), File::RDWR | File::CREAT, 0o600) do |lock|
|
|
24
|
+
if lock.flock(File::LOCK_EX | File::LOCK_NB)
|
|
25
|
+
supervise
|
|
26
|
+
else
|
|
27
|
+
@output.puts("[tunnel] already running for this application; skipping")
|
|
28
|
+
pause
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
ensure
|
|
33
|
+
terminate
|
|
34
|
+
previous&.each { |signal, handler| Signal.trap(signal, handler) }
|
|
35
|
+
@reader&.close
|
|
36
|
+
@writer&.close
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def unavailable
|
|
42
|
+
if !@configuration.enabled
|
|
43
|
+
"disabled"
|
|
44
|
+
elsif !@configuration.executable_path
|
|
45
|
+
"cloudflared not found. Install it yourself: #{INSTALL_URL}"
|
|
46
|
+
elsif @configuration.errors.any?
|
|
47
|
+
"invalid configuration: #{@configuration.errors.join("; ")}"
|
|
48
|
+
elsif @configuration.token_value.empty?
|
|
49
|
+
"token missing; set cloudflare.tunnel.token in Rails credentials"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def supervise
|
|
54
|
+
log_path = @configuration.log_path && @configuration.path(@configuration.log_path)
|
|
55
|
+
FileUtils.mkdir_p(File.dirname(log_path)) if log_path
|
|
56
|
+
File.open(log_path || File::NULL, "a", 0o600) do |log|
|
|
57
|
+
Tempfile.create(["cloudflare-tunnel-", ".token"]) do |token|
|
|
58
|
+
token.write(@configuration.token_value)
|
|
59
|
+
token.flush
|
|
60
|
+
@output.puts("[tunnel] #{@configuration.hostname} -> #{@configuration.origin}; diagnostics: #{log_path || "disabled"}")
|
|
61
|
+
until @stopping
|
|
62
|
+
@pid = Process.spawn({"TUNNEL_TOKEN" => nil, "TUNNEL_TOKEN_FILE" => nil,
|
|
63
|
+
"TUNNEL_LOGFILE" => nil, "TUNNEL_LOGDIRECTORY" => nil},
|
|
64
|
+
@configuration.executable_path, "tunnel", "--no-autoupdate",
|
|
65
|
+
"--loglevel", @configuration.log_level.to_s,
|
|
66
|
+
"--metrics", "127.0.0.1:#{@configuration.metrics_port}",
|
|
67
|
+
"--grace-period", "2s", "run", "--token-file", token.path,
|
|
68
|
+
out: log, err: log)
|
|
69
|
+
@waiter = Process.detach(@pid)
|
|
70
|
+
pause(1) until @stopping || @waiter.join(0)
|
|
71
|
+
terminate
|
|
72
|
+
pause(@configuration.retry_interval) unless @stopping
|
|
73
|
+
end
|
|
74
|
+
ensure
|
|
75
|
+
terminate
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def stop
|
|
81
|
+
@stopping = true
|
|
82
|
+
@writer.write_nonblock(".", exception: false)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def pause(timeout = nil)
|
|
86
|
+
IO.select([@reader], nil, nil, timeout) unless @stopping
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def terminate
|
|
90
|
+
return unless @waiter
|
|
91
|
+
|
|
92
|
+
unless @waiter.join(0)
|
|
93
|
+
Process.kill("TERM", @pid)
|
|
94
|
+
unless @waiter.join(@configuration.shutdown_timeout)
|
|
95
|
+
Process.kill("KILL", @pid)
|
|
96
|
+
@waiter.join
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
rescue Errno::ESRCH
|
|
100
|
+
@waiter.join
|
|
101
|
+
ensure
|
|
102
|
+
@waiter = @pid = nil
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "tunnel/version"
|
|
4
|
+
require_relative "tunnel/configuration"
|
|
5
|
+
require_relative "tunnel/runner"
|
|
6
|
+
require_relative "tunnel/probe"
|
|
7
|
+
require_relative "tunnel/check"
|
|
8
|
+
|
|
9
|
+
module Cloudflare
|
|
10
|
+
module Tunnel
|
|
11
|
+
INSTALL_URL = "https://developers.cloudflare.com/tunnel/downloads/"
|
|
12
|
+
|
|
13
|
+
def self.configuration
|
|
14
|
+
@configuration ||= Configuration.new
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.configure
|
|
18
|
+
yield configuration
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
require_relative "tunnel/railtie" if defined?(Rails::Railtie)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
|
|
5
|
+
module Cloudflare
|
|
6
|
+
module Tunnel
|
|
7
|
+
module Generators
|
|
8
|
+
class InstallGenerator < Rails::Generators::Base
|
|
9
|
+
namespace "cloudflare_tunnel:install"
|
|
10
|
+
source_root File.expand_path("templates", __dir__)
|
|
11
|
+
desc "Configure a remotely-managed development tunnel; never installs cloudflared"
|
|
12
|
+
class_option :hostname, type: :string, required: true, desc: "Public development hostname"
|
|
13
|
+
class_option :port, type: :numeric, default: 3000, desc: "Local Rails port"
|
|
14
|
+
class_option :metrics_port, type: :numeric, default: 20241, desc: "Unique loopback connector metrics port"
|
|
15
|
+
|
|
16
|
+
def create_configuration
|
|
17
|
+
template "initializer.rb.tt", "config/initializers/cloudflare_tunnel.rb"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def configure_procfile
|
|
21
|
+
entry = "tunnel: bin/rails cloudflare_tunnel:start\n"
|
|
22
|
+
if File.exist?(File.join(destination_root, "Procfile.dev"))
|
|
23
|
+
content = File.read(File.join(destination_root, "Procfile.dev"))
|
|
24
|
+
if content.match?(/^tunnel:\s+bin\/tunnel\s*$/)
|
|
25
|
+
gsub_file "Procfile.dev", /^tunnel:\s+bin\/tunnel[^\n]*\n?/, entry
|
|
26
|
+
elsif content.match?(/^tunnel:/)
|
|
27
|
+
say "Existing tunnel entry retained; use: #{entry.strip}"
|
|
28
|
+
else
|
|
29
|
+
append_to_file "Procfile.dev", "\n#{entry}"
|
|
30
|
+
end
|
|
31
|
+
else
|
|
32
|
+
create_file "Procfile.dev", entry
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def instructions
|
|
37
|
+
say "Set cloudflare.tunnel.token with bin/rails credentials:edit."
|
|
38
|
+
say "In Cloudflare, route #{options[:hostname]} to http://localhost:#{options[:port]}."
|
|
39
|
+
say "Install cloudflared yourself if needed: #{Cloudflare::Tunnel::INSTALL_URL}"
|
|
40
|
+
say "Start bin/dev, then run bin/rails cloudflare_tunnel:check."
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
if Rails.env.development?
|
|
4
|
+
Cloudflare::Tunnel.configure do |config|
|
|
5
|
+
config.hostname = <%= options[:hostname].inspect %>
|
|
6
|
+
config.origin = "http://localhost:#{ENV.fetch("PORT", <%= options[:port].to_s.inspect %>)}"
|
|
7
|
+
config.metrics_port = <%= options[:metrics_port] %>
|
|
8
|
+
config.log_level = :error
|
|
9
|
+
config.log_path = "log/cloudflare-tunnel.log" # nil discards connector output
|
|
10
|
+
config.retry_interval = 30
|
|
11
|
+
# config.executable = "/path/to/cloudflared"
|
|
12
|
+
# config.enabled = false # TUNNEL=0 also disables the default configuration
|
|
13
|
+
# Token defaults to TUNNEL_TOKEN or credentials.dig(:cloudflare, :tunnel, :token).
|
|
14
|
+
end
|
|
15
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: cloudflare-tunnel
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Paulo Fidalgo
|
|
8
|
+
- Ethos Link
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: railties
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '8.1'
|
|
20
|
+
- - "<"
|
|
21
|
+
- !ruby/object:Gem::Version
|
|
22
|
+
version: '9'
|
|
23
|
+
type: :runtime
|
|
24
|
+
prerelease: false
|
|
25
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
26
|
+
requirements:
|
|
27
|
+
- - ">="
|
|
28
|
+
- !ruby/object:Gem::Version
|
|
29
|
+
version: '8.1'
|
|
30
|
+
- - "<"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '9'
|
|
33
|
+
- !ruby/object:Gem::Dependency
|
|
34
|
+
name: net-http
|
|
35
|
+
requirement: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '0.9'
|
|
40
|
+
type: :runtime
|
|
41
|
+
prerelease: false
|
|
42
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '0.9'
|
|
47
|
+
- !ruby/object:Gem::Dependency
|
|
48
|
+
name: openssl
|
|
49
|
+
requirement: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '3.3'
|
|
54
|
+
- - "<"
|
|
55
|
+
- !ruby/object:Gem::Version
|
|
56
|
+
version: '5'
|
|
57
|
+
type: :runtime
|
|
58
|
+
prerelease: false
|
|
59
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
60
|
+
requirements:
|
|
61
|
+
- - ">="
|
|
62
|
+
- !ruby/object:Gem::Version
|
|
63
|
+
version: '3.3'
|
|
64
|
+
- - "<"
|
|
65
|
+
- !ruby/object:Gem::Version
|
|
66
|
+
version: '5'
|
|
67
|
+
description: Supervise host-installed cloudflared with Rails configuration, quiet
|
|
68
|
+
retries, secure token handling, and end-to-end diagnostics.
|
|
69
|
+
email:
|
|
70
|
+
- devel@ethos-link.com
|
|
71
|
+
executables: []
|
|
72
|
+
extensions: []
|
|
73
|
+
extra_rdoc_files: []
|
|
74
|
+
files:
|
|
75
|
+
- CHANGELOG.md
|
|
76
|
+
- LICENSE.txt
|
|
77
|
+
- README.md
|
|
78
|
+
- lib/cloudflare-tunnel.rb
|
|
79
|
+
- lib/cloudflare/tasks/tunnel.rake
|
|
80
|
+
- lib/cloudflare/tunnel.rb
|
|
81
|
+
- lib/cloudflare/tunnel/check.rb
|
|
82
|
+
- lib/cloudflare/tunnel/configuration.rb
|
|
83
|
+
- lib/cloudflare/tunnel/probe.rb
|
|
84
|
+
- lib/cloudflare/tunnel/railtie.rb
|
|
85
|
+
- lib/cloudflare/tunnel/runner.rb
|
|
86
|
+
- lib/cloudflare/tunnel/version.rb
|
|
87
|
+
- lib/generators/cloudflare_tunnel/install_generator.rb
|
|
88
|
+
- lib/generators/cloudflare_tunnel/templates/initializer.rb.tt
|
|
89
|
+
homepage: https://github.com/ethos-link/cloudflare-tunnel
|
|
90
|
+
licenses:
|
|
91
|
+
- MIT
|
|
92
|
+
metadata:
|
|
93
|
+
allowed_push_host: https://rubygems.org
|
|
94
|
+
homepage_uri: https://github.com/ethos-link/cloudflare-tunnel
|
|
95
|
+
source_code_uri: https://github.com/ethos-link/cloudflare-tunnel
|
|
96
|
+
changelog_uri: https://github.com/ethos-link/cloudflare-tunnel/blob/main/CHANGELOG.md
|
|
97
|
+
bug_tracker_uri: https://github.com/ethos-link/cloudflare-tunnel/issues
|
|
98
|
+
documentation_uri: https://github.com/ethos-link/cloudflare-tunnel/blob/main/README.md
|
|
99
|
+
rubygems_mfa_required: 'true'
|
|
100
|
+
rdoc_options: []
|
|
101
|
+
require_paths:
|
|
102
|
+
- lib
|
|
103
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
104
|
+
requirements:
|
|
105
|
+
- - ">="
|
|
106
|
+
- !ruby/object:Gem::Version
|
|
107
|
+
version: 4.0.0
|
|
108
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
109
|
+
requirements:
|
|
110
|
+
- - ">="
|
|
111
|
+
- !ruby/object:Gem::Version
|
|
112
|
+
version: '0'
|
|
113
|
+
requirements: []
|
|
114
|
+
rubygems_version: 4.0.20
|
|
115
|
+
specification_version: 4
|
|
116
|
+
summary: Quiet, offline-tolerant Cloudflare tunnels for Rails development
|
|
117
|
+
test_files: []
|