matchd 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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