Module: Portless::Daemon

Defined in:
lib/portless/daemon.rb

Overview

Starting/stopping the proxy daemon, including the privileged-port dance: for ports < 1024 we re-exec under sudo; the elevated process spawns the detached daemon that binds the socket as root. Falls back to :1355 when sudo is unavailable. Mirrors portless's handleProxy + ensureProxyRunning.

Class Method Summary collapse

Class Method Details

.cleanup_markersObject



204
205
206
# File 'lib/portless/daemon.rb', line 204

def cleanup_markers
  [ State.proxy_pid_file, State.proxy_port_file ].each { |f| File.delete(f) if File.exist?(f) }
end

.default_port(tls) ⇒ Object



198
# File 'lib/portless/daemon.rb', line 198

def default_port(tls) = tls ? Constants::HTTPS_PORT : Constants::HTTP_PORT

.discovered_pidObject

Fallback when the pid marker is missing: whoever listens on the port the proxy answered from (lsof only sees our own processes unless root).



137
138
139
140
141
# File 'lib/portless/daemon.rb', line 137

def discovered_pid
  port = Health.discover_port or return nil

  PortOwner.listeners(port).find { |pid| pid != Process.pid }
end

.ensure_running(tls:, lan: false) ⇒ Object



13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# File 'lib/portless/daemon.rb', line 13

def ensure_running(tls:, lan: false)
  port = Health.discover_port
  port = nil if tls && port == Constants::HTTP_PORT

  if port && lan && !lan_active?
    # The proxy binds loopback-only by default; --lan needs it reachable
    # from the network, so switch the running daemon over.
    warn "rb-portless: restarting the proxy in LAN mode (it was loopback-only) — only apps run " \
         "with --lan answer network clients; `rb-portless proxy restart --no-lan` undoes it"
    restart(tls: tls, port: port, lan: true)
    return Health.discover_port || port
  end
  if port && !recorded_tls.nil? && recorded_tls != tls
    # Another project started the daemon in the other TLS mode; don't fight
    # over it — that used to spawn a rival proxy on the default port.
    warn "rb-portless: the proxy is serving #{recorded_tls ? 'HTTPS' : 'HTTP'} but this project " \
         "sets tls: #{tls} — keeping the running proxy " \
         "(`rb-portless proxy restart #{tls ? '--tls' : '--no-tls'}` to switch)"
  end
  return refresh_stale(port, tls: tls) if port

  start(tls: tls, lan: lan)
  Health.discover_port
end

.lan_active?Boolean

Was the running proxy started in LAN mode? (Marker written by the daemon.)

Returns:

  • (Boolean)


39
# File 'lib/portless/daemon.rb', line 39

def lan_active? = File.exist?(State.proxy_lan_file)

.lib_dirObject



208
# File 'lib/portless/daemon.rb', line 208

def lib_dir = File.expand_path("..", __dir__)

.monotonicObject



209
# File 'lib/portless/daemon.rb', line 209

def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)

.read_pidObject



200
201
202
# File 'lib/portless/daemon.rb', line 200

def read_pid
  Integer(File.read(State.proxy_pid_file).strip, exception: false) if File.exist?(State.proxy_pid_file)
end

.recorded_tlsObject

The TLS mode the running daemon recorded, or nil (no marker / old daemon).



42
43
44
45
46
47
# File 'lib/portless/daemon.rb', line 42

def recorded_tls
  value = File.read(State.proxy_tls_file).strip
  value != "0"
rescue StandardError
  nil
end

.refresh_stale(port, tls:) ⇒ Object

The daemon outlives gem updates — after a bundle update the process on :443 may still be running last week's code. Compare the version it stamps on its responses with ours and offer to restart it; the reverse mismatch (a newer proxy) means this project's gem is the stale side.



53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
# File 'lib/portless/daemon.rb', line 53

def refresh_stale(port, tls:)
  running = Health.proxy_version(port)
  case version_action(running, VERSION)
  when :restart
    if restart_consented?(running)
      restart(tls: tls, port: port)
      port = Health.discover_port || port
    else
      warn "rb-portless: keeping the v#{running} proxy — run `rb-portless proxy restart` when ready"
    end
  when :update_gem
    warn "rb-portless: the proxy is v#{running} but this project loads rb-portless v#{VERSION}" \
         "update the gem (`bundle update rb-portless`) so they match"
  end
  port
end

.restart(tls: nil, port: nil, lan: nil) ⇒ Object



90
91
92
93
94
95
96
97
98
99
# File 'lib/portless/daemon.rb', line 90

def restart(tls: nil, port: nil, lan: nil)
  # A plain restart keeps the daemon's recorded modes; explicit flags win.
  tls = recorded_tls if tls.nil?
  tls = true if tls.nil?
  lan = lan_active? if lan.nil?
  port ||= Integer(ENV["PORTLESS_PORT"], exception: false) || default_port(tls)
  stop
  wait_until_stopped(port) if port
  start(tls: tls, port: port, lan: lan)
end

.restart_consented?(running) ⇒ Boolean

Returns:

  • (Boolean)


82
83
84
85
86
87
88
# File 'lib/portless/daemon.rb', line 82

def restart_consented?(running)
  warn "rb-portless: the running proxy is v#{running}; this rb-portless is v#{VERSION}"
  return false unless Privilege.interactive?

  $stderr.print "rb-portless: restart the proxy to pick up the update? [Y/n] "
  !$stdin.gets.to_s.strip.downcase.start_with?("n")
end

.spawn_detached(port:, tls:, lan: false) ⇒ Object



164
165
166
167
168
169
170
171
172
173
174
# File 'lib/portless/daemon.rb', line 164

def spawn_detached(port:, tls:, lan: false)
  State.ensure_dir!
  log = File.open(State.proxy_log, "a")
  args = [ RbConfig.ruby, "-I", lib_dir, Privilege.program,
           "proxy", "start", "--foreground", "--port", port.to_s,
           tls ? "--tls" : "--no-tls", *(lan ? [ "--lan" ] : []) ]
  pid = Process.spawn(*args, out: log, err: log, pgroup: true)
  Process.detach(pid)
  log.close
  wait_until_running(port)
end

.start(tls:, port: nil, foreground: false, lan: false) ⇒ Object

foreground: become the daemon (binds the port, blocks). Otherwise orchestrate: elevate if needed, then spawn the detached foreground daemon.



103
104
105
106
107
108
109
110
111
112
113
114
# File 'lib/portless/daemon.rb', line 103

def start(tls:, port: nil, foreground: false, lan: false)
  port ||= Integer(ENV["PORTLESS_PORT"], exception: false) || default_port(tls)

  return Proxy.new(port: port, tls: tls, lan: lan).run if foreground
  return if Health.proxy_running?(port)

  if Privilege.needs_sudo?(port) && !Privilege.root?
    start_privileged(port: port, tls: tls, lan: lan)
  else
    spawn_detached(port: port, tls: tls, lan: lan)
  end
end

.start_privileged(port:, tls:, lan: false) ⇒ Object



143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
# File 'lib/portless/daemon.rb', line 143

def start_privileged(port:, tls:, lan: false)
  unless Privilege.interactive?
    # Don't silently fall back to :1355 here — that changes every URL
    # (OAuth redirect URIs, bookmarks) with nobody watching. Fail with the
    # ways out instead, like portless does in CI.
    raise NonInteractiveError,
          "binding :#{port} needs sudo and there's no terminal to ask — pre-start the proxy " \
          "(`rb-portless proxy start`), install the boot service (`rb-portless service install`), " \
          "or set PORTLESS_PORT to an unprivileged port"
  end

  warn "rb-portless: binding :#{port} needs sudo (it's a privileged port) — " \
       "enter your password to serve #{tls ? 'HTTPS' : 'HTTP'} without a port number"
  ok = Privilege.reexec_with_sudo([ "proxy", "start", "--port", port.to_s,
                                    tls ? "--tls" : "--no-tls", *(lan ? [ "--lan" ] : []) ])
  return wait_until_running(port) if ok

  warn "rb-portless: sudo declined — using :#{Constants::FALLBACK_PROXY_PORT}"
  spawn_detached(port: Constants::FALLBACK_PROXY_PORT, tls: tls, lan: lan)
end

.stopObject



116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
# File 'lib/portless/daemon.rb', line 116

def stop
  pid = read_pid || discovered_pid
  unless pid
    # A proxy answers but we can't see who owns the port (a root daemon
    # whose marker files were lost) — retry the whole stop under sudo.
    return Privilege.reexec_with_sudo([ "proxy", "stop" ]) if Health.discover_port && !Privilege.root?

    warn "rb-portless: no proxy is running"
    return
  end

  Process.kill("TERM", pid)
rescue Errno::ESRCH
  cleanup_markers
rescue Errno::EPERM
  # Proxy owned by root (privileged bind) — stop it with sudo.
  Privilege.reexec_with_sudo([ "proxy", "stop" ]) unless Privilege.root?
end

.version_action(running, current) ⇒ Object

nil → proxy unreadable or versions equal: leave it alone.



71
72
73
74
75
76
77
78
79
80
# File 'lib/portless/daemon.rb', line 71

def version_action(running, current)
  return nil unless running

  case Gem::Version.new(running) <=> Gem::Version.new(current)
  when -1 then :restart
  when 1 then :update_gem
  end
rescue ArgumentError
  nil
end

.wait_until_running(port, timeout: 10) ⇒ Object



176
177
178
179
180
181
182
183
184
185
# File 'lib/portless/daemon.rb', line 176

def wait_until_running(port, timeout: 10)
  deadline = monotonic + timeout
  until Health.proxy_running?(port)
    return false if monotonic > deadline

    sleep 0.2
  end
  State.fix_ownership
  true
end

.wait_until_stopped(port, timeout: 10) ⇒ Object

A restart can't rebind the port until the old daemon has let go of it.



188
189
190
191
192
193
194
195
196
# File 'lib/portless/daemon.rb', line 188

def wait_until_stopped(port, timeout: 10)
  deadline = monotonic + timeout
  while Health.proxy_running?(port)
    return false if monotonic > deadline

    sleep 0.2
  end
  true
end