matchd 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (49) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +13 -0
  3. data/.rspec +3 -0
  4. data/.rubocop-https---relaxed-ruby-style-rubocop-yml +166 -0
  5. data/.rubocop.yml +20 -0
  6. data/.travis.yml +18 -0
  7. data/DEVELOPMENT.md +5 -0
  8. data/Gemfile +8 -0
  9. data/License.txt +373 -0
  10. data/README.md +473 -0
  11. data/Rakefile +6 -0
  12. data/bin/console +14 -0
  13. data/bin/matchd +4 -0
  14. data/bin/rake +29 -0
  15. data/bin/rspec +29 -0
  16. data/bin/setup +8 -0
  17. data/examples/registry.yml +30 -0
  18. data/exe/matchd +6 -0
  19. data/lib/matchd.rb +31 -0
  20. data/lib/matchd/cli.rb +10 -0
  21. data/lib/matchd/cli/config.rb +36 -0
  22. data/lib/matchd/cli/config_file_option.rb +23 -0
  23. data/lib/matchd/cli/main.rb +45 -0
  24. data/lib/matchd/config.rb +39 -0
  25. data/lib/matchd/control.rb +56 -0
  26. data/lib/matchd/glue.rb +6 -0
  27. data/lib/matchd/glue/async_endpoint.rb +93 -0
  28. data/lib/matchd/helpers.rb +14 -0
  29. data/lib/matchd/registry.rb +66 -0
  30. data/lib/matchd/response.rb +72 -0
  31. data/lib/matchd/response/a.rb +12 -0
  32. data/lib/matchd/response/aaaa.rb +5 -0
  33. data/lib/matchd/response/cname.rb +12 -0
  34. data/lib/matchd/response/mx.rb +16 -0
  35. data/lib/matchd/response/ns.rb +12 -0
  36. data/lib/matchd/response/ptr.rb +12 -0
  37. data/lib/matchd/response/soa.rb +28 -0
  38. data/lib/matchd/response/srv.rb +20 -0
  39. data/lib/matchd/response/txt.rb +12 -0
  40. data/lib/matchd/rule.rb +116 -0
  41. data/lib/matchd/rule/append.rb +23 -0
  42. data/lib/matchd/rule/fail.rb +18 -0
  43. data/lib/matchd/rule/invalid.rb +24 -0
  44. data/lib/matchd/rule/passthrough.rb +50 -0
  45. data/lib/matchd/rule/respond.rb +16 -0
  46. data/lib/matchd/server.rb +44 -0
  47. data/lib/matchd/version.rb +3 -0
  48. data/matchd.gemspec +31 -0
  49. metadata +191 -0
