Implemented keep-alive packets.
[clw.git] / clw
blobe77d48318c9ba73b3a80bc20c89b643f7969c310
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 --[no-]once Client closes server after first connection.
73 --[no-]daemon (Do not) daemonize after startup. Daemon mode is
74 enabled by default, except for clients acting in
75 stdio mode.
76 --exec COMMAND Client runs COMMAND after opening server (if
77 --listen is active) or talks to it using a pipe (in
78 --stdio mode).
79 --pidfile FILE On startup, write process ID to FILE. If FILE
80 exists and creates a PID of a running process,
81 refuse to start.
82 --logfile FILE Write logs to FILE. Default is to disable logging if
83 daemon mode is enabled, standard error output
84 otherwise. The special value `-' logs to stderr,
85 `off' forces to disable logging.
86 --loglevel LEVEL Minimum level (info, warn, fatal) of log messages.
87 Default is to log everything (info).
88 --logname NAME Log as program NAME. Useful if multiple instances
89 of Clarkway log into the same file.
90 --help Show this help.
92 Please report bugs to Michael Schutte <m.schutte.jr@gmail.com>.
93 EOF
94 end
97 # Option parser.
99 class CommandLineParser
101 class Error < RuntimeError; end
103 attr_reader :args, :options
105 CLWHOME = ENV["HOME"] + "/.clw"
107 @@known_options = {
108 "mode" => :string,
109 "ca" => :string,
110 "cert" => :string,
111 "key" => :string,
112 "crl" => :string,
113 "subject" => :string,
114 "gateway" => :string,
115 "reconnect" => :integer,
116 "keepalive" => :integer,
117 "connect" => :string,
118 "stdio" => :boolean,
119 "listen" => :string,
120 "direct" => :string,
121 "once" => :boolean,
122 "daemon" => :boolean,
123 "exec" => :string,
124 "pidfile" => :string,
125 "logfile" => :string,
126 "loglevel" => :string,
127 "logname" => :string,
128 "help" => :boolean,
132 # Create an option parser reading from +args+.
134 def initialize(args)
135 @args = args
136 @options = Hash.new
140 # Process all options.
142 def process
143 loop do
144 arg = @args.shift
145 break if arg.nil?
147 case arg
148 when "master", "gateway", "client"
149 @options["mode"] = arg
150 when /^--(no-?)?(\w+)$/
151 negate = (not $1.nil?)
152 option = $2
153 if @@known_options.include? option
154 type = @@known_options[option]
156 if negate and type != :boolean
157 raise Error, "Tried to assign boolean value to " +
158 "non-boolean option: #{arg}"
159 elsif type == :boolean
160 @options[option] = (not negate)
161 elsif type == :integer
162 value = @args.shift
163 if value.nil?
164 raise Error, "Option needs a value: #{arg}"
165 elsif value.empty?
166 @options[option] = nil
167 elsif value !~ /^\d+$/
168 raise Error, "Tried to assign string value to " +
169 "integer option: #{arg}"
170 else
171 @options[option] = value.to_i
173 elsif type == :string
174 value = @args.shift
175 if value.nil?
176 raise Error, "Option needs a value: #{arg}"
177 elsif value.empty?
178 @options[option] = nil
179 else
180 @options[option] = value
183 else
184 raise Error, "Unknown option: #{arg}"
186 when /[.\/]/
187 process_yaml arg
188 else
189 process_yaml CLWHOME + "/profiles/" + arg
193 # Post-process
194 @options.each_pair do |key, value|
195 if value.is_a? String
196 value = value.gsub(/\{(.*)\}/) {
197 case $1
198 when "open": "{"
199 when "close": "}"
200 when "~": ENV["HOME"] + "/.clw"
201 when "host", "port"
202 if @options["connect"].nil?
204 else
205 @options["connect"].split(":")[
206 ($1 == "host") ? 0 : 1
207 ].to_s
209 else @options[key].to_s
212 @options[key] = value.empty? ? nil : value
218 # Retrieve an option.
220 def [](option)
221 @options[option]
225 # Set an option manually.
227 def []=(option, value)
228 @options[option] = value
231 protected
234 # Process a YAML file to read options from.
236 def process_yaml(path)
237 begin
238 file = File.new path
239 rescue SystemCallError => e
240 raise Error, "While opening profile: #{e}"
243 begin
244 yaml = YAML::load file
245 rescue => e
246 raise Error, "#{path}: #{e}"
248 unless yaml.is_a? Hash
249 raise Error, "#{path}: Hash expected"
252 yaml.each_pair do |key, value|
253 if @@known_options.include? key
254 type = @@known_options[key]
256 if value.is_a? String and value.empty?
257 @options[key] = nil
258 elsif type == :boolean
259 unless value == true or value == false
260 raise Error, "#{path}: #{key} is a boolean option"
262 @options[key] = value
263 elsif type == :integer
264 unless value.is_a? Fixnum and value >= 0
265 raise Error, "#{path}: #{key} is a numeric option"
267 @options[key] = value
268 elsif type == :string
269 @options[key] = value.to_s
274 file.close
280 # Print an error message.
282 def error(message)
283 STDERR.puts "Error: #{message}"
284 STDERR.puts "Type `clw --help' for details."
288 # The main program.
290 def main
291 options = CommandLineParser.new ARGV
292 begin
293 options.process
294 rescue CommandLineParser::Error => e
295 error "#{e}."
296 return 1
299 if options["help"]
300 usage
301 return 0
304 mandatory = ["ca", "cert", "key", "subject"]
306 case mode = options["mode"]
307 when "master"
308 mandatory += ["gateway"]
309 when "gateway"
310 # No additional mandatory options
311 when "client"
312 mandatory += ["gateway", "connect"]
313 else
314 error "Please specify a valid mode (master, gateway or client)."
315 return 1
318 # Check options
319 mandatory.each do |option|
320 if options[option].nil?
321 error "--#{option} is mandatory for #{mode} operation."
322 return 1
326 # Prepare the daemon
327 subject = options["subject"]
328 case mode
329 when "master"
330 ghost, gport = options["gateway"].split(":")
331 if gport.nil?
332 gport = Clarkway::PORT
333 elsif gport !~ /^\d+$/
334 error "Gateway port has to be numeric."
335 return 1
336 else
337 gport = gport.to_i
340 reconnect = options["reconnect"] || 0
341 reconnect = 60 if reconnect == 0
343 require "clw/master"
344 daemon = Clarkway::Master.new(ghost, gport,
345 options["subject"], reconnect)
347 when "gateway"
348 listen = options["listen"]
349 if listen.nil?
350 lhost, lport = nil, Clarkway::PORT
351 else
352 lhost, lport = listen.split(":")
353 lhost, lport = nil, lhost if lport.nil?
354 if lport !~ /^\d+$/
355 error "Local port has to be numeric."
356 return 1
358 lport = lport.to_i
361 keepalive = options["keepalive"] || 0
362 keepalive = nil if keepalive == 0
364 require "clw/gateway"
365 daemon = Clarkway::Gateway.new(lhost, lport, subject)
366 daemon.keepalive = keepalive
368 when "client"
369 options["daemon"] = false if options["daemon"].nil?
371 # Where to forward to?
372 stdio = options["stdio"] || false
373 if stdio
374 lhost = lport = nil
375 else
376 listen = options["listen"] || "0"
377 lhost, lport = listen.split(":")
378 lhost, lport = nil, lhost if lport.nil?
379 if lport !~ /^\d+$/
380 error "Local port has to be numeric."
381 return 1
383 lport = lport.to_i
384 lport = nil if lport == 0
387 # Where to connect to?
388 chost, cport = options["connect"].split(":")
389 if cport.nil? or cport !~ /^\d+$/
390 error "Target port has to be numeric."
391 return 1
394 # Gateway location
395 ghost, gport = options["gateway"].split(":")
396 if gport.nil?
397 gport = Clarkway::PORT
398 elsif gport !~ /^\d+$/
399 error "Gateway port has to be numeric."
400 return 1
401 else
402 gport = gport.to_i
405 # Command to execute afterwards
406 command = options["exec"]
407 unless command.nil?
408 options["once"] = true
409 options["daemon"] = false
412 # Direct connection?
413 direct = options["direct"]
414 dhost = dport = nil
415 unless direct.nil?
416 dhost, dport = direct.split ":"
417 unless dport.nil?
418 if dport !~ /^\d+$/
419 error "Direct connection port has to be numeric."
420 return 1
422 dport = dport.to_i
423 dport = nil if dport == 0
427 require "clw/client"
428 daemon = Clarkway::Client.new(chost, cport, ghost, gport, subject)
429 daemon.once = options["once"] || false
430 daemon.stdio = stdio
431 daemon.exec = command
432 daemon.local_host = lhost unless lhost.nil?
433 daemon.local_port = lport
434 daemon.direct_host = dhost
435 daemon.direct_port = dport
436 options["daemon"] = false if stdio
439 # SSL initialization
440 cafile = daemon.ca = options["ca"]
441 begin
442 ca = OpenSSL::X509::Certificate.new File::read(cafile)
443 daemon.cert = OpenSSL::X509::Certificate.new File::read(options["cert"])
444 daemon.key = OpenSSL::PKey::RSA.new File::read(options["key"])
445 unless options["crl"].nil?
446 crl = OpenSSL::X509::CRL.new File::read(options["crl"])
447 raise "CRL not signed by CA" unless crl.verify ca.public_key
448 daemon.crl = crl
450 rescue => e
451 error "While initializing OpenSSL: #{e}."
452 return 1
455 # Logging
456 daemonize = options["daemon"]
457 daemonize = true if daemonize.nil?
458 logfile = options["logfile"]
459 loglevel = options["loglevel"]
460 logname = options["logname"] || "clw"
462 if logfile.nil?
463 logfile = daemonize ? nil : STDERR
464 elsif logfile == "-"
465 logfile = STDERR
466 elsif logfile == "off"
467 logfile = nil
470 begin
471 daemon.logger = Logger.new logfile
472 rescue SystemCallError => e
473 error "While opening logfile: #{e}."
474 return 1
477 daemon.logger.progname = logname
479 case loglevel
480 when "fatal"
481 daemon.logger.sev_threshold = Logger::FATAL
482 when "warn"
483 daemon.logger.sev_threshold = Logger::WARN
484 else
485 daemon.logger.sev_threshold = Logger::INFO
488 # Check PID file
489 pidfile = options["pidfile"]
490 unless pidfile.nil?
491 begin
492 oldpid = File::read(pidfile).to_i
493 begin
494 # Signal 0 does not kill, only check if process is alive:
495 Process.kill 0, oldpid
496 rescue
497 # PID file outdated
498 else
499 error "Already running (#{pidfile}, ##{oldpid})."
500 return 1
502 rescue
503 # No old PID file
506 daemon.on_stop do
507 begin
508 File.unlink pidfile
509 rescue
510 # PID file probably already deleted
515 # Start the daemon
516 unless pidfile.nil?
517 pid = Process.pid
518 pid = daemon.start true if daemonize
520 begin
521 file = File.new pidfile, "w"
522 file.puts pid
523 file.close
524 rescue => e
525 @logger.warn "Unable to write PID file: #{e}."
528 daemon.start false unless daemonize
529 else
530 daemon.start daemonize
533 # Exit successfully
537 exit main if $0 == __FILE__
539 # vim:tw=78:fmr=<<<,>>>:fdm=marker: