Chdir to / when acting as daemon.
[clw.git] / clw
blob2841a39266ee8fd2b1cae2d1697f854f8d08b982
1 #!/usr/bin/env ruby
2 # Clarkway tunnelling service
3 # Copyright (C) 2007 Michael Schutte
5 # This program is free software; you can redistribute it and/or modify it
6 # under the terms of the GNU General Public License as published by the Free
7 # Software Foundation; either version 2 of the License, or (at your option)
8 # any later version.
10 # This program is distributed in the hope that it will be useful, but WITHOUT
11 # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
12 # FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
13 # more details.
15 # Modules
16 $: << "lib"
17 require "clw/daemon"
19 # External modules
20 require "logger"
21 require "openssl"
22 require "yaml"
25 # Print command line usage information.
27 def usage
28 puts <<EOF
29 Usage: clw MODE|OPTION ...
30 This is #{Clarkway::ID}, an SSL tunnelling application.
32 Modes:
33 master Connect to a gateway and obey to its connection
34 requests.
35 gateway Accept connections from clients and the master and
36 moderate between them.
37 client Connect to a gateway and get forwarded to the
38 Clarkway master.
39 PROFILE User defined profiles are stored as YAML files in
40 ~/.clw/profiles. See clw(1) for details.
41 FILE Loads user defined profile from FILE. This only
42 works if the file name contains dots or slashes; if
43 it does not, use something like ./FILE.
45 Options:
46 --ca FILE Use FILE as certificate authority; mandatory.
47 --cert FILE Use FILE as certificate; mandatory.
48 --key FILE Use FILE as private key; mandatory.
49 --crl FILE Use FILE as certificate revocation list; optional.
50 --subject SUBJECT Expect peer to identify with the given certificate
51 subject; mandatory.
52 --gateway HOST[:PORT] Host and port to connect to; mandatory for master
53 and client, ignored for gateway operation.
54 --reconnect N Master: Try to reconnect to the gateway after N
55 seconds [60].
56 --connect HOST:PORT Host and port to connect to; mandatory for client,
57 ignored otherwise.
58 --[no-]stdio Client uses standard input and output for the
59 communication with the target.
60 --listen [HOST:]PORT Gateway: Listen on host HOST [0.0.0.0], port PORT
61 [#{Clarkway::PORT}].
62 Client: Listen on HOST [127.0.0.1], port PORT
63 [chosen randomly].
64 Master: Ignored.
65 --direct HOST[:PORT] Client: Instead of redirecting traffic through the
66 gateway, listen on PORT and tell the master to
67 connect to HOST:PORT. HOST must be this host's
68 public IP address.
69 --[no-]once Client closes server after first connection.
70 --[no-]daemon (Do not) daemonize after startup. Daemon mode is
71 enabled by default, except for clients acting in
72 stdio mode.
73 --exec COMMAND Client runs COMMAND after opening server (if
74 --listen is active) or talks to it using a pipe (in
75 --stdio mode).
76 --pidfile FILE On startup, write process ID to FILE. If FILE
77 exists and creates a PID of a running process,
78 refuse to start.
79 --logfile FILE Write logs to FILE. Default is to disable logging if
80 daemon mode is enabled, standard error output
81 otherwise. The special value `-' logs to stderr,
82 `off' forces to disable logging.
83 --loglevel LEVEL Minimum level (info, warn, fatal) of log messages.
84 Default is to log everything (info).
85 --logname NAME Log as program NAME. Useful if multiple instances
86 of Clarkway log into the same file.
87 --help Show this help.
89 Please report bugs to Michael Schutte <m.schutte.jr@gmail.com>.
90 EOF
91 end
94 # Option parser.
96 class CommandLineParser
98 class Error < RuntimeError; end
100 attr_reader :args, :options
102 CLWHOME = ENV["HOME"] + "/.clw"
104 @@known_options = {
105 "mode" => :string,
106 "ca" => :string,
107 "cert" => :string,
108 "key" => :string,
109 "crl" => :string,
110 "subject" => :string,
111 "gateway" => :string,
112 "reconnect" => :integer,
113 "connect" => :string,
114 "stdio" => :boolean,
115 "listen" => :string,
116 "direct" => :string,
117 "once" => :boolean,
118 "daemon" => :boolean,
119 "exec" => :string,
120 "pidfile" => :string,
121 "logfile" => :string,
122 "loglevel" => :string,
123 "logname" => :string,
124 "help" => :boolean,
128 # Create an option parser reading from +args+.
130 def initialize(args)
131 @args = args
132 @options = Hash.new
136 # Process all options.
138 def process
139 loop do
140 arg = @args.shift
141 break if arg.nil?
143 case arg
144 when "master", "gateway", "client"
145 @options["mode"] = arg
146 when /^--(no-?)?(\w+)$/
147 negate = (not $1.nil?)
148 option = $2
149 if @@known_options.include? option
150 type = @@known_options[option]
152 if negate and type != :boolean
153 raise Error, "Tried to assign boolean value to " +
154 "non-boolean option: #{arg}"
155 elsif type == :boolean
156 @options[option] = (not negate)
157 elsif type == :integer
158 value = @args.shift
159 if value.nil?
160 raise Error, "Option needs a value: #{arg}"
161 elsif value.empty?
162 @options[option] = nil
163 elsif value !~ /^\d+$/
164 raise Error, "Tried to assign string value to " +
165 "integer option: #{arg}"
166 else
167 @options[option] = value.to_i
169 elsif type == :string
170 value = @args.shift
171 if value.nil?
172 raise Error, "Option needs a value: #{arg}"
173 elsif value.empty?
174 @options[option] = nil
175 else
176 @options[option] = value
179 else
180 raise Error, "Unknown option: #{arg}"
182 when /[.\/]/
183 process_yaml arg
184 else
185 process_yaml CLWHOME + "/profiles/" + arg
189 # Post-process
190 @options.each_pair do |key, value|
191 if value.is_a? String
192 value = value.gsub(/\{(.*)\}/) {
193 case $1
194 when "open": "{"
195 when "close": "}"
196 when "~": ENV["HOME"] + "/.clw"
197 when "host", "port"
198 if @options["connect"].nil?
200 else
201 @options["connect"].split(":")[
202 ($1 == "host") ? 0 : 1
203 ].to_s
205 else @options[key].to_s
208 @options[key] = value.empty? ? nil : value
214 # Retrieve an option.
216 def [](option)
217 @options[option]
221 # Set an option manually.
223 def []=(option, value)
224 @options[option] = value
227 protected
230 # Process a YAML file to read options from.
232 def process_yaml(path)
233 begin
234 file = File.new path
235 rescue SystemCallError => e
236 raise Error, "While opening profile: #{e}"
239 begin
240 yaml = YAML::load file
241 rescue => e
242 raise Error, "#{path}: #{e}"
244 unless yaml.is_a? Hash
245 raise Error, "#{path}: Hash expected"
248 yaml.each_pair do |key, value|
249 if @@known_options.include? key
250 type = @@known_options[key]
252 if value.is_a? String and value.empty?
253 @options[key] = nil
254 elsif type == :boolean
255 unless value == true or value == false
256 raise Error, "#{path}: #{key} is a boolean option"
258 @options[key] = value
259 elsif type == :integer
260 unless value.is_a? Fixnum and value >= 0
261 raise Error, "#{path}: #{key} is a numeric option"
263 @options[key] = value
264 elsif type == :string
265 @options[key] = value.to_s
270 file.close
276 # Print an error message.
278 def error(message)
279 STDERR.puts "Error: #{message}"
280 STDERR.puts "Type `clw --help' for details."
284 # The main program.
286 def main
287 options = CommandLineParser.new ARGV
288 begin
289 options.process
290 rescue CommandLineParser::Error => e
291 error "#{e}."
292 return 1
295 if options["help"]
296 usage
297 return 0
300 mandatory = ["ca", "cert", "key", "subject"]
302 case mode = options["mode"]
303 when "master"
304 mandatory += ["gateway"]
305 when "gateway"
306 # No additional mandatory options
307 when "client"
308 mandatory += ["gateway", "connect"]
309 else
310 error "Please specify a valid mode (master, gateway or client)."
311 return 1
314 # Check options
315 mandatory.each do |option|
316 if options[option].nil?
317 error "--#{option} is mandatory for #{mode} operation."
318 return 1
322 # Prepare the daemon
323 subject = options["subject"]
324 case mode
325 when "master"
326 ghost, gport = options["gateway"].split(":")
327 if gport.nil?
328 gport = Clarkway::PORT
329 elsif gport !~ /^\d+$/
330 error "Gateway port has to be numeric."
331 return 1
332 else
333 gport = gport.to_i
336 reconnect = options["reconnect"] || 0
337 reconnect = 60 if reconnect == 0
339 require "clw/master"
340 daemon = Clarkway::Master.new(ghost, gport,
341 options["subject"], reconnect)
343 when "gateway"
344 listen = options["listen"]
345 if listen.nil?
346 lhost, lport = nil, Clarkway::PORT
347 else
348 lhost, lport = listen.split(":")
349 lhost, lport = nil, lhost if lport.nil?
350 if lport !~ /^\d+$/
351 error "Local port has to be numeric."
352 return 1
354 lport = lport.to_i
357 require "clw/gateway"
358 daemon = Clarkway::Gateway.new(lhost, lport, subject)
360 when "client"
361 options["daemon"] = false if options["daemon"].nil?
363 # Where to forward to?
364 stdio = options["stdio"] || false
365 if stdio
366 lhost = lport = nil
367 else
368 listen = options["listen"] || "0"
369 lhost, lport = listen.split(":")
370 lhost, lport = nil, lhost if lport.nil?
371 if lport !~ /^\d+$/
372 error "Local port has to be numeric."
373 return 1
375 lport = lport.to_i
376 lport = nil if lport == 0
379 # Where to connect to?
380 chost, cport = options["connect"].split(":")
381 if cport.nil? or cport !~ /^\d+$/
382 error "Target port has to be numeric."
383 return 1
386 # Gateway location
387 ghost, gport = options["gateway"].split(":")
388 if gport.nil?
389 gport = Clarkway::PORT
390 elsif gport !~ /^\d+$/
391 error "Gateway port has to be numeric."
392 return 1
393 else
394 gport = gport.to_i
397 # Command to execute afterwards
398 command = options["exec"]
399 unless command.nil?
400 options["once"] = true
401 options["daemon"] = false
404 # Direct connection?
405 direct = options["direct"]
406 dhost = dport = nil
407 unless direct.nil?
408 dhost, dport = direct.split ":"
409 unless dport.nil?
410 if dport !~ /^\d+$/
411 error "Direct connection port has to be numeric."
412 return 1
414 dport = dport.to_i
415 dport = nil if dport == 0
419 require "clw/client"
420 daemon = Clarkway::Client.new(chost, cport, ghost, gport, subject)
421 daemon.once = options["once"] || false
422 daemon.stdio = stdio
423 daemon.exec = command
424 daemon.local_host = lhost unless lhost.nil?
425 daemon.local_port = lport
426 daemon.direct_host = dhost
427 daemon.direct_port = dport
428 options["daemon"] = false if stdio
431 # SSL initialization
432 cafile = daemon.ca = options["ca"]
433 begin
434 ca = OpenSSL::X509::Certificate.new File::read(cafile)
435 daemon.cert = OpenSSL::X509::Certificate.new File::read(options["cert"])
436 daemon.key = OpenSSL::PKey::RSA.new File::read(options["key"])
437 unless options["crl"].nil?
438 crl = OpenSSL::X509::CRL.new File::read(options["crl"])
439 raise "CRL not signed by CA" unless crl.verify ca.public_key
440 daemon.crl = crl
442 rescue => e
443 error "While initializing OpenSSL: #{e}."
444 return 1
447 # Logging
448 daemonize = options["daemon"]
449 daemonize = true if daemonize.nil?
450 logfile = options["logfile"]
451 loglevel = options["loglevel"]
452 logname = options["logname"] || "clw"
454 if logfile.nil?
455 logfile = daemonize ? nil : STDERR
456 elsif logfile == "-"
457 logfile = STDERR
458 elsif logfile == "off"
459 logfile = nil
462 begin
463 daemon.logger = Logger.new logfile
464 rescue SystemCallError => e
465 error "While opening logfile: #{e}."
466 return 1
469 daemon.logger.progname = logname
471 case loglevel
472 when "fatal"
473 daemon.logger.sev_threshold = Logger::FATAL
474 when "warn"
475 daemon.logger.sev_threshold = Logger::WARN
476 else
477 daemon.logger.sev_threshold = Logger::INFO
480 # Check PID file
481 pidfile = options["pidfile"]
482 unless pidfile.nil?
483 begin
484 oldpid = File::read(pidfile).to_i
485 begin
486 # Signal 0 does not kill, only check if process is alive:
487 Process.kill 0, oldpid
488 rescue
489 # PID file outdated
490 else
491 error "Already running (#{pidfile}, ##{oldpid})."
492 return 1
494 rescue
495 # No old PID file
498 daemon.on_stop do
499 begin
500 File.unlink pidfile
501 rescue
502 # PID file probably already deleted
507 # Start the daemon
508 unless pidfile.nil?
509 pid = Process.pid
510 pid = daemon.start true if daemonize
512 begin
513 file = File.new pidfile, "w"
514 file.puts pid
515 file.close
516 rescue => e
517 @logger.warn "Unable to write PID file: #{e}."
520 daemon.start false unless daemonize
521 else
522 daemon.start daemonize
525 # Exit successfully
529 exit main if $0 == __FILE__
531 # vim:tw=78:fmr=<<<,>>>:fdm=marker: