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.
- checksums.yaml +7 -0
- data/.gitignore +13 -0
- data/.rspec +3 -0
- data/.rubocop-https---relaxed-ruby-style-rubocop-yml +166 -0
- data/.rubocop.yml +20 -0
- data/.travis.yml +18 -0
- data/DEVELOPMENT.md +5 -0
- data/Gemfile +8 -0
- data/License.txt +373 -0
- data/README.md +473 -0
- data/Rakefile +6 -0
- data/bin/console +14 -0
- data/bin/matchd +4 -0
- data/bin/rake +29 -0
- data/bin/rspec +29 -0
- data/bin/setup +8 -0
- data/examples/registry.yml +30 -0
- data/exe/matchd +6 -0
- data/lib/matchd.rb +31 -0
- data/lib/matchd/cli.rb +10 -0
- data/lib/matchd/cli/config.rb +36 -0
- data/lib/matchd/cli/config_file_option.rb +23 -0
- data/lib/matchd/cli/main.rb +45 -0
- data/lib/matchd/config.rb +39 -0
- data/lib/matchd/control.rb +56 -0
- data/lib/matchd/glue.rb +6 -0
- data/lib/matchd/glue/async_endpoint.rb +93 -0
- data/lib/matchd/helpers.rb +14 -0
- data/lib/matchd/registry.rb +66 -0
- data/lib/matchd/response.rb +72 -0
- data/lib/matchd/response/a.rb +12 -0
- data/lib/matchd/response/aaaa.rb +5 -0
- data/lib/matchd/response/cname.rb +12 -0
- data/lib/matchd/response/mx.rb +16 -0
- data/lib/matchd/response/ns.rb +12 -0
- data/lib/matchd/response/ptr.rb +12 -0
- data/lib/matchd/response/soa.rb +28 -0
- data/lib/matchd/response/srv.rb +20 -0
- data/lib/matchd/response/txt.rb +12 -0
- data/lib/matchd/rule.rb +116 -0
- data/lib/matchd/rule/append.rb +23 -0
- data/lib/matchd/rule/fail.rb +18 -0
- data/lib/matchd/rule/invalid.rb +24 -0
- data/lib/matchd/rule/passthrough.rb +50 -0
- data/lib/matchd/rule/respond.rb +16 -0
- data/lib/matchd/server.rb +44 -0
- data/lib/matchd/version.rb +3 -0
- data/matchd.gemspec +31 -0
- 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::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
|
data/lib/matchd/rule.rb
ADDED
@@ -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
|