unbind 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- 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
|
+
[![Code Climate](https://codeclimate.com/github/krupenik/unbind.png)](https://codeclimate.com/github/krupenik/unbind)
|
4
|
+
[![Build Status](https://travis-ci.org/krupenik/unbind.png?branch=master)](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
|