ghost 0.3.0 → 1.0.0.pre
Sign up to get free protection for your applications and to get access to all the features.
- data/LICENSE +1 -1
- data/bin/ghost +2 -130
- data/lib/ghost.rb +3 -16
- data/lib/ghost/cli.rb +60 -0
- data/lib/ghost/cli/task.rb +71 -0
- data/lib/ghost/cli/task/add.rb +25 -0
- data/lib/ghost/cli/task/delete.rb +30 -0
- data/lib/ghost/cli/task/empty.rb +18 -0
- data/lib/ghost/cli/task/export.rb +19 -0
- data/lib/ghost/cli/task/help.rb +41 -0
- data/lib/ghost/cli/task/import.rb +25 -0
- data/lib/ghost/cli/task/list.rb +40 -0
- data/lib/ghost/host.rb +34 -0
- data/lib/ghost/store.rb +12 -0
- data/lib/ghost/store/dscl_store.rb +71 -0
- data/lib/ghost/store/hosts_file_store.rb +123 -0
- data/lib/ghost/tokenized_file.rb +65 -0
- data/lib/ghost/version.rb +3 -0
- data/spec/ghost/cli/task/add_spec.rb +80 -0
- data/spec/ghost/cli/task/delete_spec.rb +20 -0
- data/spec/ghost/cli/task/empty_spec.rb +19 -0
- data/spec/ghost/cli/task/export_spec.rb +16 -0
- data/spec/ghost/cli/task/help_spec.rb +36 -0
- data/spec/ghost/cli/task/import_spec.rb +56 -0
- data/spec/ghost/cli/task/list_spec.rb +50 -0
- data/spec/ghost/cli_spec.rb +22 -0
- data/spec/ghost/host_spec.rb +36 -0
- data/spec/ghost/store/dscl_store_spec.rb +153 -0
- data/spec/ghost/store/hosts_file_store_spec.rb +316 -0
- data/spec/ghost/store_spec.rb +2 -0
- data/spec/ghost/tokenized_file_spec.rb +131 -0
- data/spec/spec_helper.rb +4 -2
- data/spec/support/cli.rb +29 -0
- data/spec/support/resolv.rb +15 -0
- metadata +91 -27
- data/Rakefile +0 -28
- data/TODO +0 -0
- data/bin/ghost-ssh +0 -132
- data/lib/ghost/linux-host.rb +0 -158
- data/lib/ghost/mac-host.rb +0 -116
- data/lib/ghost/ssh_config.rb +0 -110
- data/spec/etc_hosts_spec.rb +0 -190
- data/spec/ghost_spec.rb +0 -151
- data/spec/spec.opts +0 -1
- data/spec/ssh_config_spec.rb +0 -80
- data/spec/ssh_config_template +0 -11
@@ -0,0 +1,40 @@
|
|
1
|
+
Ghost::Cli.task :list do
|
2
|
+
desc "Show all (or a filtered) list of hosts"
|
3
|
+
def perform(filter = nil)
|
4
|
+
hosts = get_hosts(filter)
|
5
|
+
|
6
|
+
puts "Listing #{hosts.size} host(s):"
|
7
|
+
|
8
|
+
pad = hosts.map {|h| h.name.length }.max
|
9
|
+
hosts.each do |host|
|
10
|
+
puts "#{host.name.rjust(pad + 2)} -> #{host.ip}"
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
help do
|
15
|
+
<<-EOF.unindent
|
16
|
+
Usage: ghost list [<regex>]
|
17
|
+
|
18
|
+
#{desc}.
|
19
|
+
|
20
|
+
If no regular expression is provided, a summary of all hosts is
|
21
|
+
shown. If a regular expression is provided, only hosts whose
|
22
|
+
host names match the regular expression are listed.
|
23
|
+
|
24
|
+
Examples:
|
25
|
+
ghost list # will list every host name
|
26
|
+
ghost list /^fo+\\.com$/ # will list fo.com, fooooo.com, etc.
|
27
|
+
EOF
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
def get_hosts(filter)
|
33
|
+
hosts = if filter
|
34
|
+
filter = $1 if filter =~ %r|^/(.*)/$|
|
35
|
+
Ghost.store.find(/#{filter}/i)
|
36
|
+
else
|
37
|
+
Ghost.store.all
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
data/lib/ghost/host.rb
ADDED
@@ -0,0 +1,34 @@
|
|
1
|
+
require 'socket'
|
2
|
+
|
3
|
+
module Ghost
|
4
|
+
class Host < Struct.new(:name, :ip)
|
5
|
+
class NotResolvable < Exception; end
|
6
|
+
|
7
|
+
alias :to_s :name
|
8
|
+
alias :host :name
|
9
|
+
alias :hostname :name
|
10
|
+
alias :ip_address :ip
|
11
|
+
|
12
|
+
def initialize(host, ip = "127.0.0.1")
|
13
|
+
super(host, resolve_ip(ip))
|
14
|
+
end
|
15
|
+
|
16
|
+
def <=>(host)
|
17
|
+
if ip == host.ip
|
18
|
+
name <=> host.name
|
19
|
+
else
|
20
|
+
ip <=> host.ip
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def match(name)
|
25
|
+
host.match(name)
|
26
|
+
end
|
27
|
+
|
28
|
+
def resolve_ip(ip_or_hostname)
|
29
|
+
IPSocket.getaddress(ip_or_hostname)
|
30
|
+
rescue SocketError
|
31
|
+
raise Ghost::Host::NotResolvable, "#{ip_or_hostname} is not resolvable."
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
data/lib/ghost/store.rb
ADDED
@@ -0,0 +1,12 @@
|
|
1
|
+
module Ghost
|
2
|
+
class << self
|
3
|
+
attr_accessor :store
|
4
|
+
end
|
5
|
+
end
|
6
|
+
|
7
|
+
|
8
|
+
require 'ghost/store/hosts_file_store'
|
9
|
+
Ghost.store = Ghost::Store::HostsFileStore.new
|
10
|
+
|
11
|
+
# TODO: only load on OS X and make it default when compatible
|
12
|
+
require 'ghost/store/dscl_store'
|
@@ -0,0 +1,71 @@
|
|
1
|
+
require 'ghost/host'
|
2
|
+
require 'set'
|
3
|
+
|
4
|
+
module Ghost
|
5
|
+
module Store
|
6
|
+
class DsclStore
|
7
|
+
class Dscl
|
8
|
+
class << self
|
9
|
+
def list(domain)
|
10
|
+
`dscl % -readall /Local/Default/Hosts 2>&1` % domain
|
11
|
+
end
|
12
|
+
|
13
|
+
# TODO is shell injection a concern here?
|
14
|
+
def read(domain, host)
|
15
|
+
`dscl % -read /Local/Default/Hosts/%s 2>&1` % [domain, host]
|
16
|
+
end
|
17
|
+
|
18
|
+
def create(domain, host, ip)
|
19
|
+
`dscl % -create /Local/Default/Hosts/%s IPAddress %s 2>&1` % [domain, host, ip]
|
20
|
+
end
|
21
|
+
|
22
|
+
def delete(domain, host)
|
23
|
+
`dscl % -delete /Local/Default/Hosts/%s 2>&1` % [domain, host]
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
attr_accessor :domain
|
29
|
+
|
30
|
+
def initialize(domain = "localhost")
|
31
|
+
self.domain = domain
|
32
|
+
end
|
33
|
+
|
34
|
+
def add(host)
|
35
|
+
Dscl.create(domain, host.name, host.ip)
|
36
|
+
true
|
37
|
+
end
|
38
|
+
|
39
|
+
def all
|
40
|
+
Dscl.list(domain).map do |host|
|
41
|
+
name = host.scan(/^RecordName: (.+)$/).flatten.first
|
42
|
+
ip = host.scan(/^IPAddress: (.+)$/).flatten.first
|
43
|
+
|
44
|
+
Ghost::Host.new(name, ip)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
def find(regex)
|
49
|
+
all.select { |h| h.name =~ regex }
|
50
|
+
end
|
51
|
+
|
52
|
+
def delete(host)
|
53
|
+
result = SortedSet.new
|
54
|
+
|
55
|
+
all.each do |existing_host|
|
56
|
+
next unless host.match(existing_host.name)
|
57
|
+
next if host.respond_to?(:ip) && host.ip != existing_host.ip
|
58
|
+
|
59
|
+
Dscl.delete(domain, existing_host.name)
|
60
|
+
result << existing_host
|
61
|
+
end
|
62
|
+
|
63
|
+
result.to_a
|
64
|
+
end
|
65
|
+
|
66
|
+
def empty
|
67
|
+
all.each { |host| Dscl.delete(domain, host.name) }
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
@@ -0,0 +1,123 @@
|
|
1
|
+
require 'set'
|
2
|
+
|
3
|
+
require 'ghost/host'
|
4
|
+
require 'ghost/tokenized_file'
|
5
|
+
|
6
|
+
module Ghost
|
7
|
+
module Store
|
8
|
+
# TODO: A lot of this duplicates Resolv::Hosts in Ruby stdlib.
|
9
|
+
# Can that be modifiied to use tokens in place of this?
|
10
|
+
class HostsFileStore
|
11
|
+
attr_accessor :path, :file
|
12
|
+
|
13
|
+
# TODO: Support windows locations:
|
14
|
+
# Windows 95/98/Me c:\windows\hosts
|
15
|
+
# Windows NT/2000/XP Pro c:\winnt\system32\drivers\etc\hosts
|
16
|
+
# Windows XP Home c:\windows\system32\drivers\etc\hosts
|
17
|
+
def initialize(path = "/etc/hosts")
|
18
|
+
self.path = path
|
19
|
+
self.file = Ghost::TokenizedFile.new(path, "# ghost start", "# ghost end")
|
20
|
+
end
|
21
|
+
|
22
|
+
def add(host)
|
23
|
+
sync do |buffer|
|
24
|
+
buffer[host.ip] << host.name
|
25
|
+
buffer_changed!
|
26
|
+
end
|
27
|
+
|
28
|
+
true
|
29
|
+
end
|
30
|
+
|
31
|
+
def all
|
32
|
+
sync do |buffer|
|
33
|
+
buffer.map do |ip, hosts|
|
34
|
+
hosts.map { |h| Ghost::Host.new(h, ip) }
|
35
|
+
end.flatten.sort
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def find(filter)
|
40
|
+
all.select { |host| host.name =~ filter }
|
41
|
+
end
|
42
|
+
|
43
|
+
def delete(host)
|
44
|
+
result = SortedSet.new
|
45
|
+
sync do |buffer|
|
46
|
+
buffer.each do |ip, names|
|
47
|
+
names.dup.each do |name|
|
48
|
+
next unless host.match(name)
|
49
|
+
next if host.respond_to?(:ip) && host.ip != ip
|
50
|
+
|
51
|
+
result << Ghost::Host.new(name, ip)
|
52
|
+
names.delete(name)
|
53
|
+
buffer_changed!
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
result.to_a
|
58
|
+
end
|
59
|
+
|
60
|
+
def empty
|
61
|
+
result = false
|
62
|
+
sync do |buffer|
|
63
|
+
unless buffer.empty?
|
64
|
+
result = true
|
65
|
+
buffer.replace({})
|
66
|
+
buffer_changed!
|
67
|
+
end
|
68
|
+
end
|
69
|
+
result
|
70
|
+
end
|
71
|
+
|
72
|
+
private # TODO: Add buffer management code to new class
|
73
|
+
|
74
|
+
def buffer_changed?
|
75
|
+
@buffer_changed
|
76
|
+
end
|
77
|
+
|
78
|
+
def buffer_changed!
|
79
|
+
@buffer_changed = true
|
80
|
+
end
|
81
|
+
|
82
|
+
def with_buffer
|
83
|
+
@buffer_changed = false
|
84
|
+
yield(Hash.new { |hash, key| hash[key] = SortedSet.new })
|
85
|
+
end
|
86
|
+
|
87
|
+
def parse_into_buffer(lines, buffer)
|
88
|
+
lines.split($/).each do |line|
|
89
|
+
ip, hosts = *line.scan(/^\s*([^\s]+)\s+([^#]*)/).first
|
90
|
+
|
91
|
+
return unless ip and hosts
|
92
|
+
|
93
|
+
hosts.split(/\s+/).each do |host|
|
94
|
+
buffer[ip] << host
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
def sync
|
100
|
+
result = nil
|
101
|
+
|
102
|
+
with_buffer do |buffer|
|
103
|
+
parse_into_buffer(file.read, buffer)
|
104
|
+
result = yield(buffer)
|
105
|
+
file.write(content(buffer)) if buffer_changed?
|
106
|
+
end
|
107
|
+
|
108
|
+
result
|
109
|
+
end
|
110
|
+
|
111
|
+
def content(buffer)
|
112
|
+
ips = buffer.keys.sort
|
113
|
+
lines = ips.map do |ip|
|
114
|
+
unless (hosts = buffer[ip]).empty?
|
115
|
+
"#{ip} #{buffer[ip].to_a.join(" ")}"
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
lines.compact.join($/)
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
module Ghost
|
2
|
+
# TODO: make it not necessarily line-based tokenization
|
3
|
+
# TODO: make it delegate or inherit from File/IO/StringIO to allow it to be a
|
4
|
+
# drop-in to things expecting an IO.
|
5
|
+
# - Allow consumer to manipulate a real IO, and sync the contents
|
6
|
+
# into the real file between the tokens?
|
7
|
+
# TODO: make this it's own gem/library. This has nothing to do (specifically)
|
8
|
+
# with hosts file management
|
9
|
+
class TokenizedFile
|
10
|
+
attr_accessor :path, :start_token, :end_token
|
11
|
+
|
12
|
+
def initialize(path, start_token, end_token)
|
13
|
+
self.path = path
|
14
|
+
self.start_token = start_token
|
15
|
+
self.end_token = end_token
|
16
|
+
end
|
17
|
+
|
18
|
+
def read
|
19
|
+
read_capturing
|
20
|
+
end
|
21
|
+
|
22
|
+
def write(content)
|
23
|
+
existing_lines = []
|
24
|
+
|
25
|
+
# TODO: how to do this without closing the file so
|
26
|
+
# we can reopen with write mode and maintain a
|
27
|
+
# lock
|
28
|
+
read_capturing { |line| existing_lines << line}
|
29
|
+
|
30
|
+
# TODO: lock file
|
31
|
+
File.open(path, 'w') do |file|
|
32
|
+
file.puts(existing_lines)
|
33
|
+
|
34
|
+
unless content.nil? || content.empty?
|
35
|
+
file.puts(start_token)
|
36
|
+
file.puts(content)
|
37
|
+
file.puts(end_token)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
private
|
43
|
+
|
44
|
+
def read_capturing
|
45
|
+
between_tokens = false
|
46
|
+
lines = []
|
47
|
+
|
48
|
+
File.open(path, 'r') do |file|
|
49
|
+
file.each_line do |line|
|
50
|
+
if line =~ /^#{start_token}\s*$/
|
51
|
+
between_tokens = true
|
52
|
+
elsif line =~ /^#{end_token}\s*$/
|
53
|
+
between_tokens = false
|
54
|
+
elsif between_tokens
|
55
|
+
lines << line
|
56
|
+
else
|
57
|
+
yield(line) if block_given?
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
lines.join
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
@@ -0,0 +1,80 @@
|
|
1
|
+
require File.expand_path("#{File.dirname(__FILE__)}/../../../spec_helper.rb")
|
2
|
+
require 'ghost/cli'
|
3
|
+
|
4
|
+
describe Ghost::Cli, :type => :cli do
|
5
|
+
# FIXME: these are petty silly tests to have. Find a nice way to check
|
6
|
+
# that documentation is accessible and printable for all meaninful tests
|
7
|
+
# but don't test actual contents...
|
8
|
+
describe "help add" do
|
9
|
+
specify do
|
10
|
+
ghost("help add").should == <<-EOF.unindent
|
11
|
+
Usage: ghost add <local host name> [<remote host name>|<IP address>]
|
12
|
+
|
13
|
+
Add a host.
|
14
|
+
|
15
|
+
If a second parameter is not provided, it defaults to 127.0.0.1
|
16
|
+
|
17
|
+
Examples:
|
18
|
+
ghost add my-localhost # points to 127.0.0.1
|
19
|
+
ghost add google.dev google.com # points to the IP of google.com
|
20
|
+
ghost add router 192.168.1.1 # points to 192.168.1.1
|
21
|
+
EOF
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
describe "add" do
|
26
|
+
context "with a local hostname" do
|
27
|
+
let(:host) { Ghost::Host.new("my-app.local") }
|
28
|
+
|
29
|
+
it "adds the host pointing to 127.0.0.1" do
|
30
|
+
ghost("add my-app.local")
|
31
|
+
store.all.should include(Ghost::Host.new("my-app.local", "127.0.0.1"))
|
32
|
+
end
|
33
|
+
|
34
|
+
it "outputs a summary of the operation" do
|
35
|
+
ghost("add my-app.local").should == "[Adding] my-app.local -> 127.0.0.1\n"
|
36
|
+
end
|
37
|
+
|
38
|
+
context "when an entry for that hostname already exists" do
|
39
|
+
it "outputs an error message"
|
40
|
+
end
|
41
|
+
|
42
|
+
context "and an IP address" do
|
43
|
+
let(:host) { Ghost::Host.new("my-app.local", "192.168.1.1") }
|
44
|
+
|
45
|
+
it "adds the host pointing to the IP address" do
|
46
|
+
ghost("add my-app.local 192.168.1.1")
|
47
|
+
store.all.should include(Ghost::Host.new("my-app.local", "192.168.1.1"))
|
48
|
+
end
|
49
|
+
|
50
|
+
it "outputs a summary of the operation" do
|
51
|
+
ghost("add my-app.local 192.168.1.1").should == "[Adding] my-app.local -> 192.168.1.1\n"
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
context "and a remote hostname" do
|
56
|
+
# TODO: replace this stub once DNS resolution works
|
57
|
+
let(:host) { Ghost::Host.new("my-app.local", "google.com") }
|
58
|
+
before { Ghost::Host.any_instance.stub(:resolve_ip => "74.125.225.99") }
|
59
|
+
|
60
|
+
it "adds the host pointing to the IP address" do
|
61
|
+
ghost("add my-app.local google.com")
|
62
|
+
store.all.should include(host)
|
63
|
+
end
|
64
|
+
|
65
|
+
it "outputs a summary of the operation" do
|
66
|
+
ghost("add my-app.local google.com").should ==
|
67
|
+
"[Adding] my-app.local -> 74.125.225.99\n"
|
68
|
+
end
|
69
|
+
|
70
|
+
context "when the remote hostname can not be resolved" do
|
71
|
+
before { Ghost::Host.stub(:new).and_raise(Ghost::Host::NotResolvable) }
|
72
|
+
|
73
|
+
it "outputs an error message" do
|
74
|
+
ghost("add my-app.local google.com").should == "Unable to resolve IP address for target host \"google.com\".\n"
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
require File.expand_path("#{File.dirname(__FILE__)}/../../../spec_helper.rb")
|
2
|
+
require 'ghost/cli'
|
3
|
+
|
4
|
+
describe Ghost::Cli, :type => :cli do
|
5
|
+
describe "delete" do
|
6
|
+
context 'with filtering pattern' do
|
7
|
+
it 'deletes only entries whose hostname matches the pattern' do
|
8
|
+
store.should_receive(:delete).with(/fo*.com?/i)
|
9
|
+
ghost("delete /fo*.com?/")
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
context 'without filtering pattern' do
|
14
|
+
it 'deletes the specified hostname' do
|
15
|
+
store.should_receive(:delete).with('foo.com')
|
16
|
+
ghost("delete foo.com")
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|