-
Notifications
You must be signed in to change notification settings - Fork 6
/
dnscrypt-proxy-multi.rb
executable file
·1176 lines (983 loc) · 37.5 KB
/
dnscrypt-proxy-multi.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env ruby
# ----------------------------------------------------------
# dnscrypt-proxy-multi
#
# Runs multiple instances of dnscrypt-proxy.
#
# It parses a CSV file and makes use of the entries found in
# it as target remote services when creating instances of
# dnscrypt-proxy. Remote services are checked for
# availability before an instance of dnscrypt-proxy is used
# to connect to them. An FQDN can also be used to check if
# a remote service can resolve names.
#
# The script waits for all instances to exit before it
# exits. It also automaticaly stops them when it receives
# SIGTERM or SIGINT.
#
# Usage: dnscrypt-proxy-multi[.rb] [options] [-- [extra_dnscrypt_proxy_opts]]
#
# Run with --help to see readable info about the options.
#
# Disclaimer: This tool comes with no warranty.
#
# Author: konsolebox
# Copyright Free / Public Domain
# July 13, 2024
# ----------------------------------------------------------
require 'csv'
require 'fileutils'
require 'optparse'
require 'resolv'
require 'socket'
require 'timeout'
module Net
autoload :Ping, 'net/ping'
end
VERSION = '2024.07.13'
INSTANCES_LIMIT = 50
WAIT_FOR_CONNECTION_TIMEOUT = 5
WAIT_FOR_CONNECTION_PAUSE = 1
DEFAULT_PORT = 443
HEADER_MAP = {
:name => 'Name',
:full_name => 'Full name',
:description => 'Description',
:location => 'Location',
:coordinates => 'Coordinates',
:url => 'URL',
:version => 'Version',
:dnssec => 'DNSSEC validation',
:no_logs => 'No logs',
:namecoin => 'Namecoin',
:resolver_addr => 'Resolver address',
:provider_name => 'Provider name',
:provider_key => 'Provider public key',
:provider_key_txt => 'Provider public key TXT record'
}.freeze
@exit_status = 1
@log_buffer = []
@log_file = nil
@pids = []
@syslog_logger = nil
@params = Struct.new(
:change_owner, :check_resolvers, :check_resolvers_timeout,
:check_resolvers_wait, :debug, :dnscrypt_proxy, :dnscrypt_proxy_extra_args,
:dnscrypt_proxy_syslog, :dnscrypt_proxy_syslog_prefix, :dnscrypt_proxy_user,
:dnssec_only, :ephemeral_keys, :group, :ifilters, :ignore_ip_format,
:instance_delay, :local_ip_range, :local_port_range, :log, :log_dir,
:log_file, :log_level, :log_overwrite, :max_instances, :port_check_async,
:port_check_timeout, :resolvers_list, :resolvers_list_encoding, :syslog,
:syslog_prefix, :user, :verbose, :wait_for_connection, :write_pids,
:write_pids_dir, :xfilters
).freeze.new
def initialize_params
@params.change_owner = nil
@params.check_resolvers = nil
@params.check_resolvers_timeout = 5.0
@params.check_resolvers_wait = 0.1
@params.debug = false
@params.dnscrypt_proxy = nil
@params.dnscrypt_proxy_extra_args = nil
@params.dnscrypt_proxy_syslog = false
@params.dnscrypt_proxy_user = nil
@params.ephemeral_keys = false
@params.group = nil
@params.ifilters = []
@params.ignore_ip_format = false
@params.instance_delay = 0.0
@params.local_ip_range = '127.0.100.1-254'
@params.local_port_range = '53'
@params.log = false
@params.log_dir = '/var/log/dnscrypt-proxy-multi'
@params.log_file = nil
@params.log_level = 6
@params.log_overwrite = false
@params.max_instances = 10
@params.port_check_async = 10
@params.port_check_timeout = 5.0
@params.resolvers_list = '/usr/share/dnscrypt-proxy/dnscrypt-resolvers.csv'
@params.resolvers_list_encoding = 'utf-8'
@params.syslog = false
@params.syslog_prefix = ''
@params.user = nil
@params.verbose = false
@params.wait_for_connection = nil
@params.write_pids = false
@params.write_pids_dir = '/var/run/dnscrypt-proxy-multi'
@params.xfilters = []
end
def log(msg, stderr, syslog_method, prefix = '')
stderr ? $stderr.puts("#{prefix}#{msg}") : puts("#{prefix}#{msg}")
if @log_buffer
@log_buffer << "[#{Time.now.strftime('%F %T')}] #{msg}"
elsif @log_file
@log_file.puts "[#{Time.now.strftime('%F %T')}] #{msg}"
@log_file.flush
end
@syslog_logger.method(syslog_method).call("#{@params.syslog_prefix}#{msg}") if @syslog_logger
end
def log_message(msg)
log(msg, false, :info)
end
def log_warning(msg)
log('[Warning] ' + msg, false, :warn)
end
def log_error(msg)
log('[Error] ' + msg, true, :error)
end
def log_verbose(msg)
log(msg, false, :info) if @params.verbose
end
def log_debug(msg = nil)
if @params.debug
msg = yield if block_given?
log('[Debug] ' + msg.to_s, false, :debug)
end
end
def fail(msg)
log('[Failure] ' + msg, true, :fatal)
exit 1
end
def which(cmd)
raise ArgumentError.new("Argument not a string: #{cmd.inspect}") unless cmd.is_a?(String)
return nil if cmd.empty?
case RbConfig::CONFIG['host_os']
when /cygwin/
exts = nil
when /dos|mswin|^win|mingw|msys/
pathext = ENV['PATHEXT']
exts = pathext ? pathext.split(';').select{ |e| e[0] == '.' } : ['.com', '.exe', '.bat']
else
exts = nil
end
if cmd[File::SEPARATOR] or (File::ALT_SEPARATOR and cmd[File::ALT_SEPARATOR])
if exts
ext = File.extname(cmd)
return File.absolute_path(cmd) \
if not ext.empty? and exts.any?{ |e| e.casecmp(ext).zero? } \
and File.file?(cmd) and File.executable?(cmd)
exts.each do |ext|
exe = "#{cmd}#{ext}"
return File.absolute_path(exe) if File.file?(exe) and File.executable?(exe)
end
else
return File.absolute_path(cmd) if File.file?(cmd) and File.executable?(cmd)
end
else
paths = ENV['PATH']
paths = paths ? paths.split(File::PATH_SEPARATOR).select{ |e| File.directory?(e) } : []
if exts
ext = File.extname(cmd)
has_valid_ext = !ext.empty? && exts.any?{ |e| e.casecmp(ext).zero? }
paths.unshift('.').each do |path|
if has_valid_ext
exe = File.join(path, "#{cmd}")
return File.absolute_path(exe) if File.file?(exe) and File.executable?(exe)
end
exts.each do |ext|
exe = File.join(path, "#{cmd}#{ext}")
return File.absolute_path(exe) if File.file?(exe) and File.executable?(exe)
end
end
else
paths.each do |path|
exe = File.join(path, cmd)
return File.absolute_path(exe) if File.file?(exe) and File.executable?(exe)
end
end
end
nil
end
def check_tcp_port(ip, port, timeout_in_seconds)
Timeout::timeout(timeout_in_seconds) do
start = Time.now
TCPSocket.new(ip, port).close
Time.now - start
end
end
def executable_file?(path)
File.file?(path) and File.executable_file?(path) and File.readable?(path)
end
def valid_fqdn?(name)
return false if name =~ /\.\./ or name =~ /^\./
labels = name.split('.')
labels.size > 1 and labels.all? do |e|
e =~ /^[[:alnum:]]+$/ or e =~ /^[[:alnum:]]+[[:alnum:]-]+[[:alnum:]]+$/
end
end
def is_host_valid?(host)
octets = host.split('.')
if octets.last =~ /^[[:digit:]]+$/
return false unless octets.size == 4
octets.each_with_index do |e, i|
n = Integer(e) rescue nil
return false unless n and n < 255
return false if (i == 0 or i == 3) and n == 0
end
else
return false unless octets.all? do |e|
e =~ /^[[:alnum:]]+$/ or e =~ /^[[:alnum:]]+[[:alnum:]-]+[[:alnum:]]+$/
end
end
true
end
def wait_for_connection
log_message "Waiting for connection."
while true
@params.wait_for_connection.each do |host, port|
begin
if port
return if check_tcp_port(host, port, WAIT_FOR_CONNECTION_TIMEOUT)
else
return if Net::Ping::ICMP.new(host, WAIT_FOR_CONNECTION_TIMEOUT).ping
end
rescue SocketError => ex
fail "Socket error exception caught while attempting to wait for connection: #{ex.message}"
rescue SystemCallError, Timeout::Error => ex
log_debug{ "Caught '#{ex.class}' exception while waiting for connection: #{ex.message}" }
end
sleep WAIT_FOR_CONNECTION_PAUSE
end
end
end
module RangeCommon
def expand_simple_range(range, min, max)
case range
when /^[[:digit:]]+$/
i = Integer(range)
return nil if i < min or i > max
[i]
when /^[[:digit:]]+-[[:digit:]]+$/
a, b = range.split('-').map{ |e| Integer(e) }
return nil if a > b or a < min or b > max
Range.new(a, b)
else
nil
end
end
def each
if block_given?
enumerate do |e|
yield e
end
else
Enumerator.new do |y|
enumerate do |e|
y << e
end
end
end
end
def to_s
return @range_string || self
end
end
class IPAddrRange
include RangeCommon
include Enumerable
def initialize(range, ignore_ip_format)
fail "Invalid IP range specificatioin: #{range}" unless range.is_a? String
@range = range.split(',').map do |specific_range|
sections = specific_range.split('.')
fail "Invalid range: #{specific_range}" unless sections.size == 4
sections.each_with_index.map do |e, i|
min = ignore_ip_format || i == 1 || i == 2 ? 0 : 1
max = ignore_ip_format ? 255 : 254
expand_simple_range(e, min, max) or fail "Invalid range: #{specific_range}"
end
end
@range_string = range
end
def enumerate_with(ports_range)
Enumerator.new do |y|
enumerate do |ip|
ports_range.each do |port|
y << [ip, port]
end
end
end
end
protected
def enumerate
@range.each do |a, b, c, d|
a.each do |w|
b.each do |x|
c.each do |y|
d.each do |z|
yield "#{w}.#{x}.#{y}.#{z}"
end
end
end
end
end
end
end
class PortsRange
include RangeCommon
include Enumerable
def initialize(range)
fail "Invalid range: #{range}" unless range.is_a? String
@range = range.split(',').map do |specific_range|
expand_simple_range(specific_range, 1, 65535) or fail "Invalid range: #{specific_range}"
end
@range_string = range
end
protected
def enumerate
@range.each do |specific_range|
specific_range.each do |e|
yield e
end
end
end
end
Entry = Struct.new(:resolver_address, :provider_name, :provider_key, :latency, :next_) do
def resolver_ip
@resolver_ip ||= resolver_address.gsub(/:.*$/, '')
end
def resolver_port
@resolver_port ||= Integer(resolver_address.gsub(/^.*:/, ''))
end
end
def prepare_dir(dir)
if not File.exist? dir
log_message "Creating directory #{dir}."
begin
FileUtils.mkdir_p dir
rescue SystemCallError => ex
fail "Failed to create directory #{dir}: #{ex.message}"
end
elsif not File.directory? dir
fail "Object exists but is not a directory: #{dir}"
end
if @params.change_owner
owner = @params.change_owner
user, group = owner.split(':')
log_message "Changing owner of #{dir} to #{owner}."
begin
FileUtils.chown(user, group, [dir])
rescue SystemCallError, ArgumentError => ex
fail "Failed to change ownership of directory #{dir} to #{owner}: #{ex.message}"
end
end
end
def stop_instances
log_message "Stopping instances."
@pids.each do |pid|
begin
Process.kill('TERM', pid)
rescue SystemCallError
end
end
end
def parse_check_resolvers_arg(arg)
fqdn_list, timeout, wait = arg.split('/')
fqdn_list = fqdn_list.split(',')
if timeout and not timeout.empty?
timeout = Float(timeout) rescue fail("Invalid timeout value: #{timeout}")
@params.check_resolvers_timeout = timeout
end
if wait and not wait.empty?
wait = Float(wait) rescue fail("Invalid waiting time value: #{timeout}")
@params.check_resolvers_wait = wait
end
@params.check_resolvers = fqdn_list.map do |e|
fqdn, sep, option = e.partition(":")
validate_with_dnssec = option == 'dnssec'
fail "Invalid FQDN option: #{option}" unless validate_with_dnssec or option.empty?
fail "Not a valid FQDN: #{e}" unless valid_fqdn?(e)
[fqdn, validate_with_dnssec]
end
end
class Expression
class Options
attr_reader :regex
attr_reader :multiline
attr_reader :full_string_match
attr_reader :ignore_case
def initialize(opts = '')
raise "Expecting string, not #{opts.class}." unless opts.is_a? String
opts.split('').each do |char|
case char
when 'm'
@multiline = true
when 'i'
@ignore_case = true
when 'r'
@regex = true
when 'f'
@full_string_match = true
else
fail "Invalid expression option: #{char}"
end
end
fail "Option full string match ('m') can't be used along with regex ('r')." if @regex and @full_string_match
fail "Option multiline ('m') can only be used with regex ('r')." if @multiline and not @regex
@to_s = opts.dup.freeze
end
def +(opts)
self.class.new(@to_s + opts.to_s)
end
def to_s
@to_s
end
end
attr_reader :expression
attr_reader :options
DEFAULT_OPTIONS = Options.new('')
def initialize(expr, opts = DEFAULT_OPTIONS)
raise "Unexpected argument type for options: #{opts.class}." unless opts.is_a? String or opts.is_a? Options
fail "Expression can't be empty." if expr.empty?
@options = opts.is_a?(Options) ? opts : Options.new(opts)
if @options.regex
i = 0
i |= Regexp::MULTILINE if @options.multiline
i |= Regexp::IGNORECASE if @options.ignore_case
@expression = Regexp.new(expr, i)
elsif @options.ignore_case
@expression = expr.downcase
else
@expression = expr
end
end
def validates?(str)
raise "Expecting string to validate to be string." unless str.is_a? String
if @options.regex
@expression.match?(str)
else
str = str.downcase if @options.ignore_case
if @options.full_string_match
str == @expression
else
not str[@expression].nil?
end
end
end
end
def parse_filter_arg(arg, hash = {})
global_opts, sep, etc = arg.partition(':')
if not sep.empty? and not global_opts['=']
global_opts = Expression::Options.new(global_opts)
arg = etc
else
global_opts = nil
end
arg.split(',').each do |pair|
name_and_opts, sep, keyword = pair.partition('=')
fail "Keyword or regex not specified." if keyword.empty?
name, opts, etc, = name_and_opts.split('/')
fail "Invalid extra argument: #{etc}" if etc
sym = name.to_sym
fail "Invalid column name: #{name}" unless HEADER_MAP.has_key? sym or sym == :*
opts = opts ?
(global_opts ? global_opts.to_s + opts : opts) :
(global_opts || Expression::DEFAULT_OPTIONS)
(hash[name.to_sym] ||= []) << Expression.new(keyword, opts)
end
hash
end
def all_expressions_match?(filter_pairs, row)
raise "Expecting filter_pairs to be not empty." if filter_pairs.empty?
filter_pairs.all? do |name, expressions|
if name == :*
expressions.all? do |expr|
row.fields.any?{ |value| value and expr.validates?(value) }
end
else
value = row[HEADER_MAP[name]] and expressions.all? do |expr|
expr.validates?(value)
end
end
end
end
def main
initialize_params
#
# Parse options.
#
parser = OptionParser.new
parser.require_exact = true if parser.respond_to? :require_exact
parser.on_tail("-h", "--help", "Show this help info and exit.") do
$stderr.puts "dnscrypt-proxy-multi #{VERSION}"
$stderr.puts "Runs multiple instances of dnscrypt-proxy"
$stderr.puts
$stderr.puts "Usage: #{$0} [options] [-- [extra_dnscrypt_proxy_opts]]"
$stderr.puts
$stderr.puts "Options:"
$stderr.puts parser.summarize([], 3, 80, "").map{ |e| e.gsub(/^ {4}--/, '--') }.reject{ |e| e =~ /--resolver-check/ }
$stderr.puts
$stderr.puts "Notes:"
$stderr.puts "* Directories are automatically created recursively when needed."
$stderr.puts "* Services are checked with TCP ports since TCP is a common fallback."
$stderr.puts "* Local ports are first used up before the next IP address in range is used."
$stderr.puts "* Local ports are not checked if they are currently in use."
$stderr.puts "* Names of log files are created based on the remote address, while names of"
$stderr.puts " PID files are based on the local address."
$stderr.puts "* dnscrypt-proxy creates files as the calling user; not the one specified with"
$stderr.puts " --user, so changing ownership of directories and existing files may not be"
$stderr.puts " necessary."
exit 1
end
parser.on("-c", "--check-resolvers=FQDN[:dnssec][,FQDN2[:dnssec],...][/TIMEOUT[/WAIT]]",
"Check instances of dnscrypt-proxy if they can resolve all specified FQDN",
"and replace them with another instance that targets another resolver entry",
"if they don't. Default timeout is #{@params.check_resolvers_timeout}. Default amount of wait-time to",
"allow an instance to load and initialize before checking it is #{@params.check_resolvers_wait}.") do |arg|
parse_check_resolvers_arg arg
end
parser.on("--resolver-check=FQDN[:dnssec][,FQDN2[:dnssec][,...]][/TIMEOUT[/WAIT]]") do |arg|
log_warning "Option '--resolver-check' is deprecated and will soon be removed. Please use '--check-resolvers' instead."
parse_check_resolvers_arg arg
end
parser.on("-C", "--change-owner=USER[:GROUP]",
"Change ownership of directories to user-group before doing anything",
"significant like opening files, instantiating dnscrypt-proxy's, or dropping",
"privilege to a user or group if configured.") do |user_group|
fail "User can't be an empty string." if user_group.empty?
@params.change_owner = user_group
end
parser.on("-d", "--dnscrypt-proxy=PATH", "Set path to dnscrypt-proxy executable.",
"Default is \"#{which('dnscrypt-proxy')}\".") do |path|
fail "Not executable or file does not exist: #{path}" unless executable_file?(path)
@params.dnscrypt_proxy = path
end
parser.on("-D", "--instance-delay=SECONDS",
"Wait SECONDS seconds before creating the next instance of dnscrypt-proxy.",
"Default is #{@params.instance_delay}.") do |secs|
@params.instance_delay = Float(secs) rescue fail("Invalid value for instance-wait: #{secs}.")
end
parser.on("-E", "--ephemeral-keys",
"Pass --ephemeral-keys option to every instance of dnscrypt-proxy.",
"See dnscrypt-proxy(8) for more info.") do
@params.ephemeral_keys = true
end
parser.on("-f", "--filter=[GLOBAL_OPTS:]NAME[/OPTS]=KEYWORD[,NAME2[/OPTS2]=KEYWORD2[,...]]",
"This option inclusively filters resolver entries. The NAME refers to a",
"particular column in the CSV table, and the KEYWORD is a string that matches",
"or submatches the entry's value in the column. Multiple NAME=KEYWORD pairs",
"can be specified, and a NAME can be specified more than once so that more",
"keywords can be used to filter a column. If NAME is '*', the KEYWORD will",
"validate if it matches with any column. Every instance that this options is",
"used defines a filter group. The entry becomes valid for inclusion once",
"all keywords in a filter group validates with its values. Multiple filter",
"groups can be specified to allow different ways to validate an entry for",
"inclusion.",
" ",
"Options can also be included to change how keyword-matching is performed.",
"The usable options are 'r' (regex mode), 'm' (multi-line matching), 'i'",
"(ignore-case)', and 'f' (full string matching). Multi-line matching can",
"only be used with 'r' (regex mode), while full-string matching can only be",
"used without it.",
" ",
"The following table shows the usable names:",
" --------------------------------------------------------------------------",
"| Name | CSV Header String | Details |",
"| ---------------- | --------------------| --------------------------------|",
"| name | Name | |",
"| full_name | Full name | |",
"| description | Description | |",
"| location | Location | |",
"| coordinates | Coordinates | |",
"| url | URL | |",
"| version | Version | |",
"| dnssec | DNSSEC Validation | Values are 'yes' or 'no'. |",
"| no_logs | No logs | Values are 'yes' or 'no'. |",
"| namecoin | Namecoin | Values are 'yes' or 'no'. |",
"| resolver_addr | Resolver address | IP address with port. It's the |",
"| | | argument to dnscrypt-proxy's |",
"| | | '--resolver-address' option. |",
"| provider_name | Provider name | Argument to '--provider-name'. |",
"| provider_key | Provider public key | Argument to '--provider-key'. |",
"| provider_key_txt | Provider public key | |",
"| | TXT record | |",
" --------------------------------------------------------------------------") do |arg|
@params.ifilters << parse_filter_arg(arg)
end
parser.on("-g", "--group=GROUP",
"Drop priviliges to GROUP before creating instances of dnscrypt-proxy.") do |group|
fail "User can't be an empty string." if group.empty?
@params.group = group
end
parser.on("-G", "--debug", "Show debug messages.") do
@params.debug = true
end
parser.on("-i", "--local-ip=RANGE",
"Set range of IP addresses to listen to. Default is \"#{@params.local_ip_range}\".",
"Example: \"127.0.1-254.1-254,10.0.0.1\"") do |range|
@params.local_ip_range = range
end
parser.on("-I", "--ignore-ip-format", "Do not check if a local IP address starts or ends with 0 or 255.") do
@params.ignore_ip_format = true
end
parser.on("-l", "--log [LOG_DIR]", "Enable logging files to LOG_DIR.",
"Default directory is \"#{@params.log_dir}\".") do |dir|
@params.log = true
@params.log_dir = dir if dir
end
parser.on("-L", "--log-level=LEVEL",
"When logging is enabled, tell dnscrypt-proxy to use log level LEVEL.",
"Default level is #{@params.log_level}. See dnscrypt-proxy(8) for info.") do |level|
fail "Value for log level an unsigned integer: #{level}" unless level =~ /^[[:digit:]]+$/
@params.log_level = Integer(level)
end
parser.on("-m", "--max-instances=N",
"Set maximum number of dnscrypt-proxy instances. Default is #{@params.max_instances}.") do |n|
fail "Value for max instances must be an unsigned integer: #{n}" unless n =~ /^[[:digit:]]+$/
n = Integer(n)
fail "Value for max instances cannot be 0 or greater than #{INSTANCES_LIMIT}: #{n}" if n.zero? or n > INSTANCES_LIMIT
@params.max_instances = n
end
parser.on("-o", "--log-output=FILE", "When logging is enabled, write main log output to FILE.",
"Default is \"<LOG_DIR>/dnscrypt-proxy-multi.log\".") do |file|
@params.log_file = file
end
parser.on("-O", "--log-overwrite", "When logging is enabled, do not append output to main log-file.") do
@params.log_overwrite = true
end
parser.on("-p", "--local-port=RANGE", "Set range of ports to listen to.",
"Default is \"#{@params.local_port_range}\". Example: \"2053,5300-5399\"") do |range|
@params.local_port_range = range
end
parser.on("-r", "--resolvers-list=PATH", "Set resolvers list file to use.",
"Default is \"#{@params.resolvers_list}\".") do |path|
fail "Not a readable file: #{path}" unless File.file?(path) and File.readable?(path)
@params.resolvers_list = path
end
parser.on("-R", "--resolvers-list-encoding",
"Set encoding of resolvers list. Default is \"#{@params.resolvers_list_encoding}\".") do |e|
@params.resolvers_list_encoding = e
end
parser.on("-s", "--port-check-async=N",
"Set number of port-check queries to send simultaneously. Default is #{@params.port_check_async}.") do |n|
fail "Value for number of simultaneous checks must be an unsigned integer: #{n}" unless n =~ /^[[:digit:]]+$/
n = Integer(n)
fail "Value for number of simultaneous checks can't be 0." if n.zero?
@params.port_check_async = n
end
parser.on("-S", "--syslog [PREFIX]",
"Log messages to system log. PREFIX gets inserted at the beginning of every",
"message sent to syslog if it's specified. See also -Z.") do |prefix|
@params.syslog = true
@params.syslog_prefix = prefix || ''
end
parser.on("-t", "--port-check-timeout=SECONDS",
"Set timeout when waiting for a port-check reply. Default is #{@params.port_check_timeout}.") do |secs|
secs = Float(secs) rescue fail("Value for check timeout must be a number: #{secs}")
fail "Value for check timeout can't be 0." if secs.zero?
@params.port_check_timeout = secs
end
parser.on("-u", "--user=USER",
"Drop priviliges to USER before creating instances of dnscrypt-proxy.",
"Note that this might prevent dnscrypt-proxy from being able to listen to",
"ports lower than 1024.") do |user|
fail "User can't be an empty string." if user.empty?
@params.user = user
end
parser.on("-U", "--dnscrypt-proxy-user=USER",
"Tell dnscrypt-proxy to drop privileges as USER.",
"Please consider that this may or may not work with --user.") do |user|
fail "User can't be an empty string." if user.empty?
@params.dnscrypt_proxy_user = user
end
parser.on("-v", "--verbose", "Show verbose messages.") do
@params.verbose = true
end
parser.on("-V", "--version", "Show version and exit.") do
$stderr.puts "dnscrypt-proxy-multi #{VERSION}"
exit 1
end
parser.on("-w", "--wait-for-connection=HOST[:PORT][,HOST2[:PORT2][,...]]",
"Wait until any of the specified hosts acknowledges connection, or responds",
"with an ICMP Echo reply if no port is specified. Checking with ICMP needs",
"net-ping gem, and requires root/administrative privileges.") do |pairs|
@params.wait_for_connection = pairs.split(',').map do |host_and_port|
host, port = host_and_port.split(':')
fail "Invalid host: #{host}" unless is_host_valid?(host)
if port.nil?
fail "Root/administrative privileges required for ICMP." unless Process.euid.zero?
begin
require 'net/ping'
rescue LoadError
fail "Gem net-ping needs to be installed to send ICMP Echo requests."
end
else
port = Integer(port) rescue nil
fail "Invalid port: #{port}" unless port and port > 0 and port < 65536
end
[host, port]
end
end
parser.on("-W", "--write-pids [DIR]", "Enable writing PID's to DIR.",
"Default directory is \"#{@params.write_pids_dir}\".") do |dir|
@params.write_pids = true
@params.write_pids_dir = dir if dir
end
parser.on("-x", "--exclude=[GLOBAL_OPTS:]NAME[/OPTS]=KEYWORD[,NAME2=KEYWORD2[/OPTS2][,...]]",
"This option behaves similar to -f or --filter, but it directs entries to be",
"excluded than included.") do |arg|
@params.xfilters << parse_filter_arg(arg)
end
parser.on("-z", "--dnssec-only", "Only use resolvers that support DNSSEC validation.",
"This gives same effect as --filter=dnssec=yes or --exclude=dnssec=no.") do
@params.dnssec_only = true
end
parser.on("-Z", "--dnscrypt-proxy-syslog [PREFIX]",
"Tell dnscrypt-proxy to log messages to system log. It is automatically",
"configured to have a prefix of '[REMOTE_IP:PORT]'. If PREFIX is specified,",
"it is added to it with a space as a separator.",
"Note that this disables file-logging in dnscrypt-proxy.") do |prefix|
@params.dnscrypt_proxy_syslog = true
@params.dnscrypt_proxy_syslog_prefix = prefix if prefix
end
parser.on("--", "All arguments after this are passed to dnscrypt-proxy.",
"Please use this feature only if an option of dnscrypt-proxy is not yet",
"supported by dnscrypt-proxy-multi.") do
@params.dnscrypt_proxy_extra_args = ARGV
parser.terminate
end
parser.parse!
begin
#
# Enable logging to system log if wanted.
#
if @params.syslog
require 'syslog/logger'
begin
@syslog_logger = Syslog::Logger.new('dnscrypt-proxy-multi')
rescue SystemCallError => ex
fail "Failed to open syslog: #{ex.message}"
end
end
#
# Show startup message.
#
log_message "----------------------------------------"
log_message "Starting up."
#
# Setup SIGTERM trap.
#
Signal.trap('TERM') do
log_message "SIGTERM caught."
stop_instances unless @pids.empty?
exit 1
end
#
# Locate dnscrypt-proxy if it wasn't specified.
#
unless @params.dnscrypt_proxy
@params.dnscrypt_proxy = which('dnscrypt-proxy')
fail "Unable to find dnscrypt-proxy. Please specify its location manually." unless @params.dnscrypt_proxy
end
#
# Prepare stuff.
#
range = @params.local_ip_range
ip_addr_range = IPAddrRange.new(range, @params.ignore_ip_format) or fail "Invalid range of IP addresses: #{range}"
range = @params.local_port_range
ports_range = PortsRange.new(range) or fail "Invalid range of ports: #{range}"
log_warning "Specified local ip-port pairs are fewer than maximum number of instances." unless \
ip_addr_range.enumerate_with(ports_range).take(@params.max_instances).to_a.size >= @params.max_instances
prepare_dir(@params.write_pids_dir) if @params.write_pids
prepare_dir(@params.log_dir) if @params.log
mode = @params.log_overwrite ? 'w' : 'a'
if @params.log
log_file = @params.log_file || File.join(@params.log_dir, "dnscrypt-proxy-multi.log")
fail "Log-file exists but is a directory: #{log_file}" if File.directory?(log_file)
begin
@log_file = File.open(log_file, mode)
rescue Errno::EACCES, Errno::ENOENT => ex
fail "Failed to open file #{log_file}: #{ex.message}"
end
@log_file.puts @log_buffer
@log_file.flush
end
@log_buffer = nil
#
# Parse resolver list.
#
entries = []
raw_count = 0
CSV.foreach(@params.resolvers_list, encoding: @params.resolvers_list_encoding, headers: true) do |row|
dnssec_validation, resolver_address, provider_name, provider_key = row.values_at(HEADER_MAP[:dnssec],
HEADER_MAP[:resolver_addr], HEADER_MAP[:provider_name], HEADER_MAP[:provider_key])
if @params.dnssec_only and dnssec_validation != 'yes'
log_verbose "Ignoring entry that doesn't support DNSSEC validation: #{resolver_address}"
elsif not @params.ifilters.empty? and not @params.ifilters.any?{ |set| all_expressions_match?(set, row) }
log_verbose "Ignoring entry that doesn't match any inclusive filter set: #{resolver_address}"
elsif not @params.xfilters.empty? and @params.xfilters.any?{ |set| all_expressions_match?(set, row) }
log_verbose "Ignoring entry that matches an exclusive filter set: #{resolver_address}"
elsif not resolver_address =~ /^([[:alnum:]]{1,3}.){3}[[:alnum:]]{1,3}(:[[:digit:]]+)?$/
log_warning "Ignoring entry with invalid or unsupported resolver address: #{resolver_address}"
elsif not provider_name =~ /^[[:alnum:]]+[[:alnum:].-]+[.][[:alpha:]]+$/
log_warning "Ignoring entry with invalid provider name: #{resolver_address} (#{provider_name})"
elsif not provider_key =~ /^([[:alnum:]]{4}:){15}[[:alnum:]]{4}$/
log_warning "Ignoring entry with invalid provider key: #{resolver_address} (#{provider_key})"
else
unless resolver_address =~ /:[[:digit:]]+$/
log_warning "Using default port #{DEFAULT_PORT} for #{resolver_address}."
resolver_address = "#{resolver_address}:#{DEFAULT_PORT}"
end
entries << Entry.new(resolver_address, provider_name, provider_key)
end
raw_count += 1
end
fail "Resolvers list file \"#{resolvers_list}\" does not contain any entry." if raw_count == 0
fail "All entries have been filtered out." if entries.empty?
#
# Drop privilege if wanted.
#
if @params.group
begin
group = Integer(@params.group) rescue @params.group
Process::Sys.setgid(group)
rescue SystemCallError, ArgumentError => e
fail "Failed to change group or GID to #{@params.group}: #{e.message}"
end
end
if @params.user
begin
user = Integer(@params.user) rescue @params.user
Process::Sys.setuid(user)
rescue SystemCallError, ArgumentError => e
fail "Failed to change user or UID to #{@params.user}: #{e.message}"
end
end