Module: Portless::Share::Tailscale
- Defined in:
- lib/portless/share/tailscale.rb
Overview
Expose the local app on your tailnet (serve) or publicly (funnel) via
the tailscale CLI. Returns { mode:, port:, url: } or nil. EXPERIMENTAL.
SAFETY (mirrors portless's tailscale.ts): we never clobber your existing
serve config — we read tailscale serve status for ports already in use
and pick the first FREE one from the preferred list, register with --yes
(no prompt), and on teardown turn off ONLY the port we registered.
Constant Summary collapse
- PREFERRED_SERVE_PORTS =
[ 443, 8443, 8444, 8445, 8446, 8447, 8448, 8449, 8450 ].freeze
- FUNNEL_PORTS =
Funnel supports only these
[ 443, 8443, 10_000 ].freeze
Class Method Summary collapse
-
.available_port(funnel:) ⇒ Object
First free HTTPS port from the preferred pool, never one already in use by the user's existing serve/funnel config.
-
.capability?(status, name) ⇒ Boolean
Does this node have the HTTPS / Funnel capability? (mirrors portless).
- .dns_name(status) ⇒ Object
- .format_url(base, port) ⇒ Object
-
.off(mode, port) ⇒ Object
Turn off ONLY the registration we created (scoped to our port).
- .serve_status_json ⇒ Object
- .start(backend_port:, funnel: false) ⇒ Object
- .status_json ⇒ Object
- .stop(handle) ⇒ Object
-
.stop_url(url) ⇒ Object
Teardown from a recorded share URL alone (clean/prune reaping a dead run's registration).
-
.used_serve_ports(config = serve_status_json) ⇒ Object
HTTPS ports the user's current serve config already occupies.
Class Method Details
.available_port(funnel:) ⇒ Object
First free HTTPS port from the preferred pool, never one already in use by the user's existing serve/funnel config. nil if the funnel pool is full.
95 96 97 98 99 100 101 102 103 104 105 |
# File 'lib/portless/share/tailscale.rb', line 95 def available_port(funnel:) used = used_serve_ports pool = funnel ? FUNNEL_PORTS : PREFERRED_SERVE_PORTS free = pool.find { |port| !used.include?(port) } return free if free return nil if funnel port = PREFERRED_SERVE_PORTS.last + 1 port += 1 while used.include?(port) port end |
.capability?(status, name) ⇒ Boolean
Does this node have the HTTPS / Funnel capability? (mirrors portless)
136 137 138 139 140 |
# File 'lib/portless/share/tailscale.rb', line 136 def capability?(status, name) node = status["Self"] || {} names = Array(node["Capabilities"]) + (node["CapMap"] || {}).keys names.any? { |cap| down = cap.to_s.downcase; down == name || down.end_with?("/#{name}") } end |
.dns_name(status) ⇒ Object
130 131 132 133 |
# File 'lib/portless/share/tailscale.rb', line 130 def dns_name(status) dns = status.dig("Self", "DNSName").to_s.chomp(".") dns.empty? ? nil : "https://#{dns}" end |
.format_url(base, port) ⇒ Object
142 143 144 145 |
# File 'lib/portless/share/tailscale.rb', line 142 def format_url(base, port) base = base.chomp("/") port == 443 ? base : "#{base}:#{port}" end |
.off(mode, port) ⇒ Object
Turn off ONLY the registration we created (scoped to our port).
87 88 89 90 91 |
# File 'lib/portless/share/tailscale.rb', line 87 def off(mode, port) system("tailscale", mode, "--yes", "--https=#{port}", "off", out: File::NULL, err: File::NULL) rescue StandardError nil end |
.serve_status_json ⇒ Object
117 118 119 120 121 |
# File 'lib/portless/share/tailscale.rb', line 117 def serve_status_json JSON.parse(`tailscale serve status --json 2>/dev/null`) rescue StandardError {} end |
.start(backend_port:, funnel: false) ⇒ Object
21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 |
# File 'lib/portless/share/tailscale.rb', line 21 def start(backend_port:, funnel: false) unless Portless.which("tailscale") warn "rb-portless: tailscale not found — install it (https://tailscale.com/download) to use --tailscale" return nil end status = status_json unless status warn "rb-portless: tailscale isn't connected — run `tailscale up`, then retry" return nil end unless capability?(status, "https") warn "rb-portless: tailscale HTTPS certs aren't enabled — turn on HTTPS in your tailnet DNS settings" return nil end if funnel && !capability?(status, "funnel") warn "rb-portless: tailscale Funnel isn't enabled for this node — enable it, then retry --funnel" return nil end mode = funnel ? "funnel" : "serve" port = available_port(funnel: funnel) unless port warn "rb-portless: all tailscale Funnel ports are in use (443/8443/10000)" return nil end unless system("tailscale", mode, "--bg", "--yes", "--https=#{port}", "http://127.0.0.1:#{backend_port}", out: File::NULL, err: File::NULL) warn "rb-portless: `tailscale #{mode}` failed to register" return nil end base = dns_name(status) return (off(mode, port) and nil) unless base { mode: mode, port: port, url: format_url(base, port) } rescue StandardError => e warn "rb-portless: tailscale sharing failed (#{e.})" nil end |
.status_json ⇒ Object
123 124 125 126 127 128 |
# File 'lib/portless/share/tailscale.rb', line 123 def status_json json = JSON.parse(`tailscale status --json 2>/dev/null`) json.is_a?(Hash) ? json : nil rescue StandardError nil end |
.stop(handle) ⇒ Object
63 64 65 66 67 |
# File 'lib/portless/share/tailscale.rb', line 63 def stop(handle) return unless handle && Portless.which("tailscale") off(handle[:mode], handle[:port]) end |
.stop_url(url) ⇒ Object
Teardown from a recorded share URL alone (clean/prune reaping a dead run's registration). The URL carries the HTTPS port (absent = 443); the mode isn't recorded, so turn off both — "not found" is a no-op.
72 73 74 75 76 77 78 79 80 81 82 83 84 |
# File 'lib/portless/share/tailscale.rb', line 72 def stop_url(url) return unless url && Portless.which("tailscale") port = begin URI(url).port || 443 rescue StandardError nil end return unless port off("serve", port) off("funnel", port) end |
.used_serve_ports(config = serve_status_json) ⇒ Object
HTTPS ports the user's current serve config already occupies.
108 109 110 111 112 113 114 115 |
# File 'lib/portless/share/tailscale.rb', line 108 def used_serve_ports(config = serve_status_json) ports = [] (config["Web"] || {}).each_key { |host_port| ports << Regexp.last_match(1).to_i if host_port =~ /:(\d+)\z/ } (config["TCP"] || {}).each_key { |port| ports << port.to_i } ports rescue StandardError [] end |