Direct connections: Master implementation.
[clw.git] / clw
blobac36df354852dbf9f3520aa0f7c7245b7e526374
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 --[no-]once Client closes server after first connection.
66 --[no-]daemon (Do not) daemonize after startup. Daemon mode is
67 enabled by default, except for clients acting in
68 stdio mode.
69 --exec COMMAND Client runs COMMAND after opening server (if
70 --listen is active) or talks to it using a pipe (in
71 --stdio mode).
72 --pidfile FILE On startup, write process ID to FILE. If FILE
73 exists and creates a PID of a running process,
74 refuse to start.
75 --logfile FILE Write logs to FILE. Default is to disable logging if
76 daemon mode is enabled, standard error output
77 otherwise. The special value `-' logs to stderr,
78 `off' forces to disable logging.
79 --loglevel LEVEL Minimum level (info, warn, fatal) of log messages.
80 Default is to log everything (info).
81 --logname NAME Log as program NAME. Useful if multiple instances
82 of Clarkway log into the same file.
83 --help Show this help.
85 Please report bugs to Michael Schutte <m.schutte.jr@gmail.com>.
86 EOF
87 end
90 # Option parser.
92 class CommandLineParser
94 class Error < RuntimeError; end
96 attr_reader :args, :options
98 CLWHOME = ENV["HOME"] + "/.clw"
100 @@known_options = {
101 "mode" => :string,
102 "ca" => :string,
103 "cert" => :string,
104 "key" => :string,
105 "crl" => :string,
106 "subject" => :string,
107 "gateway" => :string,
108 "reconnect" => :integer,
109 "connect" => :string,
110 "stdio" => :boolean,
111 "listen" => :string,
112 "once" => :boolean,
113 "daemon" => :boolean,
114 "exec" => :string,
115 "pidfile" => :string,
116 "logfile" => :string,
117 "loglevel" => :string,
118 "logname" => :string,
119 "help" => :boolean,
123 # Create an option parser reading from +args+.
125 def initialize(args)
126 @args = args
127 @options = Hash.new
131 # Process all options.
133 def process
134 loop do
135 arg = @args.shift
136 break if arg.nil?
138 case arg
139 when "master", "gateway", "client"
140 @options["mode"] = arg
141 when /^--(no-?)?(\w+)$/
142 negate = (not $1.nil?)
143 option = $2
144 if @@known_options.include? option
145 type = @@known_options[option]
147 if negate and type != :boolean
148 raise Error, "Tried to assign boolean value to " +
149 "non-boolean option: #{arg}"
150 elsif type == :boolean
151 @options[option] = (not negate)
152 elsif type == :integer
153 value = @args.shift
154 if value.nil?
155 raise Error, "Option needs a value: #{arg}"
156 elsif value.empty?
157 @options[option] = nil
158 elsif value !~ /^\d+$/
159 raise Error, "Tried to assign string value to " +
160 "integer option: #{arg}"
161 else
162 @options[option] = value.to_i
164 elsif type == :string
165 value = @args.shift
166 if value.nil?
167 raise Error, "Option needs a value: #{arg}"
168 elsif value.empty?
169 @options[option] = nil
170 else
171 @options[option] = value
174 else
175 raise Error, "Unknown option: #{arg}"
177 when /[.\/]/
178 process_yaml arg
179 else
180 process_yaml CLWHOME + "/profiles/" + arg
184 # Post-process
185 @options.each_pair do |key, value|
186 if value.is_a? String
187 value = value.gsub(/\{(.*)\}/) {
188 case $1
189 when "open": "{"
190 when "close": "}"
191 when "~": ENV["HOME"] + "/.clw"
192 when "host", "port"
193 if @options["connect"].nil?
195 else
196 @options["connect"].split(":")[
197 ($1 == "host") ? 0 : 1
198 ].to_s
200 else @options[key].to_s
203 @options[key] = value.empty? ? nil : value
209 # Retrieve an option.
211 def [](option)
212 @options[option]
216 # Set an option manually.
218 def []=(option, value)
219 @options[option] = value
222 protected
225 # Process a YAML file to read options from.
227 def process_yaml(path)
228 begin
229 file = File.new path
230 rescue SystemCallError => e
231 raise Error, "While opening profile: #{e}"
234 begin
235 yaml = YAML::load file
236 rescue => e
237 raise Error, "#{path}: #{e}"
239 unless yaml.is_a? Hash
240 raise Error, "#{path}: Hash expected"
243 yaml.each_pair do |key, value|
244 if @@known_options.include? key
245 type = @@known_options[key]
247 if value.is_a? String and value.empty?
248 @options[key] = nil
249 elsif type == :boolean
250 unless value == true or value == false
251 raise Error, "#{path}: #{key} is a boolean option"
253 @options[key] = value
254 elsif type == :integer
255 unless value.is_a? Fixnum and value >= 0
256 raise Error, "#{path}: #{key} is a numeric option"
258 @options[key] = value
259 elsif type == :string
260 @options[key] = value.to_s
265 file.close
271 # Print an error message.
273 def error(message)
274 STDERR.puts "Error: #{message}"
275 STDERR.puts "Type `clw --help' for details."
279 # The main program.
281 def main
282 options = CommandLineParser.new ARGV
283 begin
284 options.process
285 rescue CommandLineParser::Error => e
286 error "#{e}."
287 return 1
290 if options["help"]
291 usage
292 return 0
295 mandatory = ["ca", "cert", "key", "subject"]
297 case mode = options["mode"]
298 when "master"
299 mandatory += ["gateway"]
300 when "gateway"
301 # No additional mandatory options
302 when "client"
303 mandatory += ["gateway", "connect"]
304 else
305 error "Please specify a valid mode (master, gateway or client)."
306 return 1
309 # Check options
310 mandatory.each do |option|
311 if options[option].nil?
312 error "--#{option} is mandatory for #{mode} operation."
313 return 1
317 # Prepare the daemon
318 subject = options["subject"]
319 case mode
320 when "master"
321 ghost, gport = options["gateway"].split(":")
322 if gport.nil?
323 gport = Clarkway::PORT
324 elsif gport !~ /^\d+$/
325 error "Gateway port has to be numeric."
326 return 1
327 else
328 gport = gport.to_i
331 reconnect = options["reconnect"] || 0
332 reconnect = 60 if reconnect == 0
334 require "clw/master"
335 daemon = Clarkway::Master.new(ghost, gport,
336 options["subject"], reconnect)
338 when "gateway"
339 listen = options["listen"]
340 if listen.nil?
341 lhost, lport = nil, Clarkway::PORT
342 else
343 lhost, lport = listen.split(":")
344 lhost, lport = nil, lhost if lport.nil?
345 if lport !~ /^\d+$/
346 error "Local port has to be numeric."
347 return 1
349 lport = lport.to_i
352 require "clw/gateway"
353 daemon = Clarkway::Gateway.new(lhost, lport, subject)
355 when "client"
356 options["daemon"] = false if options["daemon"].nil?
358 stdio = options["stdio"] || false
359 if stdio
360 lhost = lport = nil
361 else
362 listen = options["listen"] || "0"
363 lhost, lport = listen.split(":")
364 lhost, lport = nil, lhost if lport.nil?
365 if lport !~ /^\d+$/
366 error "Local port has to be numeric."
367 return 1
369 lport = lport.to_i
370 lport = nil if lport == 0
373 chost, cport = options["connect"].split(":")
374 if cport.nil? or cport !~ /^\d+$/
375 error "Target port has to be numeric."
376 return 1
379 ghost, gport = options["gateway"].split(":")
380 if gport.nil?
381 gport = Clarkway::PORT
382 elsif gport !~ /^\d+$/
383 error "Gateway port has to be numeric."
384 return 1
385 else
386 gport = gport.to_i
389 command = options["exec"]
390 unless command.nil?
391 options["once"] = true
392 options["daemon"] = false
395 require "clw/client"
396 daemon = Clarkway::Client.new(chost, cport, ghost, gport, subject)
397 daemon.once = options["once"] || false
398 daemon.stdio = stdio
399 daemon.exec = command
400 daemon.localhost = lhost unless lhost.nil?
401 daemon.localport = lport
402 options["daemon"] = false if stdio
405 # SSL initialization
406 cafile = daemon.ca = options["ca"]
407 begin
408 ca = OpenSSL::X509::Certificate.new File::read(cafile)
409 daemon.cert = OpenSSL::X509::Certificate.new File::read(options["cert"])
410 daemon.key = OpenSSL::PKey::RSA.new File::read(options["key"])
411 unless options["crl"].nil?
412 crl = OpenSSL::X509::CRL.new File::read(options["crl"])
413 raise "CRL not signed by CA" unless crl.verify ca.public_key
414 daemon.crl = crl
416 rescue => e
417 error "While initializing OpenSSL: #{e}."
418 return 1
421 # Logging
422 daemonize = options["daemon"]
423 daemonize = true if daemonize.nil?
424 logfile = options["logfile"]
425 loglevel = options["loglevel"]
426 logname = options["logname"] || "clw"
428 if logfile.nil?
429 logfile = daemonize ? nil : STDERR
430 elsif logfile == "-"
431 logfile = STDERR
432 elsif logfile == "off"
433 logfile = nil
436 begin
437 daemon.logger = Logger.new logfile
438 rescue SystemCallError => e
439 error "While opening logfile: #{e}."
440 return 1
443 daemon.logger.progname = logname
445 case loglevel
446 when "fatal"
447 daemon.logger.sev_threshold = Logger::FATAL
448 when "warn"
449 daemon.logger.sev_threshold = Logger::WARN
450 else
451 daemon.logger.sev_threshold = Logger::INFO
454 # Check PID file
455 pidfile = options["pidfile"]
456 unless pidfile.nil?
457 begin
458 oldpid = File::read(pidfile).to_i
459 begin
460 # Signal 0 does not kill, only check if process is alive:
461 Process.kill 0, oldpid
462 rescue
463 # PID file outdated
464 else
465 error "Already running (#{pidfile}, ##{oldpid})."
466 return 1
468 rescue
469 # No old PID file
472 daemon.on_stop do
473 begin
474 File.unlink pidfile
475 rescue
476 # PID file probably already deleted
481 # Start the daemon
482 unless pidfile.nil?
483 pid = Process.pid
484 pid = daemon.start true if daemonize
486 begin
487 file = File.new pidfile, "w"
488 file.puts pid
489 file.close
490 rescue => e
491 @logger.warn "Unable to write PID file: #{e}."
494 daemon.start false unless daemonize
495 else
496 daemon.start daemonize
499 # Exit successfully
503 exit main if $0 == __FILE__
505 # vim:tw=78:fmr=<<<,>>>:fdm=marker: