Compare commits
No commits in common. 'master' and 'ruby' have entirely different histories.
15 changed files with 269 additions and 382 deletions
@ -1 +1,14 @@ |
|||
/ssh-ident |
|||
/.bundle/ |
|||
/.yardoc |
|||
/Gemfile.lock |
|||
/_yardoc/ |
|||
/coverage/ |
|||
/doc/ |
|||
/pkg/ |
|||
/spec/reports/ |
|||
/tmp/ |
|||
|
|||
# rspec failure tracking |
|||
.rspec_status |
|||
|
|||
*.gem |
|||
|
@ -0,0 +1,3 @@ |
|||
--format documentation |
|||
--color |
|||
--require spec_helper |
@ -0,0 +1,2 @@ |
|||
source 'https://rubygems.org' |
|||
gemspec |
@ -1,177 +0,0 @@ |
|||
package main |
|||
|
|||
import ( |
|||
"fmt" |
|||
"io/ioutil" |
|||
"os" |
|||
"os/exec" |
|||
"path" |
|||
"path/filepath" |
|||
"regexp" |
|||
"sort" |
|||
"strconv" |
|||
"strings" |
|||
"syscall" |
|||
) |
|||
|
|||
type Agent struct { |
|||
Identity Identity |
|||
Path string |
|||
Env []string |
|||
} |
|||
|
|||
func (a *Agent) getEnv() []string { |
|||
env := os.Environ() |
|||
env = append(env, a.Env...) |
|||
return env |
|||
} |
|||
|
|||
func (a *Agent) loadEnv() { |
|||
d, err := ioutil.ReadFile(a.envFile()) |
|||
fatal(err) |
|||
properties := string(d) |
|||
env := extractEnv(properties) |
|||
a.Env = env |
|||
} |
|||
|
|||
func (a *Agent) loadKeys() { |
|||
path := path.Join(a.Identity.Path, "id_*") |
|||
keys, err := filepath.Glob(path) |
|||
fatal(err) |
|||
sort.Strings(keys) |
|||
|
|||
fmt.Fprintf(os.Stderr, "Load private key:\n") |
|||
privateKeys := []string{} |
|||
for _, key := range keys { |
|||
if strings.HasSuffix(key, ".pub") { |
|||
continue |
|||
} |
|||
fmt.Fprintf(os.Stderr, " %s\n", key) |
|||
privateKeys = append(privateKeys, key) |
|||
} |
|||
|
|||
cmd := exec.Command("ssh-add", privateKeys...) |
|||
cmd.Env = a.getEnv() |
|||
|
|||
_, e, err := capture3(cmd) |
|||
if err != nil { |
|||
os.Stderr.Write(e) |
|||
fatal(err) |
|||
} |
|||
} |
|||
|
|||
func (a *Agent) envFile() string { |
|||
return a.Path + ".env" |
|||
} |
|||
|
|||
var propertyRegex = regexp.MustCompile(`([^=]+)=([^;]+); export .*;`) |
|||
|
|||
func extractEnv(str string) []string { |
|||
properties := strings.Split(str, "\n") |
|||
env := []string{} |
|||
for _, property := range properties { |
|||
match := propertyRegex.FindStringSubmatch(property) |
|||
if match != nil { |
|||
name := match[1] |
|||
value := match[2] |
|||
property = fmt.Sprintf("%s=%s", name, value) |
|||
env = append(env, property) |
|||
} |
|||
} |
|||
return env |
|||
} |
|||
|
|||
func (a *Agent) start() { |
|||
fmt.Fprintf(os.Stderr, "Start new agent for identity %s\n", a.Identity.Name) |
|||
sock := a.Path + ".sock" |
|||
|
|||
syscall.Unlink(sock) |
|||
cmd := exec.Command("ssh-agent", "-a", sock) |
|||
o, e, err := capture3(cmd) |
|||
if err != nil { |
|||
os.Stderr.Write(e) |
|||
fatal(err) |
|||
} |
|||
|
|||
properties := string(o) |
|||
env := extractEnv(properties) |
|||
a.Env = env |
|||
|
|||
err = ioutil.WriteFile(a.envFile(), o, 0600) |
|||
fatal(err) |
|||
|
|||
a.loadKeys() |
|||
} |
|||
|
|||
func (a *Agent) getPid() int { |
|||
d, err := ioutil.ReadFile(a.envFile()) |
|||
fatal(err) |
|||
properties := string(d) |
|||
env := strings.Split(properties, "\n") |
|||
for _, property := range env { |
|||
match := propertyRegex.FindStringSubmatch(property) |
|||
if match != nil { |
|||
name := match[1] |
|||
if name == "SSH_AGENT_PID" { |
|||
value := match[2] |
|||
pid, err := strconv.Atoi(value) |
|||
fatal(err) |
|||
return pid |
|||
} |
|||
} |
|||
} |
|||
return -1 |
|||
} |
|||
|
|||
func (a *Agent) init() { |
|||
if _, err := os.Stat(a.envFile()); os.IsNotExist(err) { |
|||
a.start() |
|||
return |
|||
} |
|||
|
|||
pid := a.getPid() |
|||
if pid <= 0 { |
|||
a.start() |
|||
return |
|||
} |
|||
|
|||
proc, err := os.FindProcess(pid) |
|||
if err == nil { |
|||
err = proc.Signal(syscall.Signal(0)) |
|||
if err != nil { |
|||
a.start() |
|||
return |
|||
} |
|||
} |
|||
|
|||
a.loadEnv() |
|||
} |
|||
|
|||
func NewAgent(config Config, identity Identity) Agent { |
|||
p := path.Join(config.AgentsDir, identity.Name) |
|||
agent := Agent{ |
|||
Identity: identity, |
|||
Path: p, |
|||
} |
|||
agent.init() |
|||
return agent |
|||
} |
|||
|
|||
func (a *Agent) Run(config Config, prog string, args []string) { |
|||
identity := a.Identity |
|||
fmt.Fprintf(os.Stderr, "\033[1;41m[%s]\033[0m %s %s\n", identity.Name, prog, strings.Join(args, " ")) |
|||
exe := path.Join(config.BinDir, prog) |
|||
if _, err := os.Stat(exe); os.IsNotExist(err) { |
|||
fatal(fmt.Errorf("%s: no such file or directory", exe)) |
|||
} |
|||
|
|||
sshConfig := path.Join(identity.Path, "config") |
|||
_, err := os.Stat(exe) |
|||
if err == nil { |
|||
args = append([]string{"-F", sshConfig}, args...) |
|||
} |
|||
args = append([]string{prog}, args...) |
|||
|
|||
env := a.getEnv() |
|||
syscall.Exec(exe, args, env) |
|||
} |
@ -0,0 +1,8 @@ |
|||
#!/usr/bin/env ruby |
|||
require 'ssh-ident' |
|||
begin |
|||
SSHIdent.run |
|||
rescue => e |
|||
$stderr.puts e |
|||
exit -1 |
|||
end |
@ -1,3 +0,0 @@ |
|||
module imirhil.fr/ssh-ident |
|||
|
|||
require gopkg.in/yaml.v2 v2.2.1 |
@ -1,3 +0,0 @@ |
|||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= |
|||
gopkg.in/yaml.v2 v2.2.1 h1:mUhvW9EsL+naU5Q3cakzfE91YhliOondGd6ZrsDBHQE= |
|||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= |
@ -1,74 +0,0 @@ |
|||
package main |
|||
|
|||
import ( |
|||
"os" |
|||
"path" |
|||
"regexp" |
|||
"strings" |
|||
) |
|||
|
|||
type Identity struct { |
|||
Name string |
|||
Path string |
|||
} |
|||
|
|||
func newIdentity(config Config, name string) Identity { |
|||
return Identity{ |
|||
Name: name, |
|||
Path: "", |
|||
} |
|||
} |
|||
|
|||
var hostRegexps = map[string]*regexp.Regexp{ |
|||
"ssh": regexp.MustCompile("(?P<user>.*@)?(?P<host>.*)"), |
|||
"scp": regexp.MustCompile("(?P<user>.*@)?(?P<host>.*):(?P<file>.*)"), |
|||
} |
|||
|
|||
func extractHost(prog string, args []string) string { |
|||
args = removeOptions(prog, args) |
|||
switch prog { |
|||
case "ssh": |
|||
if len(args) == 0 { |
|||
return "" |
|||
} |
|||
re := hostRegexps["ssh"] |
|||
arg := args[0] |
|||
p := matchParams(re, arg) |
|||
return p["host"] |
|||
case "scp": |
|||
re := hostRegexps["scp"] |
|||
for _, arg := range args { |
|||
if p := matchParams(re, arg); p != nil { |
|||
return p["host"] |
|||
} |
|||
} |
|||
} |
|||
return "" |
|||
} |
|||
|
|||
func findIdentityName(config Config, prog string, args []string) string { |
|||
name := os.Getenv("SSH_IDENTITY") |
|||
if name != "" { |
|||
return name |
|||
} |
|||
identities := config.Identities |
|||
host := extractHost(prog, args) |
|||
for _, identity := range identities { |
|||
match := identity.Key.(string) |
|||
name := identity.Value.(string) |
|||
if strings.Contains(host, match) { |
|||
return name |
|||
} |
|||
} |
|||
|
|||
return config.DefaultIdentity |
|||
} |
|||
|
|||
func FindIdentity(config Config, prog string, args []string) Identity { |
|||
name := findIdentityName(config, prog, args) |
|||
path := path.Join(config.IdentitiesDir, name) |
|||
return Identity{ |
|||
Name: name, |
|||
Path: path, |
|||
} |
|||
} |
@ -0,0 +1,81 @@ |
|||
#!/usr/bin/env ruby |
|||
require 'open3' |
|||
require 'yaml' |
|||
|
|||
module SSHIdent |
|||
VERSION = '1.0.1'.freeze |
|||
|
|||
PATH = File.join Dir.home, '.ssh' |
|||
CONFIG = YAML.load_file File.join PATH, 'identities', 'config.yml' |
|||
|
|||
OPTIONS = { |
|||
ssh: { |
|||
short: '1246AaCfGgKkMNnqsTtVvXxYy', |
|||
long: 'bcDEeFIiJLlmOopQRSWw' |
|||
}, |
|||
scp: { |
|||
short: '12346BCpqrv', |
|||
long: 'cFiloPS' |
|||
} |
|||
}.freeze |
|||
|
|||
HOST_REGEX = /(?<user>.*@)?(?<host>[^:]+)(?<file>:.*)/ |
|||
|
|||
def self.run |
|||
prog = ENV['SSH_BINARY'] || File.basename($PROGRAM_NAME) |
|||
raise "Unable to guess which binary to use\nPlease set SSH_BINARY environment variable" unless OPTIONS.key? prog.to_sym |
|||
|
|||
identity = Agent.new find_identity prog, ARGV |
|||
identity.run prog, ARGV |
|||
end |
|||
|
|||
private |
|||
|
|||
def self.remove_options(prog, args) |
|||
not_options = [] |
|||
long_options = OPTIONS[prog][:long] |
|||
long = false |
|||
args.each do |arg| |
|||
if long |
|||
long = false |
|||
next |
|||
elsif arg.start_with? '-' |
|||
long = long_options.include? arg[-1] |
|||
else |
|||
not_options << arg |
|||
end |
|||
end |
|||
not_options |
|||
end |
|||
|
|||
SSH_REGEX = /(?<user>.*@)?(?<host>.*)/ |
|||
SCP_REGEX = /(?<user>.*@)?(?<host>.*):(?<file>.*)/ |
|||
|
|||
def self.extract_host(prog, args) |
|||
prog = prog.to_sym |
|||
args = self.remove_options prog, args |
|||
return nil if args.empty? |
|||
case prog |
|||
when :ssh |
|||
return SSH_REGEX.match(args.first)[:host] |
|||
when :scp |
|||
args.each do |a| |
|||
m = SCP_REGEX.match a |
|||
return m[:host] if m |
|||
end |
|||
end |
|||
end |
|||
|
|||
def self.find_identity(prog, args) |
|||
env = ENV['SSH_IDENTITY'] |
|||
return env if env |
|||
|
|||
host = self.extract_host prog, args |
|||
CONFIG['identities'].each do |match, identity| |
|||
return identity if host.include? match |
|||
end if host |
|||
CONFIG['default_identity'] |
|||
end |
|||
end |
|||
|
|||
require 'ssh-ident/agent' |
@ -0,0 +1,81 @@ |
|||
module SSHIdent |
|||
class Agent |
|||
AGENT_PATH = File.join SSHIdent::PATH, 'agents' |
|||
IDENTITY_PATH = File.join SSHIdent::PATH, 'identities' |
|||
|
|||
def initialize(name) |
|||
@name = name |
|||
@path = File.join SSHIdent::PATH, 'identities', @name |
|||
raise "Not existing identity #{@name}" unless Dir.exist? @path |
|||
|
|||
@agent_path = File.join AGENT_PATH, @name |
|||
@env_path = "#{@agent_path}.env" |
|||
@env = init_env |
|||
end |
|||
|
|||
def run(prog, args) |
|||
$stderr.puts "\033[1;41m[ #{@name} ]\033[0m #{prog} #{args.join ' '}" unless ENV['SSH_QUIET'] |
|||
|
|||
prog = File.join CONFIG['bin_dir'], prog |
|||
raise "#{prog}: not such file or directory" unless File.executable? prog |
|||
|
|||
config = File.join @path, 'config' |
|||
args = ['-F', config] + args if File.exist? config |
|||
|
|||
$stdout.flush |
|||
$stderr.flush |
|||
|
|||
cmd = [get_env, prog, *args] |
|||
exec *cmd |
|||
end |
|||
|
|||
private |
|||
|
|||
def init_env |
|||
if File.exists? @env_path |
|||
env = YAML.load_file @env_path |
|||
pid = env['SSH_AGENT_PID'] |
|||
agent = pid.nil? ? nil : Process.getpgid(pid.to_i) rescue nil |
|||
socket = File.exists? env['SSH_AUTH_SOCK'] |
|||
return env if agent && socket |
|||
end |
|||
start |
|||
end |
|||
|
|||
def get_env(env = nil) |
|||
env ||= @env |
|||
ENV.to_h.merge env |
|||
end |
|||
|
|||
def start |
|||
$stderr.puts "Starting new agent for identity #{@name}" |
|||
sock = "#{@agent_path}.sock" |
|||
if File.exist? sock |
|||
$stderr.puts "Warning, dangling SSH agent socks file #{sock}" |
|||
File.unlink sock |
|||
end |
|||
env, err, status = Open3.capture3 'ssh-agent', '-a', sock |
|||
raise err unless status.success? |
|||
re = Regexp.new /^(.*)=([^;]+); export \1;/ |
|||
env = env.split(/$/).collect { |l| re.match l }.compact.collect { |l| l.captures }.to_h |
|||
File.write @env_path, YAML.dump(env) |
|||
|
|||
load_keys env |
|||
|
|||
env |
|||
end |
|||
|
|||
def load_keys(env) |
|||
keys = Dir[File.join @path, 'id_*'].reject { |p| p =~ /\.pub/ }.sort |
|||
|
|||
$stderr.puts 'Loading keys:' |
|||
keys.each { |k| $stderr.puts " #{k}" } |
|||
|
|||
options = {} |
|||
options[:in] = :close unless ENV['SSH_TTY'] |
|||
|
|||
pid = Process.spawn get_env(env), 'ssh-add', *keys, options |
|||
Process.wait pid |
|||
end |
|||
end |
|||
end |
@ -1,79 +0,0 @@ |
|||
package main |
|||
|
|||
import ( |
|||
"io/ioutil" |
|||
"os" |
|||
"os/user" |
|||
"path" |
|||
"strings" |
|||
|
|||
"gopkg.in/yaml.v2" |
|||
) |
|||
|
|||
type Config struct { |
|||
BinDir string `yaml:"bin_dir"` |
|||
AgentsDir string |
|||
IdentitiesDir string |
|||
DefaultIdentity string `yaml:"default_identity"` |
|||
Identities yaml.MapSlice |
|||
} |
|||
|
|||
var sshOptions = map[string]map[string]string{ |
|||
"ssh": { |
|||
"short": "1246AaconfiggKkMNnqsTtVvXxYy", |
|||
"long": "bcDEeFIiJLlmOopQRSWw", |
|||
}, |
|||
"scp": { |
|||
"short": "12346BCpqrv", |
|||
"long": "cFiloPS", |
|||
}, |
|||
} |
|||
|
|||
func removeOptions(prog string, args []string) []string { |
|||
notOptions := []string{} |
|||
longOptions := sshOptions[prog]["long"] |
|||
long := false |
|||
for _, arg := range args { |
|||
if long { |
|||
long = false |
|||
continue |
|||
} else if strings.HasPrefix(arg, "-") { |
|||
last := arg[len(arg)-1:] |
|||
long = strings.Contains(longOptions, last) |
|||
} else { |
|||
notOptions = append(notOptions, arg) |
|||
} |
|||
} |
|||
return notOptions |
|||
} |
|||
|
|||
func main() { |
|||
usr, err := user.Current() |
|||
fatal(err) |
|||
home := usr.HomeDir |
|||
sshDir := path.Join(home, ".ssh") |
|||
identitiesDir := path.Join(sshDir, "identities") |
|||
|
|||
configFile := path.Join(identitiesDir, "config.yml") |
|||
data, err := ioutil.ReadFile(configFile) |
|||
fatal(err) |
|||
config := Config{} |
|||
err = yaml.Unmarshal(data, &config) |
|||
fatal(err) |
|||
|
|||
config.IdentitiesDir = identitiesDir |
|||
|
|||
prog := os.Getenv("SSH_BINARY") |
|||
if prog == "" { |
|||
prog = os.Args[0] |
|||
prog = path.Base(prog) |
|||
} |
|||
args := os.Args[1:] |
|||
|
|||
identity := FindIdentity(config, prog, args) |
|||
agentsDir := path.Join(sshDir, "agents") |
|||
config.AgentsDir = agentsDir |
|||
|
|||
agent := NewAgent(config, identity) |
|||
agent.Run(config, prog, args) |
|||
} |
@ -0,0 +1,13 @@ |
|||
require 'ssh-ident' |
|||
|
|||
RSpec.configure do |config| |
|||
config.expect_with :rspec do |expectations| |
|||
expectations.include_chain_clauses_in_custom_matcher_descriptions = true |
|||
end |
|||
|
|||
config.mock_with :rspec do |mocks| |
|||
mocks.verify_partial_doubles = true |
|||
end |
|||
|
|||
config.shared_context_metadata_behavior = :apply_to_host_groups |
|||
end |
@ -0,0 +1,42 @@ |
|||
describe SSHIdent do |
|||
describe '::remove_options' do |
|||
it 'must handle no option' do |
|||
expect(SSHIdent.send(:remove_options, :ssh, %w[foo bar])).to eq %w[foo bar] |
|||
expect(SSHIdent.send(:remove_options, :ssh, %w[bar foo])).to eq %w[bar foo] |
|||
end |
|||
|
|||
it 'must handle short options' do |
|||
expect(SSHIdent.send(:remove_options, :ssh, %w[-1 -2 foo bar])).to eq %w[foo bar] |
|||
expect(SSHIdent.send(:remove_options, :ssh, %w[-1 -2 bar foo])).to eq %w[bar foo] |
|||
end |
|||
|
|||
it 'must handle long options' do |
|||
expect(SSHIdent.send(:remove_options, :ssh, %w[-F baz foo bar])).to eq %w[foo bar] |
|||
expect(SSHIdent.send(:remove_options, :ssh, %w[-F baz bar foo])).to eq %w[bar foo] |
|||
end |
|||
|
|||
it 'must handle both options' do |
|||
expect(SSHIdent.send(:remove_options, :ssh, %w[-1 -F baz foo bar])).to eq %w[foo bar] |
|||
expect(SSHIdent.send(:remove_options, :ssh, %w[-F baz -1 bar foo])).to eq %w[bar foo] |
|||
end |
|||
|
|||
it 'must handle mixed options' do |
|||
expect(SSHIdent.send(:remove_options, :ssh, %w[-1F baz foo bar])).to eq %w[foo bar] |
|||
expect(SSHIdent.send(:remove_options, :ssh, %w[-1F baz bar foo])).to eq %w[bar foo] |
|||
end |
|||
end |
|||
|
|||
describe '::extract_host' do |
|||
it 'must detect SSH host' do |
|||
expect(SSHIdent.send(:extract_host, :ssh, %w[foo bar baz])).to eq 'foo' |
|||
expect(SSHIdent.send(:extract_host, :ssh, %w[bar@foo bar baz])).to eq 'foo' |
|||
end |
|||
|
|||
it 'must detect SCP host' do |
|||
expect(SSHIdent.send(:extract_host, :scp, %w[foo:. bar])).to eq 'foo' |
|||
expect(SSHIdent.send(:extract_host, :scp, %w[bar@foo:. baz])).to eq 'foo' |
|||
expect(SSHIdent.send(:extract_host, :scp, %w[bar foo:.])).to eq 'foo' |
|||
expect(SSHIdent.send(:extract_host, :scp, %w[baz bar@foo:.])).to eq 'foo' |
|||
end |
|||
end |
|||
end |
@ -0,0 +1,25 @@ |
|||
# coding: utf-8 |
|||
lib = File.expand_path('../lib', __FILE__) |
|||
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) |
|||
|
|||
require 'ssh-ident' |
|||
|
|||
Gem::Specification.new do |spec| |
|||
spec.name = 'ssh-ident' |
|||
spec.version = SSHIdent::VERSION |
|||
spec.authors = ['aeris'] |
|||
spec.email = ['aeris@imirhil.fr'] |
|||
spec.license = 'AGPL-3.0+' |
|||
|
|||
spec.summary = 'SSH client with identities support' |
|||
spec.homepage = 'https://git.imirhil.fr/aeris/ssh-ident' |
|||
|
|||
spec.files = `git ls-files -z`.split("\x0").reject do |f| |
|||
f.match(%r{^(test|spec|features)/}) |
|||
end |
|||
spec.bindir = 'bin' |
|||
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } |
|||
spec.require_paths = ['lib'] |
|||
|
|||
spec.add_development_dependency 'bundler', '~> 1.15' |
|||
end |
@ -1,45 +0,0 @@ |
|||
package main |
|||
|
|||
import ( |
|||
"bytes" |
|||
"fmt" |
|||
"os" |
|||
"os/exec" |
|||
"regexp" |
|||
) |
|||
|
|||
func fatal(err error) { |
|||
if err == nil { |
|||
return |
|||
} |
|||
|
|||
fmt.Fprintf(os.Stderr, "%s\n", err.Error()) |
|||
os.Exit(-1) |
|||
} |
|||
|
|||
func matchParams(re *regexp.Regexp, str string) map[string]string { |
|||
match := re.FindStringSubmatch(str) |
|||
if match == nil { |
|||
return nil |
|||
} |
|||
params := make(map[string]string) |
|||
for i, name := range re.SubexpNames() { |
|||
params[name] = match[i] |
|||
} |
|||
return params |
|||
} |
|||
|
|||
func capture3(cmd *exec.Cmd) ([]byte, []byte, error) { |
|||
var stdout, stderr bytes.Buffer |
|||
cmd.Stdout = &stdout |
|||
cmd.Stderr = &stderr |
|||
|
|||
err := cmd.Run() |
|||
return stdout.Bytes(), stderr.Bytes(), err |
|||
} |
|||
|
|||
func dumpEnv(env []string) { |
|||
for _, e := range env { |
|||
fmt.Println(e) |
|||
} |
|||
} |
Loading…
Reference in new issue