This is release 1.2.
[clw.git] / clw
blobdde6cb2d8e767759c03e07ce6db599c8d1132be0
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 --keepalive N Gateway: Send keep-alive lines to avoid mysteriously
57 closing connections every N seconds [0]; a value of
58 zero disables the feature.
59 --connect HOST:PORT Host and port to connect to; mandatory for client,
60 ignored otherwise.
61 --[no-]stdio Client uses standard input and output for the
62 communication with the target.
63 --listen [HOST:]PORT Gateway: Listen on host HOST [0.0.0.0], port PORT
64 [#{Clarkway::PORT}].
65 Client: Listen on HOST [127.0.0.1], port PORT
66 [chosen randomly].
67 Master: Ignored.
68 --direct HOST[:PORT] Client: Instead of redirecting traffic through the
69 gateway, listen on PORT and tell the master to
70 connect to HOST:PORT. HOST must be this host's
71 public IP address.
72 --direct-ports LIST Master: Only directly connect to ports found in
73 LIST. Useful when a firewall is filtering ports.
74 --[no-]once Client closes server after first connection.
75 --[no-]daemon (Do not) daemonize after startup. Daemon mode is
76 enabled by default, except for clients acting in
77 stdio mode.
78 --exec COMMAND Client runs COMMAND after opening server (if
79 --listen is active) or talks to it using a pipe (in
80 --stdio mode).
81 --pidfile FILE On startup, write process ID to FILE. If FILE
82 exists and creates a PID of a running process,
83 refuse to start.
84 --logfile FILE Write logs to FILE. Default is to disable logging if
85 daemon mode is enabled, standard error output
86 otherwise. The special value `-' logs to stderr,
87 `off' forces to disable logging.
88 --loglevel LEVEL Minimum level (info, warn, fatal) of log messages.
89 Default is to log everything (info).
90 --logname NAME Log as program NAME. Useful if multiple instances
91 of Clarkway log into the same file.
92 --help Show this help.
94 Please report bugs to Michael Schutte <m.schutte.jr@gmail.com>.
95 EOF
96 end
99 # Option parser.
101 class CommandLineParser
103 class Error < RuntimeError; end
105 attr_reader :args, :options
107 CLWHOME = ENV["HOME"] + "/.clw"
109 @@known_options = {
110 "mode" => :string,
111 "ca" => :string,
112 "cert" => :string,
113 "key" => :string,
114 "crl" => :string,
115 "subject" => :string,
116 "gateway" => :string,
117 "reconnect" => :integer,
118 "keepalive" => :integer,
119 "connect" => :string,
120 "stdio" => :boolean,
121 "listen" => :string,
122 "direct" => :string,
123 "direct-ports" => :string,
124 "once" => :boolean,
125 "daemon" => :boolean,
126 "exec" => :string,
127 "pidfile" => :string,
128 "logfile" => :string,
129 "loglevel" => :string,
130 "logname" => :string,
131 "help" => :boolean,
135 # Create an option parser reading from +args+.
137 def initialize(args)
138 @args = args
139 @options = Hash.new
143 # Process all options.
145 def process
146 loop do
147 arg = @args.shift
148 break if arg.nil?
150 case arg
151 when "master", "gateway", "client"
152 @options["mode"] = arg
153 when /^--(no-?)?([a-zA-Z-]+)$/
154 negate = (not $1.nil?)
155 option = $2
156 if @@known_options.include? option
157 type = @@known_options[option]
159 if negate and type != :boolean
160 raise Error, "Tried to assign boolean value to " +
161 "non-boolean option: #{arg}"
162 elsif type == :boolean
163 @options[option] = (not negate)
164 elsif type == :integer
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 elsif value !~ /^\d+$/
171 raise Error, "Tried to assign string value to " +
172 "integer option: #{arg}"
173 else
174 @options[option] = value.to_i
176 elsif type == :string
177 value = @args.shift
178 if value.nil?
179 raise Error, "Option needs a value: #{arg}"
180 elsif value.empty?
181 @options[option] = nil
182 else
183 @options[option] = value
186 else
187 raise Error, "Unknown option: #{arg}"
189 when /[.\/]/
190 process_yaml arg
191 else
192 process_yaml CLWHOME + "/profiles/" + arg
196 # Post-process
197 @options.each_pair do |key, value|
198 if value.is_a? String
199 value = value.gsub(/\{(.*)\}/) {
200 case $1
201 when "open": "{"
202 when "close": "}"
203 when "~": ENV["HOME"] + "/.clw"
204 when "host", "port"
205 if @options["connect"].nil?
207 else
208 @options["connect"].split(":")[
209 ($1 == "host") ? 0 : 1
210 ].to_s
212 else @options[key].to_s
215 @options[key] = value.empty? ? nil : value
221 # Retrieve an option.
223 def [](option)
224 @options[option]
228 # Set an option manually.
230 def []=(option, value)
231 @options[option] = value
234 protected
237 # Process a YAML file to read options from.
239 def process_yaml(path)
240 begin
241 file = File.new path
242 rescue SystemCallError => e
243 raise Error, "While opening profile: #{e}"
246 begin
247 yaml = YAML::load file
248 rescue => e
249 raise Error, "#{path}: #{e}"
251 unless yaml.is_a? Hash
252 raise Error, "#{path}: Hash expected"
255 yaml.each_pair do |key, value|
256 if @@known_options.include? key
257 type = @@known_options[key]
259 if value.is_a? String and value.empty?
260 @options[key] = nil
261 elsif type == :boolean
262 unless value == true or value == false
263 raise Error, "#{path}: #{key} is a boolean option"
265 @options[key] = value
266 elsif type == :integer
267 unless value.is_a? Fixnum and value >= 0
268 raise Error, "#{path}: #{key} is a numeric option"
270 @options[key] = value
271 elsif type == :string
272 @options[key] = value.to_s
277 file.close
283 # Print an error message.
285 def error(message)
286 STDERR.puts "Error: #{message}"
287 STDERR.puts "Type `clw --help' for details."
291 # The main program.
293 def main
294 options = CommandLineParser.new ARGV
295 begin
296 options.process
297 rescue CommandLineParser::Error => e
298 error "#{e}."
299 return 1
302 if options["help"]
303 usage
304 return 0
307 mandatory = ["ca", "cert", "key", "subject"]
309 case mode = options["mode"]
310 when "master"
311 mandatory += ["gateway"]
312 when "gateway"
313 # No additional mandatory options
314 when "client"
315 mandatory += ["gateway", "connect"]
316 else
317 error "Please specify a valid mode (master, gateway or client)."
318 return 1
321 # Check options
322 mandatory.each do |option|
323 if options[option].nil?
324 error "--#{option} is mandatory for #{mode} operation."
325 return 1
329 # Prepare the daemon
330 subject = options["subject"]
331 case mode
332 when "master"
333 ghost, gport = options["gateway"].split(":")
334 if gport.nil?
335 gport = Clarkway::PORT
336 elsif gport !~ /^\d+$/
337 error "Gateway port has to be numeric."
338 return 1
339 else
340 gport = gport.to_i
343 reconnect = options["reconnect"] || 0
344 reconnect = 60 if reconnect == 0
346 require "clw/master"
347 daemon = Clarkway::Master.new(ghost, gport,
348 options["subject"], reconnect)
350 direct_ports = options["direct-ports"]
351 if direct_ports.nil? or direct_ports.empty?
352 daemon.direct_ports = nil
353 elsif direct_ports == "-"
354 daemon.direct_ports = []
355 else
356 daemon.direct_ports = []
357 direct_ports.split(",").each do |port|
358 if port =~ /^(\d+)\.\.(\d+)$/
359 daemon.direct_ports << $1.to_i .. $2.to_i
360 elsif port =~ /^(\d+)$/
361 daemon.direct_ports << $1.to_i
362 else
363 error "Invalid direct port range #{port}."
364 return 1
369 when "gateway"
370 listen = options["listen"]
371 if listen.nil?
372 lhost, lport = nil, Clarkway::PORT
373 else
374 lhost, lport = listen.split(":")
375 lhost, lport = nil, lhost if lport.nil?
376 if lport !~ /^\d+$/
377 error "Local port has to be numeric."
378 return 1
380 lport = lport.to_i
383 keepalive = options["keepalive"] || 0
384 keepalive = nil if keepalive == 0
386 require "clw/gateway"
387 daemon = Clarkway::Gateway.new(lhost, lport, subject)
388 daemon.keepalive = keepalive
390 when "client"
391 options["daemon"] = false if options["daemon"].nil?
393 # Where to forward to?
394 stdio = options["stdio"] || false
395 if stdio
396 lhost = lport = nil
397 else
398 listen = options["listen"] || "0"
399 lhost, lport = listen.split(":")
400 lhost, lport = nil, lhost if lport.nil?
401 if lport !~ /^\d+$/
402 error "Local port has to be numeric."
403 return 1
405 lport = lport.to_i
406 lport = nil if lport == 0
409 # Where to connect to?
410 chost, cport = options["connect"].split(":")
411 if cport.nil? or cport !~ /^\d+$/
412 error "Target port has to be numeric."
413 return 1
416 # Gateway location
417 ghost, gport = options["gateway"].split(":")
418 if gport.nil?
419 gport = Clarkway::PORT
420 elsif gport !~ /^\d+$/
421 error "Gateway port has to be numeric."
422 return 1
423 else
424 gport = gport.to_i
427 # Command to execute afterwards
428 command = options["exec"]
429 unless command.nil?
430 options["once"] = true
431 options["daemon"] = false
434 # Direct connection?
435 direct = options["direct"]
436 dhost = dport = nil
437 unless direct.nil?
438 dhost, dport = direct.split ":"
439 unless dport.nil?
440 if dport !~ /^\d+$/
441 error "Direct connection port has to be numeric."
442 return 1
444 dport = dport.to_i
445 dport = nil if dport == 0
449 require "clw/client"
450 daemon = Clarkway::Client.new(chost, cport, ghost, gport, subject)
451 daemon.once = options["once"] || false
452 daemon.stdio = stdio
453 daemon.exec = command
454 daemon.local_host = lhost unless lhost.nil?
455 daemon.local_port = lport
456 daemon.direct_host = dhost
457 daemon.direct_port = dport
458 options["daemon"] = false if stdio
461 # SSL initialization
462 cafile = daemon.ca = options["ca"]
463 begin
464 ca = OpenSSL::X509::Certificate.new File::read(cafile)
465 daemon.cert = OpenSSL::X509::Certificate.new File::read(options["cert"])
466 daemon.key = OpenSSL::PKey::RSA.new File::read(options["key"])
467 unless options["crl"].nil?
468 crl = OpenSSL::X509::CRL.new File::read(options["crl"])
469 raise "CRL not signed by CA" unless crl.verify ca.public_key
470 daemon.crl = crl
472 rescue => e
473 error "While initializing OpenSSL: #{e}."
474 return 1
477 # Logging
478 daemonize = options["daemon"]
479 daemonize = true if daemonize.nil?
480 logfile = options["logfile"]
481 loglevel = options["loglevel"]
482 logname = options["logname"] || "clw"
484 if logfile.nil?
485 logfile = daemonize ? nil : STDERR
486 elsif logfile == "-"
487 logfile = STDERR
488 elsif logfile == "off"
489 logfile = nil
492 begin
493 daemon.logger = Logger.new logfile
494 rescue SystemCallError => e
495 error "While opening logfile: #{e}."
496 return 1
499 daemon.logger.progname = logname
501 case loglevel
502 when "fatal"
503 daemon.logger.sev_threshold = Logger::FATAL
504 when "warn"
505 daemon.logger.sev_threshold = Logger::WARN
506 else
507 daemon.logger.sev_threshold = Logger::INFO
510 # Check PID file
511 pidfile = options["pidfile"]
512 unless pidfile.nil?
513 begin
514 oldpid = File::read(pidfile).to_i
515 begin
516 # Signal 0 does not kill, only check if process is alive:
517 Process.kill 0, oldpid
518 rescue
519 # PID file outdated
520 else
521 error "Already running (#{pidfile}, ##{oldpid})."
522 return 1
524 rescue
525 # No old PID file
528 daemon.on_stop do
529 begin
530 File.unlink pidfile
531 rescue
532 # PID file probably already deleted
537 # Start the daemon
538 unless pidfile.nil?
539 pid = Process.pid
540 pid = daemon.start true if daemonize
542 begin
543 file = File.new pidfile, "w"
544 file.puts pid
545 file.close
546 rescue => e
547 @logger.warn "Unable to write PID file: #{e}."
550 daemon.start false unless daemonize
551 else
552 daemon.start daemonize
555 # Exit successfully
559 exit main if $0 == __FILE__