unbind 0.0.1
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 +8 -0
- data/.travis.yml +4 -0
- data/Gemfile +4 -0
- data/Guardfile +6 -0
- data/LICENSE +20 -0
- data/README.md +22 -0
- data/Rakefile +7 -0
- data/bin/unbind +33 -0
- data/lib/unbind.rb +99 -0
- data/lib/unbind/core_ext/hash/keys.rb +140 -0
- data/lib/unbind/core_ext/string/inflections.rb +5 -0
- data/lib/unbind/version.rb +3 -0
- data/lib/unbind/view.rb +74 -0
- data/lib/unbind/zone.rb +72 -0
- data/spec/integration/unbind_spec.rb +56 -0
- data/spec/integration/view_spec.rb +38 -0
- data/spec/integration/zone_spec.rb +32 -0
- data/spec/spec_helper.rb +77 -0
- data/spec/unit/core_ext_spec.rb +18 -0
- data/spec/unit/unbind_spec.rb +48 -0
- data/spec/unit/view_spec.rb +76 -0
- data/spec/unit/zone_spec.rb +66 -0
- data/unbind.gemspec +22 -0
- metadata +104 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 649241be7fdfaeb998b499334bef1325010744a7
|
4
|
+
data.tar.gz: c9f48654df4efc5eb9eaa539c57fca5b45e18a83
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 9a290a2d4c77f5dfe5f1414fb4c4d17bc26a750fd356a151e754f260b4fc626f39cc11e6a83963cff0e8aa61cc1b9cd3aa897d73d9d545c764fabbffdaf101eb
|
7
|
+
data.tar.gz: 8df5b75089aa332d0db6b1dd606f1de74187493aa0d92133fe4a5704a41f39b8b180d69058c874f17ffa1ff86a4471c3ce69a875bb188c560e4d7b85e77f81ec
|
data/.gitignore
ADDED
data/.travis.yml
ADDED
data/Gemfile
ADDED
data/Guardfile
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2013 Dennis Krupenik
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
6
|
+
this software and associated documentation files (the "Software"), to deal in
|
7
|
+
the Software without restriction, including without limitation the rights to
|
8
|
+
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
9
|
+
the Software, and to permit persons to whom the Software is furnished to do so,
|
10
|
+
subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
13
|
+
copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
17
|
+
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
18
|
+
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
19
|
+
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
20
|
+
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
# UNBIND
|
2
|
+
|
3
|
+
[](https://codeclimate.com/github/krupenik/unbind)
|
4
|
+
[](https://travis-ci.org/krupenik/unbind)
|
5
|
+
|
6
|
+
ISC BINDv9 config generator
|
7
|
+
|
8
|
+
## Installation
|
9
|
+
|
10
|
+
gem install unbind
|
11
|
+
|
12
|
+
## Usage
|
13
|
+
|
14
|
+
$ unbind [-c <config file>] command
|
15
|
+
|
16
|
+
Commands:
|
17
|
+
master generate named.conf for master
|
18
|
+
slave generate named.conf for slave
|
19
|
+
zone zone_name [view_name] generate zone file for view
|
20
|
+
|
21
|
+
Options:
|
22
|
+
-c config file to use
|
data/Rakefile
ADDED
data/bin/unbind
ADDED
@@ -0,0 +1,33 @@
|
|
1
|
+
#! /usr/bin/env ruby
|
2
|
+
|
3
|
+
require "optparse"
|
4
|
+
require "unbind"
|
5
|
+
|
6
|
+
options = {config: "/etc/unbind.conf"}
|
7
|
+
opts = OptionParser.new do |opts|
|
8
|
+
opts.banner = <<-EOF
|
9
|
+
Usage: unbind [-c <config file>] command
|
10
|
+
|
11
|
+
Commands:
|
12
|
+
master generate named.conf for master
|
13
|
+
slave generate named.conf for slave
|
14
|
+
zone zone_name [view_name] generate zone file for view
|
15
|
+
|
16
|
+
Options:
|
17
|
+
-c config file to use
|
18
|
+
EOF
|
19
|
+
|
20
|
+
opts.on("-cCONFIG", "--config-file CONFIG", "Configuration file (default: #{options[:config]})") do |x|
|
21
|
+
options[:config] = x
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
opts.parse!
|
26
|
+
command = ARGV.shift
|
27
|
+
|
28
|
+
unless Unbind::Commands.include? command
|
29
|
+
abort opts.help
|
30
|
+
end
|
31
|
+
|
32
|
+
Unbind.load_config options[:config]
|
33
|
+
print Unbind.send(command, ARGV)
|
data/lib/unbind.rb
ADDED
@@ -0,0 +1,99 @@
|
|
1
|
+
require 'yaml'
|
2
|
+
|
3
|
+
require 'unbind/core_ext/hash/keys'
|
4
|
+
require 'unbind/core_ext/string/inflections'
|
5
|
+
|
6
|
+
require 'unbind/version'
|
7
|
+
require 'unbind/view'
|
8
|
+
require 'unbind/zone'
|
9
|
+
|
10
|
+
module Unbind
|
11
|
+
Commands = %w(master slave zone).freeze
|
12
|
+
|
13
|
+
@config = {}
|
14
|
+
|
15
|
+
class << self
|
16
|
+
attr_reader :config, :geoip
|
17
|
+
|
18
|
+
def load_config config
|
19
|
+
clear_config
|
20
|
+
|
21
|
+
files = File.directory?(config) ? Dir["#{config}/**/*.conf"] : Dir[config]
|
22
|
+
raise "No files could be found (search path: #{config})" if files.empty?
|
23
|
+
files.each { |f| raise "File '#{f}' could not be loaded" unless load_file(f) }
|
24
|
+
|
25
|
+
prepare_config
|
26
|
+
|
27
|
+
true
|
28
|
+
end
|
29
|
+
|
30
|
+
def master(*)
|
31
|
+
views.map { |v| v.master }.join("\n")
|
32
|
+
end
|
33
|
+
|
34
|
+
def slave(*)
|
35
|
+
views.map { |v| v.slave }.join("\n")
|
36
|
+
end
|
37
|
+
|
38
|
+
def zone zone_names
|
39
|
+
zone_names.map { |z| Unbind::Zone.new(z, @config[:zones][z]).generate }.join("\n")
|
40
|
+
end
|
41
|
+
|
42
|
+
# private
|
43
|
+
|
44
|
+
def load_file f
|
45
|
+
load_string(File.read(File.expand_path(f)))
|
46
|
+
end
|
47
|
+
|
48
|
+
def load_string s
|
49
|
+
data = YAML.load(s)
|
50
|
+
raise "config data should be a hash" unless data.is_a? Hash
|
51
|
+
@config.merge!(data) and true
|
52
|
+
end
|
53
|
+
|
54
|
+
def clear_config
|
55
|
+
@config = {}
|
56
|
+
end
|
57
|
+
|
58
|
+
def prepare_config
|
59
|
+
symbolize_config
|
60
|
+
init_geoip
|
61
|
+
end
|
62
|
+
|
63
|
+
def init_geoip
|
64
|
+
if @config[:geoip_dat]
|
65
|
+
begin
|
66
|
+
require 'geoip'
|
67
|
+
@geoip = GeoIP.new(@config[:geoip_dat])
|
68
|
+
rescue LoadError
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def symbolize_config
|
74
|
+
@config.symbolize_keys!
|
75
|
+
|
76
|
+
@config[:views] ||= {}
|
77
|
+
@config[:zones] ||= {}
|
78
|
+
|
79
|
+
@config[:zones].each do |k, v|
|
80
|
+
v.symbolize_keys!
|
81
|
+
v[:data].symbolize_keys!
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
def policies
|
86
|
+
@policies ||= @config[:zones].reduce([]) { |a, (name, config)| a + (config[:policies] || []) }.map { |policy_name|
|
87
|
+
Unbind::Policy.const_get(policy_name.camelize)
|
88
|
+
}
|
89
|
+
end
|
90
|
+
|
91
|
+
def zones
|
92
|
+
@zones ||= @config[:zones].map { |name, config| Unbind::Zone.new(name, config) }
|
93
|
+
end
|
94
|
+
|
95
|
+
def views
|
96
|
+
@views ||= @config[:views].map { |name, clients| Unbind::View.new(name, {clients: clients, zones: zones}) }
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
@@ -0,0 +1,140 @@
|
|
1
|
+
# grabbed from activesupport-4.0.1
|
2
|
+
|
3
|
+
class Hash
|
4
|
+
# Return a new hash with all keys converted using the block operation.
|
5
|
+
#
|
6
|
+
# hash = { name: 'Rob', age: '28' }
|
7
|
+
#
|
8
|
+
# hash.transform_keys{ |key| key.to_s.upcase }
|
9
|
+
# # => { "NAME" => "Rob", "AGE" => "28" }
|
10
|
+
def transform_keys
|
11
|
+
result = {}
|
12
|
+
each_key do |key|
|
13
|
+
result[yield(key)] = self[key]
|
14
|
+
end
|
15
|
+
result
|
16
|
+
end
|
17
|
+
|
18
|
+
# Destructively convert all keys using the block operations.
|
19
|
+
# Same as transform_keys but modifies +self+.
|
20
|
+
def transform_keys!
|
21
|
+
keys.each do |key|
|
22
|
+
self[yield(key)] = delete(key)
|
23
|
+
end
|
24
|
+
self
|
25
|
+
end
|
26
|
+
|
27
|
+
# Return a new hash with all keys converted to strings.
|
28
|
+
#
|
29
|
+
# hash = { name: 'Rob', age: '28' }
|
30
|
+
#
|
31
|
+
# hash.stringify_keys
|
32
|
+
# #=> { "name" => "Rob", "age" => "28" }
|
33
|
+
def stringify_keys
|
34
|
+
transform_keys{ |key| key.to_s }
|
35
|
+
end
|
36
|
+
|
37
|
+
# Destructively convert all keys to strings. Same as
|
38
|
+
# +stringify_keys+, but modifies +self+.
|
39
|
+
def stringify_keys!
|
40
|
+
transform_keys!{ |key| key.to_s }
|
41
|
+
end
|
42
|
+
|
43
|
+
# Return a new hash with all keys converted to symbols, as long as
|
44
|
+
# they respond to +to_sym+.
|
45
|
+
#
|
46
|
+
# hash = { 'name' => 'Rob', 'age' => '28' }
|
47
|
+
#
|
48
|
+
# hash.symbolize_keys
|
49
|
+
# #=> { name: "Rob", age: "28" }
|
50
|
+
def symbolize_keys
|
51
|
+
transform_keys{ |key| key.to_sym rescue key }
|
52
|
+
end
|
53
|
+
alias_method :to_options, :symbolize_keys
|
54
|
+
|
55
|
+
# Destructively convert all keys to symbols, as long as they respond
|
56
|
+
# to +to_sym+. Same as +symbolize_keys+, but modifies +self+.
|
57
|
+
def symbolize_keys!
|
58
|
+
transform_keys!{ |key| key.to_sym rescue key }
|
59
|
+
end
|
60
|
+
alias_method :to_options!, :symbolize_keys!
|
61
|
+
|
62
|
+
# Validate all keys in a hash match <tt>*valid_keys</tt>, raising ArgumentError
|
63
|
+
# on a mismatch. Note that keys are NOT treated indifferently, meaning if you
|
64
|
+
# use strings for keys but assert symbols as keys, this will fail.
|
65
|
+
#
|
66
|
+
# { name: 'Rob', years: '28' }.assert_valid_keys(:name, :age) # => raises "ArgumentError: Unknown key: years"
|
67
|
+
# { name: 'Rob', age: '28' }.assert_valid_keys('name', 'age') # => raises "ArgumentError: Unknown key: name"
|
68
|
+
# { name: 'Rob', age: '28' }.assert_valid_keys(:name, :age) # => passes, raises nothing
|
69
|
+
def assert_valid_keys(*valid_keys)
|
70
|
+
valid_keys.flatten!
|
71
|
+
each_key do |k|
|
72
|
+
raise ArgumentError.new("Unknown key: #{k}") unless valid_keys.include?(k)
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
# Return a new hash with all keys converted by the block operation.
|
77
|
+
# This includes the keys from the root hash and from all
|
78
|
+
# nested hashes.
|
79
|
+
#
|
80
|
+
# hash = { person: { name: 'Rob', age: '28' } }
|
81
|
+
#
|
82
|
+
# hash.deep_transform_keys{ |key| key.to_s.upcase }
|
83
|
+
# # => { "PERSON" => { "NAME" => "Rob", "AGE" => "28" } }
|
84
|
+
def deep_transform_keys(&block)
|
85
|
+
result = {}
|
86
|
+
each do |key, value|
|
87
|
+
result[yield(key)] = value.is_a?(Hash) ? value.deep_transform_keys(&block) : value
|
88
|
+
end
|
89
|
+
result
|
90
|
+
end
|
91
|
+
|
92
|
+
# Destructively convert all keys by using the block operation.
|
93
|
+
# This includes the keys from the root hash and from all
|
94
|
+
# nested hashes.
|
95
|
+
def deep_transform_keys!(&block)
|
96
|
+
keys.each do |key|
|
97
|
+
value = delete(key)
|
98
|
+
self[yield(key)] = value.is_a?(Hash) ? value.deep_transform_keys!(&block) : value
|
99
|
+
end
|
100
|
+
self
|
101
|
+
end
|
102
|
+
|
103
|
+
# Return a new hash with all keys converted to strings.
|
104
|
+
# This includes the keys from the root hash and from all
|
105
|
+
# nested hashes.
|
106
|
+
#
|
107
|
+
# hash = { person: { name: 'Rob', age: '28' } }
|
108
|
+
#
|
109
|
+
# hash.deep_stringify_keys
|
110
|
+
# # => { "person" => { "name" => "Rob", "age" => "28" } }
|
111
|
+
def deep_stringify_keys
|
112
|
+
deep_transform_keys{ |key| key.to_s }
|
113
|
+
end
|
114
|
+
|
115
|
+
# Destructively convert all keys to strings.
|
116
|
+
# This includes the keys from the root hash and from all
|
117
|
+
# nested hashes.
|
118
|
+
def deep_stringify_keys!
|
119
|
+
deep_transform_keys!{ |key| key.to_s }
|
120
|
+
end
|
121
|
+
|
122
|
+
# Return a new hash with all keys converted to symbols, as long as
|
123
|
+
# they respond to +to_sym+. This includes the keys from the root hash
|
124
|
+
# and from all nested hashes.
|
125
|
+
#
|
126
|
+
# hash = { 'person' => { 'name' => 'Rob', 'age' => '28' } }
|
127
|
+
#
|
128
|
+
# hash.deep_symbolize_keys
|
129
|
+
# # => { person: { name: "Rob", age: "28" } }
|
130
|
+
def deep_symbolize_keys
|
131
|
+
deep_transform_keys{ |key| key.to_sym rescue key }
|
132
|
+
end
|
133
|
+
|
134
|
+
# Destructively convert all keys to symbols, as long as they respond
|
135
|
+
# to +to_sym+. This includes the keys from the root hash and from all
|
136
|
+
# nested hashes.
|
137
|
+
def deep_symbolize_keys!
|
138
|
+
deep_transform_keys!{ |key| key.to_sym rescue key }
|
139
|
+
end
|
140
|
+
end
|
data/lib/unbind/view.rb
ADDED
@@ -0,0 +1,74 @@
|
|
1
|
+
module Unbind
|
2
|
+
class View
|
3
|
+
def self.expand_countries clients
|
4
|
+
clients.flat_map { |i|
|
5
|
+
if i.is_a?(Hash) && i.has_key?("countries")
|
6
|
+
i["countries"].map { |c| "country_#{c.upcase}" }
|
7
|
+
else
|
8
|
+
i
|
9
|
+
end
|
10
|
+
}
|
11
|
+
end
|
12
|
+
|
13
|
+
def initialize name, config
|
14
|
+
raise "view must have a name" unless name.is_a?(String) && 0 < name.length
|
15
|
+
raise "config should include clients and zones lists" unless
|
16
|
+
config.is_a?(Hash) && [:clients, :zones].all? { |k| config.has_key?(k) && config[k].is_a?(Array) }
|
17
|
+
|
18
|
+
@name = name
|
19
|
+
@clients = config[:clients]
|
20
|
+
@zones = config[:zones]
|
21
|
+
end
|
22
|
+
|
23
|
+
def slave
|
24
|
+
["view \"#{@name}\" {", match_clients, zones(:slave), "};\n"].join("\n")
|
25
|
+
end
|
26
|
+
|
27
|
+
def master
|
28
|
+
["view \"#{@name}\" {", match_clients, servers, view_settings(:master), zones(:master), "};\n"].join("\n")
|
29
|
+
end
|
30
|
+
|
31
|
+
def clients
|
32
|
+
self.class.expand_countries(@clients)
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
def slaves
|
38
|
+
@zones.reduce([]) { |a, e| a + e.slaves }
|
39
|
+
end
|
40
|
+
|
41
|
+
def match_clients
|
42
|
+
"match-clients { key #{@name}; !tsig_keys; #{clients.join("; ")}; };"
|
43
|
+
end
|
44
|
+
|
45
|
+
def file zone
|
46
|
+
"file \"pri/#{zone.name}/#{@name}.zone\";"
|
47
|
+
end
|
48
|
+
|
49
|
+
def masters zone
|
50
|
+
"masters { %s key %s; };" % [zone.master, @name]
|
51
|
+
end
|
52
|
+
|
53
|
+
def servers
|
54
|
+
slaves.map { |slave|
|
55
|
+
"server #{slave} { keys #{@name}; };"
|
56
|
+
}
|
57
|
+
end
|
58
|
+
|
59
|
+
def view_settings role
|
60
|
+
case role
|
61
|
+
when :master
|
62
|
+
"allow-transfer { keys #{@name}; };\nnotify yes;"
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
def zones role
|
67
|
+
@zones.flat_map { |zone|
|
68
|
+
([zone.name] + zone.aliases).flat_map { |zone_name|
|
69
|
+
"zone \"#{zone_name}\" { type #{role}; #{:master == role ? file(zone) : masters(zone) } };"
|
70
|
+
}
|
71
|
+
}
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
data/lib/unbind/zone.rb
ADDED
@@ -0,0 +1,72 @@
|
|
1
|
+
module Unbind
|
2
|
+
class Zone
|
3
|
+
TTL = 600
|
4
|
+
|
5
|
+
attr_reader :aliases, :master, :name, :slaves, :ttl, :version
|
6
|
+
|
7
|
+
def initialize name, config
|
8
|
+
raise "zone should have a valid name (given: #{name})" unless name =~ /\A[\w\-\.]+\z/
|
9
|
+
|
10
|
+
@name = name
|
11
|
+
|
12
|
+
raise 'zone config should include data' unless
|
13
|
+
config.is_a?(Hash) && config.has_key?(:data) && config[:data].is_a?(Hash)
|
14
|
+
|
15
|
+
@config = config
|
16
|
+
@data = @config[:data]
|
17
|
+
|
18
|
+
# raise 'zone data should include name and mail servers and at least one A record' unless
|
19
|
+
# [:ns, :mx, :a].all? { |k| @data.has_key?(k) && !@data[k].empty? }
|
20
|
+
|
21
|
+
sanitize_ttl
|
22
|
+
sanitize_version
|
23
|
+
assign_optional_data
|
24
|
+
end
|
25
|
+
|
26
|
+
def generate
|
27
|
+
[header, essentials, resources, nil].join("\n")
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
def assign_optional_data
|
33
|
+
[:aliases, :master, :slaves].each do |i|
|
34
|
+
instance_variable_set(:"@#{i}", @config[i] || [])
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def sanitize_ttl
|
39
|
+
@ttl = @config[:ttl].to_i
|
40
|
+
@ttl = TTL if @ttl <= 0
|
41
|
+
end
|
42
|
+
|
43
|
+
def sanitize_version
|
44
|
+
@version = @config[:version].to_i
|
45
|
+
@version = Time.now.utc.strftime("%Y%m%d%H%M") if @version <= 0
|
46
|
+
end
|
47
|
+
|
48
|
+
def header
|
49
|
+
[
|
50
|
+
"$TTL #{ttl}",
|
51
|
+
"@ SOA ns0 root (#{version} 1d 10m 2w 10m)",
|
52
|
+
]
|
53
|
+
end
|
54
|
+
|
55
|
+
def essentials
|
56
|
+
Array(@data[:ns]).map { |name| "@ NS #{name}" } +
|
57
|
+
Array(@data[:mx]).map.with_index { |name, prio| "@ MX #{prio+1} #{name}" }
|
58
|
+
end
|
59
|
+
|
60
|
+
def resources
|
61
|
+
(@data.keys - [:ns, :mx]).reduce([]) { |a, type|
|
62
|
+
a + @data[type].reduce([]) { |a, (names, addresses)|
|
63
|
+
a + names.split(/\s*,\s*/).reduce([]) { |a, name|
|
64
|
+
a + Array(addresses).reduce([]) { |a, address|
|
65
|
+
a + ["#{name} #{type.upcase} #{address}"]
|
66
|
+
}
|
67
|
+
}
|
68
|
+
}
|
69
|
+
}
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
require 'unbind'
|
2
|
+
require 'spec_helper'
|
3
|
+
|
4
|
+
describe Unbind do
|
5
|
+
include_full_config
|
6
|
+
|
7
|
+
describe '.slave' do
|
8
|
+
it "generates config for slaves" do
|
9
|
+
expect(subject.slave).to eq(<<EOF
|
10
|
+
view "internal" {
|
11
|
+
match-clients { key internal; !tsig_keys; 127.0.0.0/8; 10.0.0.0/24; };
|
12
|
+
zone "zone.ua" { type slave; masters { 10.0.0.1 key internal; }; };
|
13
|
+
zone "zone.uk" { type slave; masters { 10.0.0.1 key internal; }; };
|
14
|
+
zone "zone.us" { type slave; masters { 10.0.0.1 key internal; }; };
|
15
|
+
};
|
16
|
+
|
17
|
+
view "external" {
|
18
|
+
match-clients { key external; !tsig_keys; country_UA; country_UK; country_US; country_TT; };
|
19
|
+
zone "zone.ua" { type slave; masters { 10.0.0.1 key external; }; };
|
20
|
+
zone "zone.uk" { type slave; masters { 10.0.0.1 key external; }; };
|
21
|
+
zone "zone.us" { type slave; masters { 10.0.0.1 key external; }; };
|
22
|
+
};
|
23
|
+
EOF
|
24
|
+
)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
describe '.master' do
|
29
|
+
it "generates config for the master" do
|
30
|
+
expect(subject.master).to eq(<<EOF
|
31
|
+
view "internal" {
|
32
|
+
match-clients { key internal; !tsig_keys; 127.0.0.0/8; 10.0.0.0/24; };
|
33
|
+
server 192.168.0.2 { keys internal; };
|
34
|
+
server 192.168.0.3 { keys internal; };
|
35
|
+
allow-transfer { keys internal; };
|
36
|
+
notify yes;
|
37
|
+
zone "zone.ua" { type master; file "pri/zone.ua/internal.zone"; };
|
38
|
+
zone "zone.uk" { type master; file "pri/zone.ua/internal.zone"; };
|
39
|
+
zone "zone.us" { type master; file "pri/zone.ua/internal.zone"; };
|
40
|
+
};
|
41
|
+
|
42
|
+
view "external" {
|
43
|
+
match-clients { key external; !tsig_keys; country_UA; country_UK; country_US; country_TT; };
|
44
|
+
server 192.168.0.2 { keys external; };
|
45
|
+
server 192.168.0.3 { keys external; };
|
46
|
+
allow-transfer { keys external; };
|
47
|
+
notify yes;
|
48
|
+
zone "zone.ua" { type master; file "pri/zone.ua/external.zone"; };
|
49
|
+
zone "zone.uk" { type master; file "pri/zone.ua/external.zone"; };
|
50
|
+
zone "zone.us" { type master; file "pri/zone.ua/external.zone"; };
|
51
|
+
};
|
52
|
+
EOF
|
53
|
+
)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
require 'unbind/view'
|
2
|
+
require 'spec_helper'
|
3
|
+
|
4
|
+
describe Unbind::View do
|
5
|
+
include_view_definition
|
6
|
+
|
7
|
+
describe "#slave" do
|
8
|
+
it "generates the view for a slave" do
|
9
|
+
expect(subject.slave).to eq(<<EOF
|
10
|
+
view "test" {
|
11
|
+
match-clients { key test; !tsig_keys; country_UA; country_UK; country_US; };
|
12
|
+
zone "zone.ua" { type slave; masters { 10.0.0.1 key test; }; };
|
13
|
+
zone "zone.uk" { type slave; masters { 10.0.0.1 key test; }; };
|
14
|
+
zone "zone.us" { type slave; masters { 10.0.0.1 key test; }; };
|
15
|
+
};
|
16
|
+
EOF
|
17
|
+
)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
describe "#master" do
|
22
|
+
it "generates the view for the master" do
|
23
|
+
expect(subject.master).to eq(<<EOF
|
24
|
+
view "test" {
|
25
|
+
match-clients { key test; !tsig_keys; country_UA; country_UK; country_US; };
|
26
|
+
server 10.0.0.2 { keys test; };
|
27
|
+
server 10.0.0.3 { keys test; };
|
28
|
+
allow-transfer { keys test; };
|
29
|
+
notify yes;
|
30
|
+
zone "zone.ua" { type master; file "pri/zone.ua/test.zone"; };
|
31
|
+
zone "zone.uk" { type master; file "pri/zone.ua/test.zone"; };
|
32
|
+
zone "zone.us" { type master; file "pri/zone.ua/test.zone"; };
|
33
|
+
};
|
34
|
+
EOF
|
35
|
+
)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
require 'unbind/zone'
|
2
|
+
require 'spec_helper'
|
3
|
+
|
4
|
+
describe Unbind::Zone do
|
5
|
+
include_zone_definition
|
6
|
+
|
7
|
+
describe "#generate" do
|
8
|
+
it "generates zone file content" do
|
9
|
+
expect(subject.generate).to eq(<<EOF
|
10
|
+
$TTL #{subject.ttl}
|
11
|
+
@ SOA ns0 root (#{subject.version} 1d 10m 2w 10m)
|
12
|
+
@ NS ns1
|
13
|
+
@ NS ns2
|
14
|
+
@ MX 1 mail
|
15
|
+
@ A 10.0.0.1
|
16
|
+
@ A 10.0.0.2
|
17
|
+
@ A 10.0.0.3
|
18
|
+
* A 10.0.0.1
|
19
|
+
* A 10.0.0.2
|
20
|
+
* A 10.0.0.3
|
21
|
+
mail A 10.0.0.1
|
22
|
+
mail A 10.0.0.2
|
23
|
+
ns1 A 10.0.0.1
|
24
|
+
ns2 A 10.0.0.2
|
25
|
+
@ TXT "txt data"
|
26
|
+
imap CNAME mail
|
27
|
+
_xmpp-client._tcp SRV 5222 0 5 .
|
28
|
+
EOF
|
29
|
+
)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,77 @@
|
|
1
|
+
def include_zone_definition
|
2
|
+
require 'unbind/zone'
|
3
|
+
|
4
|
+
zone = Unbind::Zone.new("zone.ua", {
|
5
|
+
master: '10.0.0.1',
|
6
|
+
slaves: %w(10.0.0.2 10.0.0.3),
|
7
|
+
aliases: %w(zone.uk zone.us),
|
8
|
+
data: {
|
9
|
+
ns: %w(ns1 ns2),
|
10
|
+
mx: 'mail',
|
11
|
+
a: {
|
12
|
+
'@, *' => %w(10.0.0.1 10.0.0.2 10.0.0.3),
|
13
|
+
'mail' => %w(10.0.0.1 10.0.0.2),
|
14
|
+
'ns1' => '10.0.0.1',
|
15
|
+
'ns2' => '10.0.0.2',
|
16
|
+
},
|
17
|
+
txt: {
|
18
|
+
'@' => '"txt data"',
|
19
|
+
},
|
20
|
+
cname: {
|
21
|
+
'imap' => 'mail',
|
22
|
+
},
|
23
|
+
srv: {
|
24
|
+
'_xmpp-client._tcp' => '5222 0 5 .'
|
25
|
+
},
|
26
|
+
},
|
27
|
+
})
|
28
|
+
|
29
|
+
@zone = zone
|
30
|
+
|
31
|
+
subject(:zone) { zone }
|
32
|
+
end
|
33
|
+
|
34
|
+
def include_view_definition
|
35
|
+
include_zone_definition
|
36
|
+
|
37
|
+
require 'unbind/view'
|
38
|
+
|
39
|
+
view = Unbind::View.new("test", {
|
40
|
+
clients: [{"countries" => %w(ua uk us)}],
|
41
|
+
zones: [@zone],
|
42
|
+
})
|
43
|
+
|
44
|
+
subject(:view) { view }
|
45
|
+
end
|
46
|
+
|
47
|
+
def include_full_config
|
48
|
+
Unbind.instance_variable_set(:@config, {
|
49
|
+
views: {
|
50
|
+
'internal' => ['127.0.0.0/8', '10.0.0.0/24'],
|
51
|
+
'external' => [{
|
52
|
+
'countries' => ['ua', 'uk', 'us', 'tt'],
|
53
|
+
}],
|
54
|
+
},
|
55
|
+
zones: {
|
56
|
+
'zone.ua' => {
|
57
|
+
master: '10.0.0.1',
|
58
|
+
slaves: ['192.168.0.2', '192.168.0.3'],
|
59
|
+
aliases: ['zone.uk', 'zone.us'],
|
60
|
+
data: {
|
61
|
+
ns: ['ns1', 'ns2'],
|
62
|
+
mx: ['mx1'],
|
63
|
+
a: {
|
64
|
+
'ns1' => '192.168.0.2',
|
65
|
+
'ns2' => '192.168.0.3',
|
66
|
+
'mx1' => '192.168.0.2',
|
67
|
+
'@, *' => ['192.168.0.2', '192.168.0.3'],
|
68
|
+
},
|
69
|
+
},
|
70
|
+
},
|
71
|
+
},
|
72
|
+
})
|
73
|
+
|
74
|
+
Unbind.send :prepare_config
|
75
|
+
|
76
|
+
subject { Unbind }
|
77
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
require 'unbind/core_ext/hash/keys'
|
2
|
+
require 'unbind/core_ext/string/inflections'
|
3
|
+
|
4
|
+
describe Hash do
|
5
|
+
describe '#symbolize_keys' do
|
6
|
+
it 'converts string keys to symbols' do
|
7
|
+
expect({'symbol' => ''}.symbolize_keys).to eq({symbol: ''})
|
8
|
+
end
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
describe String do
|
13
|
+
describe '#camelize' do
|
14
|
+
it 'converts underscored_string to CamelCase' do
|
15
|
+
expect('camel_case_test'.camelize).to eq('CamelCaseTest')
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
require 'unbind'
|
2
|
+
require 'tempfile'
|
3
|
+
require 'spec_helper'
|
4
|
+
|
5
|
+
describe Unbind do
|
6
|
+
describe ".load_string" do
|
7
|
+
it "rejects non-hashes" do
|
8
|
+
expect { described_class.send(:load_string, nil) }.to raise_error
|
9
|
+
expect { described_class.send(:load_string, "") }.to raise_error
|
10
|
+
expect { described_class.send(:load_string, "---\n") }.to raise_error
|
11
|
+
expect { described_class.send(:load_string, "[]") }.to raise_error
|
12
|
+
end
|
13
|
+
|
14
|
+
it "accepts hashes" do
|
15
|
+
expect(described_class.send(:load_string, "{}")).to be_true
|
16
|
+
expect(described_class.send(:load_string, "--- {}\n")).to be_true
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
describe ".load_config" do
|
21
|
+
let(:tmp_conf) { Tempfile.new(described_class.to_s) }
|
22
|
+
|
23
|
+
context "with valid file" do
|
24
|
+
before { tmp_conf.write("{}"); tmp_conf.close }
|
25
|
+
after { tmp_conf.unlink }
|
26
|
+
|
27
|
+
it "reads config if config file exists" do
|
28
|
+
expect(described_class.load_config(tmp_conf.path)).to be_true
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
context "without file" do
|
33
|
+
it "raises meaningful error if config file does not exist" do
|
34
|
+
expect { described_class.load_config(tmp_conf.path) }.to raise_error
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
context 'with config' do
|
40
|
+
include_full_config
|
41
|
+
|
42
|
+
describe "#zones" do
|
43
|
+
it 'collects and caches configured zones' do
|
44
|
+
expect((subject.send(:zones)).length).to eq(1)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,76 @@
|
|
1
|
+
require 'unbind/view'
|
2
|
+
require 'spec_helper'
|
3
|
+
|
4
|
+
describe Unbind::View do
|
5
|
+
describe ".expand_countries" do
|
6
|
+
it "expands countries list" do
|
7
|
+
expect(described_class.expand_countries([{"countries" => %w(ua uk us)}])).to match_array([
|
8
|
+
'country_UA', 'country_UK', 'country_US'
|
9
|
+
])
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
describe "#new" do
|
14
|
+
let(:valid_name) { 'a' }
|
15
|
+
let(:valid_data) { {clients: [], zones: []} }
|
16
|
+
|
17
|
+
it "requires valid name" do
|
18
|
+
expect { described_class.new(nil, valid_data) }.to raise_error
|
19
|
+
expect { described_class.new('', valid_data) }.to raise_error
|
20
|
+
end
|
21
|
+
|
22
|
+
it "requires valid data" do
|
23
|
+
expect { described_class.new(valid_name, {clients: nil, zones: []}) }.to raise_error
|
24
|
+
expect { described_class.new(valid_name, {clients: [], zones: nil}) }.to raise_error
|
25
|
+
end
|
26
|
+
|
27
|
+
it "requires both valid name and valid data" do
|
28
|
+
expect { described_class.new() }.to raise_error
|
29
|
+
expect { described_class.new(nil, nil) }.to raise_error
|
30
|
+
expect { described_class.new(valid_name, valid_data) }.not_to raise_error
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
context "generators" do
|
35
|
+
include_view_definition
|
36
|
+
|
37
|
+
describe "#match_clients" do
|
38
|
+
it "generates match-clients block" do
|
39
|
+
expect(view.send :match_clients).to eq("match-clients { key test; !tsig_keys; country_UA; country_UK; country_US; };")
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
describe "#servers" do
|
44
|
+
it "generates servers list" do
|
45
|
+
expect(view.send :servers).to match_array([
|
46
|
+
"server 10.0.0.2 { keys test; };",
|
47
|
+
"server 10.0.0.3 { keys test; };",
|
48
|
+
])
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
describe "#view_settings" do
|
53
|
+
it "master should allow transfer with the key and notify" do
|
54
|
+
expect(view.send :view_settings, :master).to eq("allow-transfer { keys test; };\nnotify yes;")
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
describe "#zones" do
|
59
|
+
it "generates zones list for a slave" do
|
60
|
+
expect(view.send :zones, :slave).to match_array([
|
61
|
+
"zone \"zone.ua\" { type slave; masters { 10.0.0.1 key test; }; };",
|
62
|
+
"zone \"zone.uk\" { type slave; masters { 10.0.0.1 key test; }; };",
|
63
|
+
"zone \"zone.us\" { type slave; masters { 10.0.0.1 key test; }; };",
|
64
|
+
])
|
65
|
+
end
|
66
|
+
|
67
|
+
it "generates zones list for the master" do
|
68
|
+
expect(view.send :zones, :master).to match_array([
|
69
|
+
"zone \"zone.ua\" { type master; file \"pri/zone.ua/test.zone\"; };",
|
70
|
+
"zone \"zone.uk\" { type master; file \"pri/zone.ua/test.zone\"; };",
|
71
|
+
"zone \"zone.us\" { type master; file \"pri/zone.ua/test.zone\"; };",
|
72
|
+
])
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
require 'unbind/zone'
|
2
|
+
require 'spec_helper'
|
3
|
+
|
4
|
+
describe Unbind::Zone do
|
5
|
+
let(:valid_name) { 'a' }
|
6
|
+
let(:valid_data) { {data: {ns: '.', mx: '.', a: {'' => ''}}} }
|
7
|
+
|
8
|
+
describe '#new' do
|
9
|
+
it 'requires a name' do
|
10
|
+
expect { described_class.new() }.to raise_error
|
11
|
+
expect { described_class.new(nil, valid_data) }.to raise_error
|
12
|
+
expect { described_class.new('', valid_data) }.to raise_error
|
13
|
+
expect { described_class.new(valid_name, valid_data) }.not_to raise_error
|
14
|
+
end
|
15
|
+
|
16
|
+
it 'requires essentials: ns, mx, a' do
|
17
|
+
expect { described_class.new(valid_name) }.to raise_error
|
18
|
+
expect { described_class.new(valid_name, nil) }.to raise_error
|
19
|
+
expect { described_class.new(valid_name, {}) }.to raise_error
|
20
|
+
expect { described_class.new(valid_name, valid_data) }.not_to raise_error
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
context 'generators' do
|
25
|
+
include_zone_definition
|
26
|
+
|
27
|
+
describe '#header' do
|
28
|
+
it 'generates zone header' do
|
29
|
+
expect(zone.send :header).to match_array([
|
30
|
+
"$TTL #{zone.ttl}",
|
31
|
+
"@ SOA ns0 root (#{zone.version} 1d 10m 2w 10m)",
|
32
|
+
])
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
describe '#essentials' do
|
37
|
+
it 'generates zone essentials' do
|
38
|
+
expect(zone.send :essentials).to match_array([
|
39
|
+
'@ NS ns1',
|
40
|
+
'@ NS ns2',
|
41
|
+
'@ MX 1 mail',
|
42
|
+
])
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
describe '#resources' do
|
47
|
+
it 'generates zone resources' do
|
48
|
+
expect(zone.send :resources).to match_array([
|
49
|
+
'@ A 10.0.0.1',
|
50
|
+
'@ A 10.0.0.2',
|
51
|
+
'@ A 10.0.0.3',
|
52
|
+
'* A 10.0.0.1',
|
53
|
+
'* A 10.0.0.2',
|
54
|
+
'* A 10.0.0.3',
|
55
|
+
'mail A 10.0.0.1',
|
56
|
+
'mail A 10.0.0.2',
|
57
|
+
'ns1 A 10.0.0.1',
|
58
|
+
'ns2 A 10.0.0.2',
|
59
|
+
'@ TXT "txt data"',
|
60
|
+
'imap CNAME mail',
|
61
|
+
"_xmpp-client._tcp SRV 5222 0 5 ."
|
62
|
+
])
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
data/unbind.gemspec
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
lib = File.expand_path('../lib', __FILE__)
|
2
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
3
|
+
require 'unbind/version'
|
4
|
+
|
5
|
+
Gem::Specification.new do |spec|
|
6
|
+
spec.name = "unbind"
|
7
|
+
spec.version = Unbind::VERSION
|
8
|
+
spec.authors = ["Dennis Krupenik"]
|
9
|
+
spec.email = ["dennis@krupenik.com"]
|
10
|
+
spec.description = %q{ISC BINDv9 config generator}
|
11
|
+
spec.summary = %q{ISC BINDv9 config generator}
|
12
|
+
spec.homepage = "https://github.com/krupenik/unbind"
|
13
|
+
spec.license = "MIT"
|
14
|
+
|
15
|
+
spec.files = `git ls-files`.split($/)
|
16
|
+
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
17
|
+
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
18
|
+
spec.require_paths = ["lib"]
|
19
|
+
|
20
|
+
spec.add_development_dependency "rake"
|
21
|
+
spec.add_development_dependency "rspec"
|
22
|
+
end
|
metadata
ADDED
@@ -0,0 +1,104 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: unbind
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Dennis Krupenik
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2013-11-22 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: rake
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - '>='
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '0'
|
20
|
+
type: :development
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - '>='
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: rspec
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - '>='
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '0'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - '>='
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '0'
|
41
|
+
description: ISC BINDv9 config generator
|
42
|
+
email:
|
43
|
+
- dennis@krupenik.com
|
44
|
+
executables:
|
45
|
+
- unbind
|
46
|
+
extensions: []
|
47
|
+
extra_rdoc_files: []
|
48
|
+
files:
|
49
|
+
- .gitignore
|
50
|
+
- .travis.yml
|
51
|
+
- Gemfile
|
52
|
+
- Guardfile
|
53
|
+
- LICENSE
|
54
|
+
- README.md
|
55
|
+
- Rakefile
|
56
|
+
- bin/unbind
|
57
|
+
- lib/unbind.rb
|
58
|
+
- lib/unbind/core_ext/hash/keys.rb
|
59
|
+
- lib/unbind/core_ext/string/inflections.rb
|
60
|
+
- lib/unbind/version.rb
|
61
|
+
- lib/unbind/view.rb
|
62
|
+
- lib/unbind/zone.rb
|
63
|
+
- spec/integration/unbind_spec.rb
|
64
|
+
- spec/integration/view_spec.rb
|
65
|
+
- spec/integration/zone_spec.rb
|
66
|
+
- spec/spec_helper.rb
|
67
|
+
- spec/unit/core_ext_spec.rb
|
68
|
+
- spec/unit/unbind_spec.rb
|
69
|
+
- spec/unit/view_spec.rb
|
70
|
+
- spec/unit/zone_spec.rb
|
71
|
+
- unbind.gemspec
|
72
|
+
homepage: https://github.com/krupenik/unbind
|
73
|
+
licenses:
|
74
|
+
- MIT
|
75
|
+
metadata: {}
|
76
|
+
post_install_message:
|
77
|
+
rdoc_options: []
|
78
|
+
require_paths:
|
79
|
+
- lib
|
80
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
81
|
+
requirements:
|
82
|
+
- - '>='
|
83
|
+
- !ruby/object:Gem::Version
|
84
|
+
version: '0'
|
85
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - '>='
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '0'
|
90
|
+
requirements: []
|
91
|
+
rubyforge_project:
|
92
|
+
rubygems_version: 2.0.3
|
93
|
+
signing_key:
|
94
|
+
specification_version: 4
|
95
|
+
summary: ISC BINDv9 config generator
|
96
|
+
test_files:
|
97
|
+
- spec/integration/unbind_spec.rb
|
98
|
+
- spec/integration/view_spec.rb
|
99
|
+
- spec/integration/zone_spec.rb
|
100
|
+
- spec/spec_helper.rb
|
101
|
+
- spec/unit/core_ext_spec.rb
|
102
|
+
- spec/unit/unbind_spec.rb
|
103
|
+
- spec/unit/view_spec.rb
|
104
|
+
- spec/unit/zone_spec.rb
|