@@ -0,0 +1,14 @@
1
+ module Matchd
2
+ module Helpers
3
+ module_function
4
+
5
+ # Creates a new Hash with the key-value pairs of options for the keys given
6
+ # and only if options has that keys.
7
+ # Also, new keys will get symbolized.
8
+ def extract_options(keys, options)
9
+ keys.each_with_object({}) do |key, o|
10
+ o[key.to_sym] = options[key] if options.key?(key)
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,66 @@
1
+ require "yaml"
2
+
3
+ module Matchd
4
+ # Tthe dns pattern registry
5
+ # It basically delegates everything to a YAML::Store but handles the conversion
6
+ # of Regexp dns-patterns into YAML string keys and reverse.
7
+ class Registry
8
+ include Enumerable
9
+
10
+ LoadError = Class.new(RuntimeError)
11
+
12
+ class ParseError < RuntimeError
13
+ def initialize(registry_file = nil)
14
+ @registry_file = registry_file
15
+ end
16
+
17
+ def message
18
+ msg = "Missing 'rules' key"
19
+ msg += " in registry file '#{@registry_file}'" if @registry_file
20
+ msg
21
+ end
22
+ end
23
+
24
+ def initialize(rules)
25
+ raise ArgumentError unless rules.is_a?(Enumerable)
26
+ @rules = rules
27
+ end
28
+
29
+ attr_reader :rules
30
+
31
+ # Loads a registry YAML file
32
+ def self.load_file(registry_file)
33
+ unless File.file?(registry_file)
34
+ raise LoadError, "Registry file '#{registry_file}' does not exist"
35
+ end
36
+
37
+ load(YAML.load_file(registry_file), registry_file)
38
+ end
39
+
40
+ def self.load(data, registry_file = nil)
41
+ raise ParseError, registry_file unless data.is_a?(Hash) && data.key?("rules")
42
+
43
+ rules = data["rules"]
44
+
45
+ new(rules ? parse(rules) : [])
46
+ end
47
+
48
+ # Parses raw rule hash definitions (like those read from a YAML config) into
49
+ # `Matchd::Rule`s
50
+ #
51
+ # @param rules [Array<Hash>] the raw rule definitions
52
+ # @return [Array<Matchd::Rule>]
53
+ def self.parse(rules)
54
+ rules = rules.is_a?(Array) ? rules : [rules]
55
+ rules.map { |r| Matchd.Rule(r) }
56
+ end
57
+
58
+ def each(&block)
59
+ rules.each(&block) if rules
60
+ end
61
+
62
+ def valid?
63
+ none? { |r| r.is_a?(Matchd::Rule::Invalid) }
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,72 @@
1
+ module Matchd
2
+ class Response
3
+ autoload :A, "matchd/response/a"
4
+ autoload :AAAA, "matchd/response/aaaa"
5
+ autoload :CNAME, "matchd/response/cname"
6
+ autoload :MX, "matchd/response/mx"
7
+ autoload :NS, "matchd/response/ns"
8
+ autoload :PTR, "matchd/response/ptr"
9
+ autoload :SOA, "matchd/response/soa"
10
+ autoload :SRV, "matchd/response/srv"
11
+ autoload :TXT, "matchd/response/txt"
12
+
13
+ NotImplementedError = Class.new(RuntimeError)
14
+
15
+ # @param [Hash] options (Mind the string keys!)
16
+ # @option options [Numeric] "ttl" The Time-To-Live of the record (default: `Async::DNS::Transaction::DEFAULT_TTL` = 86400sec = 24h)
17
+ # @option options [String] "name" The absolute DNS name. Default is the question name.
18
+ # @option options [String] "section" The answer section. One of "answer", "additional", "authority" (default: "answer")
19
+ def initialize(options)
20
+ @resource_options = {}
21
+
22
+ return unless options.is_a?(Hash)
23
+
24
+ @resource_options[:ttl] = options["ttl"] if options.key?("ttl")
25
+ @resource_options[:name] = options["name"] if options.key?("name")
26
+ @resource_options[:section] = options["section"] if options.key?("section")
27
+ end
28
+
29
+ attr_reader :resource_options
30
+
31
+ def resource
32
+ raise NotImplementedError
33
+ end
34
+
35
+ def call(transaction)
36
+ transaction.add([resource], resource_options)
37
+ end
38
+
39
+ def valid?
40
+ # TODO: this needs to be more suffisticated
41
+ resource && true
42
+ rescue ArgumentError
43
+ false
44
+ end
45
+
46
+ module Factory
47
+ # Creates new instances of a {Matchd::Response} subclass. Which class is being used
48
+ # is defines via the {respond} or {fallback_resource_class} parameters.
49
+ #
50
+ # @param respond [Array<Hash>|Hash] One or multiple response configurations (See subclasses)
51
+ # @param fallback_resource_class [Array[String]|String] One or multiple ressource class names (like `"A"`, `"AAAA"`, `"MX"` etc)
52
+ # defining which Resource subclass is being used if the {respond} doesn't include a `"resource_class"` configuration
53
+ #
54
+ # @return [Array<Matchd::Response>] Instances of {Matchd::Response} subclasses.
55
+ def Response(respond, fallback_resource_class) # rubocop:disable Naming/MethodName
56
+ respond = [respond] unless respond.is_a?(Array) # don't convert Hash to Array
57
+ respond.flat_map do |r|
58
+ resource_class =
59
+ if r.is_a?(Hash)
60
+ r.fetch("resource_class", fallback_resource_class)
61
+ else
62
+ fallback_resource_class
63
+ end
64
+
65
+ raise ArgumentError, "Missing resource_class for Response" unless resource_class
66
+
67
+ Array(resource_class).map { |klass| Matchd::Response.const_get(klass.upcase).new(r) }
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,12 @@
1
+ class Matchd::Response::A < Matchd::Response
2
+ def initialize(opts)
3
+ super
4
+ @ip = opts.is_a?(Hash) ? opts.fetch("ip") : opts
5
+ end
6
+
7
+ attr_reader :ip
8
+
9
+ def resource
10
+ Resolv::DNS::Resource::IN::A.new(ip)
11
+ end
12
+ end
@@ -0,0 +1,5 @@
1
+ class Matchd::Response::AAAA < Matchd::Response::A
2
+ def resource
3
+ Resolv::DNS::Resource::IN::AAAA.new(ip)
4
+ end
5
+ end
@@ -0,0 +1,12 @@
1
+ class Matchd::Response::CNAME < Matchd::Response
2
+ def initialize(opts)
3
+ super
4
+ @alias_name = opts.is_a?(Hash) ? opts.fetch("alias") : opts
5
+ end
6
+
7
+ attr_reader :alias_name
8
+
9
+ def resource
10
+ Resolv::DNS::Resource::IN::CNAME.new(Resolv::DNS::Name.create(alias_name))
11
+ end
12
+ end
@@ -0,0 +1,16 @@
1
+ class Matchd::Response::MX < Matchd::Response
2
+ def initialize(opts)
3
+ super
4
+ @preference = opts.fetch("preference")
5
+ @host = opts.fetch("host")
6
+ end
7
+
8
+ attr_reader :preference, :host
9
+
10
+ def resource
11
+ Resolv::DNS::Resource::IN::MX.new(
12
+ preference,
13
+ Resolv::DNS::Name.create(host),
14
+ )
15
+ end
16
+ end
@@ -0,0 +1,12 @@
1
+ class Matchd::Response::NS < Matchd::Response
2
+ def initialize(opts)
3
+ super
4
+ @host = opts.is_a?(Hash) ? opts.fetch("host") : opts
5
+ end
6
+
7
+ attr_reader :host
8
+
9
+ def resource
10
+ Resolv::DNS::Resource::IN::NS.new(Resolv::DNS::Name.create(host))
11
+ end
12
+ end
@@ -0,0 +1,12 @@
1
+ class Matchd::Response::PTR < Matchd::Response
2
+ def initialize(opts)
3
+ super
4
+ @host = opts.is_a?(Hash) ? opts.fetch("host") : opts
5
+ end
6
+
7
+ attr_reader :host
8
+
9
+ def resource
10
+ Resolv::DNS::Resource::IN::PTR.new(Resolv::DNS::Name.create(host))
11
+ end
12
+ end
@@ -0,0 +1,28 @@
1
+ require "time"
2
+
3
+ class Matchd::Response::SOA < Matchd::Response
4
+ def initialize(opts)
5
+ super
6
+ @mname = opts.fetch("mname")
7
+ @rname = opts.fetch("rname")
8
+ @serial = opts.fetch("serial")
9
+ @refresh_time = opts.fetch("refresh")
10
+ @retry_time = opts.fetch("retry")
11
+ @expire_time = opts.fetch("expire")
12
+ @minimum_ttl = opts.fetch("minimum_ttl")
13
+ end
14
+
15
+ attr_reader :mname, :rname, :serial, :refresh_time, :retry_time, :expire_time, :minimum_ttl
16
+
17
+ def resource
18
+ Resolv::DNS::Resource::IN::SOA.new(
19
+ Resolv::DNS::Name.create(mname),
20
+ Resolv::DNS::Name.create(rname),
21
+ serial,
22
+ refresh_time,
23
+ retry_time,
24
+ expire_time,
25
+ minimum_ttl
26
+ )
27
+ end
28
+ end
@@ -0,0 +1,20 @@
1
+ class Matchd::Response::SRV < Matchd::Response
2
+ def initialize(opts)
3
+ super
4
+ @target = opts.fetch("target")
5
+ @priority = opts.fetch("priority")
6
+ @weight = opts.fetch("weight")
7
+ @port = opts.fetch("port")
8
+ end
9
+
10
+ attr_reader :target, :priority, :weight, :port
11
+
12
+ def resource
13
+ Resolv::DNS::Resource::IN::SRV.new(
14
+ priority,
15
+ weight,
16
+ port,
17
+ target
18
+ )
19
+ end
20
+ end
@@ -0,0 +1,12 @@
1
+ class Matchd::Response::TXT < Matchd::Response
2
+ def initialize(opts)
3
+ super
4
+ @txt = opts.is_a?(Hash) ? opts.fetch("txt") : opts
5
+ end
6
+
7
+ attr_reader :txt
8
+
9
+ def resource
10
+ Resolv::DNS::Resource::IN::TXT.new(txt)
11
+ end
12
+ end
@@ -0,0 +1,116 @@
1
+ require "resolv"
2
+
3
+ module Matchd
4
+ class Rule
5
+ autoload :Append, "matchd/rule/append"
6
+ autoload :Fail, "matchd/rule/fail"
7
+ autoload :Invalid, "matchd/rule/invalid"
8
+ autoload :Passthrough, "matchd/rule/passthrough"
9
+ autoload :Respond, "matchd/rule/respond"
10
+
11
+ REGEXP_MATCHER = %r{\A/(.*)/([mix]*)\Z}m
12
+
13
+ REGEXP_OPTIONS = {
14
+ "m" => Regexp::MULTILINE,
15
+ "i" => Regexp::IGNORECASE,
16
+ "x" => Regexp::EXTENDED
17
+ }.freeze
18
+
19
+ # parses a Regexp lookalike String into Regexp or returns the String
20
+ def self.parse_match(name)
21
+ if name.is_a?(Regexp)
22
+ name
23
+ elsif (r = name.match(REGEXP_MATCHER))
24
+ regexp_opts = r[2].each_char.reduce(0) { |o, c| o |= REGEXP_OPTIONS[c] } # rubocop:disable Lint/UselessAssignment # No, it's not!
25
+ Regexp.new(r[1], regexp_opts)
26
+ else
27
+ name
28
+ end
29
+ end
30
+
31
+ def self.parse_resource_class(resource_class)
32
+ resource_class.map do |klass|
33
+ case klass
34
+ when ::Resolv::DNS::Resource then klass
35
+ when String, Symbol then ::Resolv::DNS::Resource::IN.const_get(klass.upcase)
36
+ end
37
+ end
38
+ end
39
+
40
+ NotImplementedError = Class.new(RuntimeError)
41
+
42
+ def initialize(options)
43
+ @raw = options
44
+ @name = options.fetch("match")
45
+ @resource_classes = Array(options.fetch("resource_class"))
46
+ end
47
+ attr_reader :raw
48
+
49
+ # Implements the rule logic formulating the DNS response to a query.
50
+ # It's return value signals whether this rule was a successful match (in the
51
+ # sense of the rule) and evaluating later rules shall be stopped.
52
+ #
53
+ # @note You should not need to call this method directly, use {#call} instead .
54
+ #
55
+ # @abstract This method needs to be implemented by subclasses.
56
+ #
57
+ # @param server [Matchd::Server]
58
+ # @param name [String] The query name
59
+ # @param resource_class [Resolv::DNS::Resource] The query IN ressource
60
+ # @param transaction [Async::DNS::Transaction]
61
+ # @return [TrueClass|FalseClass] Whether further processing shall stop
62
+ def visit!(_server, _name, _resource_class, _transaction)
63
+ raise NotImplementedError
64
+ end
65
+
66
+ # Checks if this rule matches a DNS query (name and ressource class).
67
+ # @return [TrueClass|FalseClass]
68
+ def matches?(query_name, query_resource_class)
69
+ name_for_match === query_name && # rubocop:disable Style/CaseEquality # This does string equality and Regexp matching at the same time
70
+ resource_classes_for_match.include?(query_resource_class)
71
+ end
72
+
73
+ # This is the main interface for executing rules.
74
+ # It tests if this rule matches by calling {#matches?} and runs it by
75
+ # calling {#visit!}
76
+ #
77
+ # @param server [Matchd::Server]
78
+ # @param name [String] The query name
79
+ # @param resource_class [Resolv::DNS::Resource] The query IN ressource
80
+ # @param transaction [Async::DNS::Transaction]
81
+ # @return [TrueClass|FalseClass] Whether further processing shall stop
82
+ def call(server, name, resource_class, transaction)
83
+ return false unless matches?(name, resource_class)
84
+
85
+ visit!(server, name, resource_class, transaction)
86
+ end
87
+
88
+ # @private
89
+ def match_name
90
+ @match_name ||= self.class.parse_match(@name)
91
+ end
92
+
93
+ # @private
94
+ def match_resource_classes
95
+ @match_resource_classes ||= self.class.parse_resource_class(@resource_classes)
96
+ end
97
+
98
+ module Factory
99
+ def Rule(data) # rubocop:disable Naming/MethodName
100
+ return Rule::Invalid.new(data) unless data.is_a?(Hash)
101
+
102
+ if data["respond"]
103
+ Rule::Respond.new(data)
104
+ elsif data["append_question"]
105
+ Rule::Append.new(data)
106
+ elsif data["passthrough"]
107
+ Rule::Passthrough.new(data)
108
+ elsif data["fail"]
109
+ Rule::Fail.new(data)
110
+ else
111
+ Rule::Invalid.new(data)
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,23 @@
1
+ class Matchd::Rule::Append < Matchd::Rule
2
+ def initialize(options)
3
+ super
4
+ opts = options.fetch("append_question")
5
+
6
+ if opts.is_a?(Hash)
7
+ @append_questions = Array(opts.fetch("resource_class"))
8
+ @transaction_options = Matchd::Helpers.extract_options(%w(ttl name section), opts)
9
+ else
10
+ @append_questions = Array(opts)
11
+ @transaction_options = {}
12
+ end
13
+ end
14
+
15
+ attr_reader :append_questions, :transaction_options
16
+
17
+ def visit!(_server, _name, _resource_class, transaction)
18
+ transaction.append_question!
19
+ Matchd::Rule.parse_resource_class(append_questions).each do |append_resource_class|
20
+ transaction.append!(transaction.name, append_resource_class, transaction_options)
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,18 @@
1
+ class Matchd::Rule::Fail < Matchd::Rule
2
+ def initialize(options)
3
+ super
4
+ @fail = options.fetch("fail")
5
+ end
6
+
7
+ def visit!(_server, _name, _resource_class, transaction)
8
+ transaction.fail!(rcode)
9
+ end
10
+
11
+ def rcode
12
+ @rcode ||=
13
+ case @fail
14
+ when Symbol, String then Resolv::DNS::RCode.const_get(@fail)
15
+ else @fail
16
+ end
17
+ end
18
+ end