ghost 0.3.0 → 1.0.0.pre
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.
- 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
|