matchd 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|