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).
- .start(backend_port:, funnel: false) ⇒ Object
- .status_json ⇒ Object
- .stop(handle) ⇒ Object
-
.used_serve_ports ⇒ 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.
77 78 79 80 81 82 83 84 85 86 87 |
# File 'lib/portless/share/tailscale.rb', line 77 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)
113 114 115 116 117 |
# File 'lib/portless/share/tailscale.rb', line 113 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
107 108 109 110 |
# File 'lib/portless/share/tailscale.rb', line 107 def dns_name(status) dns = status.dig("Self", "DNSName").to_s.chomp(".") dns.empty? ? nil : "https://#{dns}" end |
.format_url(base, port) ⇒ Object
119 120 121 122 |
# File 'lib/portless/share/tailscale.rb', line 119 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).
69 70 71 72 73 |
# File 'lib/portless/share/tailscale.rb', line 69 def off(mode, port) system("tailscale", mode, "--yes", "--https=#{port}", "off", out: File::NULL, err: File::NULL) rescue StandardError nil end |
.start(backend_port:, funnel: false) ⇒ Object
20 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 |
# File 'lib/portless/share/tailscale.rb', line 20 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
100 101 102 103 104 105 |
# File 'lib/portless/share/tailscale.rb', line 100 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
62 63 64 65 66 |
# File 'lib/portless/share/tailscale.rb', line 62 def stop(handle) return unless handle && Portless.which("tailscale") off(handle[:mode], handle[:port]) end |
.used_serve_ports ⇒ Object
HTTPS ports the user's current serve config already occupies.
90 91 92 93 94 95 96 97 98 |
# File 'lib/portless/share/tailscale.rb', line 90 def used_serve_ports config = JSON.parse(`tailscale serve status --json 2>/dev/null`) 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 |