canals 0.8.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +38 -0
- data/.rvmrc +1 -0
- data/Gemfile +8 -0
- data/LICENSE +21 -0
- data/README.md +11 -0
- data/Rakefile +3 -0
- data/assets/canals.sh +53 -0
- data/bin/canal +14 -0
- data/canals.gemspec +27 -0
- data/lib/canals/cli/environment.rb +44 -0
- data/lib/canals/cli/helpers.rb +38 -0
- data/lib/canals/cli/list.rb +43 -0
- data/lib/canals/cli/session.rb +75 -0
- data/lib/canals/cli/setup.rb +98 -0
- data/lib/canals/cli.rb +90 -0
- data/lib/canals/config.rb +37 -0
- data/lib/canals/core.rb +69 -0
- data/lib/canals/core_ext/shell_colors.rb +15 -0
- data/lib/canals/core_ext/string.rb +6 -0
- data/lib/canals/environment.rb +40 -0
- data/lib/canals/options.rb +75 -0
- data/lib/canals/repository.rb +87 -0
- data/lib/canals/session.rb +64 -0
- data/lib/canals/tools/assets.rb +21 -0
- data/lib/canals/tools/completion.rb +47 -0
- data/lib/canals/version.rb +6 -0
- data/lib/canals.rb +39 -0
- data/spec/canals/environment_spec.rb +77 -0
- data/spec/canals/options_spec.rb +203 -0
- data/spec/spec_helper.rb +4 -0
- metadata +119 -0
data/lib/canals/core.rb
ADDED
@@ -0,0 +1,69 @@
|
|
1
|
+
require 'open3'
|
2
|
+
|
3
|
+
module Canals
|
4
|
+
class << self
|
5
|
+
|
6
|
+
def create_tunnel(tunnel_opts)
|
7
|
+
Canals.repository.add tunnel_opts
|
8
|
+
end
|
9
|
+
|
10
|
+
def start(tunnel_opts)
|
11
|
+
if tunnel_opts.instance_of? String
|
12
|
+
tunnel_opts = Canals.repository.get(tunnel_opts)
|
13
|
+
end
|
14
|
+
tunnel_start(tunnel_opts)
|
15
|
+
pid = tunnel_pid(tunnel_opts)
|
16
|
+
Canals.session.add({name: tunnel_opts.name, pid: pid, socket: socket_file(tunnel_opts)})
|
17
|
+
pid.to_i
|
18
|
+
end
|
19
|
+
|
20
|
+
def stop(tunnel_opts)
|
21
|
+
if tunnel_opts.instance_of? String
|
22
|
+
tunnel_opts = Canals.repository.get(tunnel_opts)
|
23
|
+
end
|
24
|
+
tunnel_close(tunnel_opts)
|
25
|
+
Canals.session.del(tunnel_opts.name)
|
26
|
+
end
|
27
|
+
|
28
|
+
def restart(tunnel_opts)
|
29
|
+
stop(tunnel_opts)
|
30
|
+
start(tunnel_opts)
|
31
|
+
end
|
32
|
+
|
33
|
+
def isalive?(tunnel_opts)
|
34
|
+
if tunnel_opts.instance_of? String
|
35
|
+
tunnel_opts = Canals.repository.get(tunnel_opts)
|
36
|
+
end
|
37
|
+
!!tunnel_pid(tunnel_opts)
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
|
42
|
+
def socket_file(tunnel_opts)
|
43
|
+
"/tmp/canals/canal.#{tunnel_opts.name}.sock"
|
44
|
+
end
|
45
|
+
|
46
|
+
def tunnel_start(tunnel_opts)
|
47
|
+
FileUtils.mkdir_p("/tmp/canals")
|
48
|
+
cmd = "ssh -M -S #{socket_file(tunnel_opts)} -fnNT -L #{tunnel_opts.bind_address}:#{tunnel_opts.local_port}:#{tunnel_opts.remote_host}:#{tunnel_opts.remote_port} #{tunnel_opts.proxy}"
|
49
|
+
system(cmd)
|
50
|
+
end
|
51
|
+
|
52
|
+
def tunnel_check(tunnel_opts)
|
53
|
+
cmd = "ssh -S #{socket_file(tunnel_opts)} -O check #{tunnel_opts.proxy}"
|
54
|
+
Open3.capture2e(cmd)
|
55
|
+
end
|
56
|
+
|
57
|
+
def tunnel_close(tunnel_opts)
|
58
|
+
cmd = "ssh -S #{socket_file(tunnel_opts)} -O exit #{tunnel_opts.proxy}"
|
59
|
+
Open3.capture2e(cmd)
|
60
|
+
end
|
61
|
+
|
62
|
+
def tunnel_pid(tunnel_opts)
|
63
|
+
stdout, _ = tunnel_check(tunnel_opts)
|
64
|
+
m = /\(pid=(.*)\)/.match(stdout)
|
65
|
+
m[1].to_i if m
|
66
|
+
end
|
67
|
+
|
68
|
+
end
|
69
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
require 'thor'
|
2
|
+
|
3
|
+
class Thor
|
4
|
+
module Shell
|
5
|
+
class Color < Basic
|
6
|
+
# The start of an ANSI dim sequence.
|
7
|
+
DIM = "\e[2m"
|
8
|
+
# The start of an ANSI underline sequence.
|
9
|
+
UNDERLINE = "\e[4m"
|
10
|
+
|
11
|
+
# Set the terminal's foreground ANSI color to dark gray.
|
12
|
+
DARK_GRAY = "\e[90m"
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
require 'psych'
|
2
|
+
|
3
|
+
module Canals
|
4
|
+
class CanalEnvironmentError < StandardError; end
|
5
|
+
|
6
|
+
class Environment
|
7
|
+
attr_reader :name, :user, :hostname, :pem
|
8
|
+
def initialize(args)
|
9
|
+
@args = validate?(args)
|
10
|
+
@name = @args["name"]
|
11
|
+
@user = @args["user"]
|
12
|
+
@hostname = @args["hostname"]
|
13
|
+
@pem = @args["pem"]
|
14
|
+
end
|
15
|
+
|
16
|
+
def validate?(args)
|
17
|
+
vargs = args.dup
|
18
|
+
raise CanalEnvironmentError.new("Missing option: \"name\" in environment creation") if args["name"].nil?
|
19
|
+
vargs
|
20
|
+
end
|
21
|
+
|
22
|
+
def default=(val)
|
23
|
+
@args["default"] = !!val
|
24
|
+
end
|
25
|
+
|
26
|
+
def is_default?
|
27
|
+
!!@args["default"]
|
28
|
+
end
|
29
|
+
|
30
|
+
def to_yaml
|
31
|
+
Psych.dump(@args)
|
32
|
+
end
|
33
|
+
|
34
|
+
def to_hash
|
35
|
+
@args.dup
|
36
|
+
end
|
37
|
+
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
@@ -0,0 +1,75 @@
|
|
1
|
+
require 'psych'
|
2
|
+
|
3
|
+
module Canals
|
4
|
+
class CanalOptionError < StandardError; end
|
5
|
+
|
6
|
+
class CanalOptions
|
7
|
+
BIND_ADDRESS = "127.0.0.1"
|
8
|
+
attr_reader :name, :remote_host, :remote_port, :local_port, :env_name, :env
|
9
|
+
|
10
|
+
def initialize(args)
|
11
|
+
@args = validate?(args)
|
12
|
+
@name = @args["name"]
|
13
|
+
@remote_host = @args["remote_host"]
|
14
|
+
@remote_port = @args["remote_port"]
|
15
|
+
@local_port = @args["local_port"]
|
16
|
+
@env_name = @args['env']
|
17
|
+
@env = Canals.repository.environment(@env_name)
|
18
|
+
end
|
19
|
+
|
20
|
+
def bind_address
|
21
|
+
return @args["bind_address"] if @args["bind_address"]
|
22
|
+
return BIND_ADDRESS
|
23
|
+
end
|
24
|
+
|
25
|
+
def hostname
|
26
|
+
get_env_var("hostname")
|
27
|
+
end
|
28
|
+
|
29
|
+
def user
|
30
|
+
get_env_var("user")
|
31
|
+
end
|
32
|
+
|
33
|
+
def pem
|
34
|
+
get_env_var("pem")
|
35
|
+
end
|
36
|
+
|
37
|
+
def proxy
|
38
|
+
prxy = ""
|
39
|
+
prxy += "-i #{pem} " if pem
|
40
|
+
prxy += "#{user}@#{hostname}"
|
41
|
+
prxy
|
42
|
+
end
|
43
|
+
|
44
|
+
def to_yaml
|
45
|
+
Psych.dump(@args)
|
46
|
+
end
|
47
|
+
|
48
|
+
def to_hash
|
49
|
+
@args.dup
|
50
|
+
end
|
51
|
+
|
52
|
+
private
|
53
|
+
|
54
|
+
def validate?(args)
|
55
|
+
vargs = args.dup
|
56
|
+
raise CanalOptionError.new("Missing option: \"name\" in canal creation") if args["name"].nil?
|
57
|
+
raise CanalOptionError.new("Missing option: \"remote_host\" in canal creation") if args["remote_host"].nil?
|
58
|
+
raise CanalOptionError.new("Missing option: \"remote_port\" in canal creation") if args["remote_port"].nil?
|
59
|
+
vargs["remote_port"] = vargs["remote_port"].to_i
|
60
|
+
if vargs["local_port"].nil?
|
61
|
+
vargs["local_port"] = vargs["remote_port"]
|
62
|
+
else
|
63
|
+
vargs["local_port"] = vargs["local_port"].to_i
|
64
|
+
end
|
65
|
+
vargs
|
66
|
+
end
|
67
|
+
|
68
|
+
def get_env_var(var)
|
69
|
+
return @args[var] if @args[var]
|
70
|
+
return @env.send var.to_sym if @env
|
71
|
+
nil
|
72
|
+
end
|
73
|
+
|
74
|
+
end
|
75
|
+
end
|
@@ -0,0 +1,87 @@
|
|
1
|
+
require 'psych'
|
2
|
+
require 'pathname'
|
3
|
+
require 'forwardable'
|
4
|
+
require 'canals/environment'
|
5
|
+
|
6
|
+
module Canals
|
7
|
+
class Repository
|
8
|
+
include Enumerable
|
9
|
+
extend Forwardable
|
10
|
+
|
11
|
+
ENVIRONMENTS = :environments
|
12
|
+
TUNNELS = :tunnels
|
13
|
+
|
14
|
+
def initialize(root = nil)
|
15
|
+
@root = root
|
16
|
+
@repo = load_repository(repo_file)
|
17
|
+
end
|
18
|
+
|
19
|
+
def_delegator :@repo, :[]
|
20
|
+
|
21
|
+
def each(&block)
|
22
|
+
@repo[TUNNELS].map{ |n, r| Canals::CanalOptions.new(r) }.each(&block)
|
23
|
+
end
|
24
|
+
|
25
|
+
def empty?
|
26
|
+
@repo[TUNNELS].empty?
|
27
|
+
end
|
28
|
+
|
29
|
+
def add(options, save=true)
|
30
|
+
@repo[TUNNELS][options.name] = options.to_hash
|
31
|
+
if options.env_name.nil? && !options.env.nil? && options.env.is_default?
|
32
|
+
@repo[TUNNELS][options.name]["env"] = options.env.name
|
33
|
+
end
|
34
|
+
save! if save
|
35
|
+
end
|
36
|
+
|
37
|
+
def get(name)
|
38
|
+
CanalOptions.new(@repo[:tunnels][name])
|
39
|
+
end
|
40
|
+
|
41
|
+
def add_environment(environment, save=true)
|
42
|
+
if environment.is_default?
|
43
|
+
@repo[ENVIRONMENTS].each { |name, env| env.delete("default") }
|
44
|
+
end
|
45
|
+
if @repo[ENVIRONMENTS].empty?
|
46
|
+
environment.default = true
|
47
|
+
end
|
48
|
+
@repo[ENVIRONMENTS][environment.name] = environment.to_hash
|
49
|
+
save! if save
|
50
|
+
end
|
51
|
+
|
52
|
+
def save!
|
53
|
+
FileUtils.mkdir_p(repo_file.dirname)
|
54
|
+
File.open(repo_file, 'w') do |file|
|
55
|
+
file.write(Psych.dump(@repo))
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def environment(name=nil)
|
60
|
+
if name.nil?
|
61
|
+
args = @repo[ENVIRONMENTS].select{ |n,e| e["default"] }.values[0]
|
62
|
+
else
|
63
|
+
args = @repo[ENVIRONMENTS][name]
|
64
|
+
end
|
65
|
+
Canals::Environment.new(args) if !args.nil?
|
66
|
+
end
|
67
|
+
|
68
|
+
def environments
|
69
|
+
@repo[ENVIRONMENTS].map { |n, e| Canals::Environment.new(e) }
|
70
|
+
end
|
71
|
+
|
72
|
+
private
|
73
|
+
|
74
|
+
def repo_file
|
75
|
+
file = File.join(Dir.home, '.canals/repository')
|
76
|
+
Pathname.new(file)
|
77
|
+
end
|
78
|
+
|
79
|
+
def load_repository(repository_file)
|
80
|
+
valid_file = repository_file && repository_file.exist? && !repository_file.size.zero?
|
81
|
+
return { ENVIRONMENTS => {}, TUNNELS => {} } if !valid_file
|
82
|
+
return Psych.load_file(repository_file)
|
83
|
+
end
|
84
|
+
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
@@ -0,0 +1,64 @@
|
|
1
|
+
require 'psych'
|
2
|
+
require 'pathname'
|
3
|
+
require 'forwardable'
|
4
|
+
|
5
|
+
module Canals
|
6
|
+
class Session
|
7
|
+
include Enumerable
|
8
|
+
extend Forwardable
|
9
|
+
|
10
|
+
def initialize
|
11
|
+
@session = load_session(session_file)
|
12
|
+
end
|
13
|
+
|
14
|
+
def_delegator :@session, :[]
|
15
|
+
|
16
|
+
def each(&block)
|
17
|
+
@session.each(&block)
|
18
|
+
end
|
19
|
+
|
20
|
+
def empty?
|
21
|
+
@session.empty?
|
22
|
+
end
|
23
|
+
|
24
|
+
def add(session, save=true)
|
25
|
+
@session.push(session)
|
26
|
+
save! if save
|
27
|
+
end
|
28
|
+
|
29
|
+
def del(name, save=true)
|
30
|
+
@session.delete_if{ |s| s[:name] == name }
|
31
|
+
save! if save
|
32
|
+
end
|
33
|
+
|
34
|
+
def get(session_id)
|
35
|
+
case session_id
|
36
|
+
when Numeric
|
37
|
+
@session.find{ |s| s[:pid] == session_id }
|
38
|
+
when String
|
39
|
+
@session.find{ |s| s[:name] == session_id }
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def save!
|
44
|
+
FileUtils.mkdir_p(session_file.dirname)
|
45
|
+
File.open(session_file, 'w') do |file|
|
46
|
+
file.write(Psych.dump(@session))
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
private
|
51
|
+
|
52
|
+
def session_file
|
53
|
+
file = File.join(Dir.home, '.canals/session')
|
54
|
+
Pathname.new(file)
|
55
|
+
end
|
56
|
+
|
57
|
+
def load_session(_session_file)
|
58
|
+
valid_file = _session_file && _session_file.exist? && !_session_file.size.zero?
|
59
|
+
return [] if !valid_file
|
60
|
+
return Psych.load_file(_session_file)
|
61
|
+
end
|
62
|
+
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
module Canals
|
2
|
+
module Tools
|
3
|
+
module Assets
|
4
|
+
BASE = File.expand_path('../../../../assets', __FILE__)
|
5
|
+
extend self
|
6
|
+
|
7
|
+
def asset_path(file)
|
8
|
+
File.expand_path(file, BASE)
|
9
|
+
end
|
10
|
+
|
11
|
+
def asset(file)
|
12
|
+
File.read(asset_path(file))
|
13
|
+
end
|
14
|
+
|
15
|
+
class << self
|
16
|
+
alias [] asset_path
|
17
|
+
alias read asset
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
require 'canals'
|
2
|
+
require 'canals/tools/assets'
|
3
|
+
|
4
|
+
module Canals
|
5
|
+
module Tools
|
6
|
+
module Completion
|
7
|
+
include FileUtils
|
8
|
+
extend self
|
9
|
+
|
10
|
+
def config_path
|
11
|
+
File.expand_path(".canals", ENV['HOME'])
|
12
|
+
end
|
13
|
+
|
14
|
+
def cmp_file
|
15
|
+
File.expand_path('canals.sh', config_path)
|
16
|
+
end
|
17
|
+
|
18
|
+
def install_completion
|
19
|
+
update_completion
|
20
|
+
source = "source " << cmp_file
|
21
|
+
|
22
|
+
rcfile = File.expand_path('.bashrc', ENV['HOME'])
|
23
|
+
return if File.read(rcfile).include? source
|
24
|
+
File.open(rcfile, 'a') { |f| f.puts("", "# added by canals gem", "[ -f #{cmp_file} ] && #{source}") }
|
25
|
+
end
|
26
|
+
|
27
|
+
def update_completion
|
28
|
+
mkdir_p(config_path)
|
29
|
+
cp(Assets['canals.sh'], cmp_file)
|
30
|
+
update_config
|
31
|
+
end
|
32
|
+
|
33
|
+
def update_config
|
34
|
+
Canals.config[:completion_version] = Canals::VERSION
|
35
|
+
Canals.config.save!
|
36
|
+
end
|
37
|
+
|
38
|
+
def completion_installed?
|
39
|
+
source = "source " << cmp_file
|
40
|
+
rcfile = File.expand_path('.bashrc', ENV['HOME'])
|
41
|
+
return false unless File.read(rcfile).include? source
|
42
|
+
true
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
data/lib/canals.rb
ADDED
@@ -0,0 +1,39 @@
|
|
1
|
+
require 'logger'
|
2
|
+
require 'canals/core'
|
3
|
+
|
4
|
+
# a gem for managing ssh tunnel connections
|
5
|
+
module Canals
|
6
|
+
extend self
|
7
|
+
|
8
|
+
autoload :Repository, "canals/repository"
|
9
|
+
autoload :Session, "canals/session"
|
10
|
+
autoload :Config, 'canals/config'
|
11
|
+
autoload :Version, 'canals/version'
|
12
|
+
|
13
|
+
|
14
|
+
attr_accessor :logger
|
15
|
+
|
16
|
+
def config
|
17
|
+
return @config if defined?(@config)
|
18
|
+
@config = Config.new(File.join(Dir.home, '.canals'))
|
19
|
+
end
|
20
|
+
|
21
|
+
def repository
|
22
|
+
return @repository if defined?(@repository)
|
23
|
+
@repository = Repository.new
|
24
|
+
end
|
25
|
+
|
26
|
+
def environments
|
27
|
+
return @repository.environments if defined?(@repository)
|
28
|
+
@repository = Repository.new
|
29
|
+
@repository.environments
|
30
|
+
end
|
31
|
+
|
32
|
+
def session
|
33
|
+
return @session if defined?(@session)
|
34
|
+
@session = Session.new
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
# default logger
|
39
|
+
Canals.logger = Logger.new(STDERR)
|
@@ -0,0 +1,77 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'canals/environment'
|
3
|
+
require 'psych'
|
4
|
+
|
5
|
+
describe Canals::Environment do
|
6
|
+
let(:name) { "blah" }
|
7
|
+
let(:hostname) { "nat.example.com" }
|
8
|
+
let(:user) { "user" }
|
9
|
+
let(:pem) { "/tmp/file.pem" }
|
10
|
+
|
11
|
+
describe "name" do
|
12
|
+
it "contains 'name'" do
|
13
|
+
args = {"name" => name}
|
14
|
+
env = Canals::Environment.new(args)
|
15
|
+
expect(env.name).to eq name
|
16
|
+
end
|
17
|
+
|
18
|
+
it "raises error when 'name' is not availble" do
|
19
|
+
args = {"hostname" => hostname}
|
20
|
+
expect{Canals::Environment.new(args)}.to raise_error(Canals::CanalEnvironmentError)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
describe "hostname" do
|
25
|
+
it "contains 'hostname'" do
|
26
|
+
args = {"name" => name, "hostname" => hostname}
|
27
|
+
env = Canals::Environment.new(args)
|
28
|
+
expect(env.hostname).to eq hostname
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
describe "user" do
|
33
|
+
it "contains 'user'" do
|
34
|
+
args = {"name" => name, "user" => user}
|
35
|
+
env = Canals::Environment.new(args)
|
36
|
+
expect(env.user).to eq user
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
describe "pem" do
|
41
|
+
it "contains 'pem'" do
|
42
|
+
args = {"name" => name, "pem" => pem}
|
43
|
+
env = Canals::Environment.new(args)
|
44
|
+
expect(env.pem).to eq pem
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
describe "default" do
|
49
|
+
it "returns 'defualt' true if default" do
|
50
|
+
args = {"name" => name, "default" => true}
|
51
|
+
env = Canals::Environment.new(args)
|
52
|
+
expect(env.is_default?).to eq true
|
53
|
+
end
|
54
|
+
|
55
|
+
it "returns 'defualt' false if default" do
|
56
|
+
args = {"name" => name, "default" => false}
|
57
|
+
env = Canals::Environment.new(args)
|
58
|
+
expect(env.is_default?).to eq false
|
59
|
+
end
|
60
|
+
|
61
|
+
it "returns 'defualt' false if default not availble" do
|
62
|
+
args = {"name" => name}
|
63
|
+
env = Canals::Environment.new(args)
|
64
|
+
expect(env.is_default?).to eq false
|
65
|
+
end
|
66
|
+
|
67
|
+
it "default can be changed" do
|
68
|
+
args = {"name" => name, "default" => true}
|
69
|
+
env = Canals::Environment.new(args)
|
70
|
+
expect(env.is_default?).to eq true
|
71
|
+
expect(env.to_hash["default"]).to eq true
|
72
|
+
env.default = false
|
73
|
+
expect(env.is_default?).to eq false
|
74
|
+
expect(env.to_hash["default"]).to eq false
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|