Compare commits

...

No commits in common. 'master' and 'ruby' have entirely different histories.
master ... ruby

  1. 15
      .gitignore
  2. 3
      .rspec
  3. 2
      Gemfile
  4. 177
      agent.go
  5. 8
      bin/ssh-ident
  6. 3
      go.mod
  7. 3
      go.sum
  8. 74
      identity.go
  9. 81
      lib/ssh-ident.rb
  10. 81
      lib/ssh-ident/agent.rb
  11. 79
      main.go
  12. 13
      spec/spec_helper.rb
  13. 42
      spec/ssh-ident_spec.rb
  14. 25
      ssh-ident.gemspec
  15. 45
      utils.go

15
.gitignore

@ -1 +1,14 @@
/ssh-ident
/.bundle/
/.yardoc
/Gemfile.lock
/_yardoc/
/coverage/
/doc/
/pkg/
/spec/reports/
/tmp/
# rspec failure tracking
.rspec_status
*.gem

3
.rspec

@ -0,0 +1,3 @@
--format documentation
--color
--require spec_helper

2
Gemfile

@ -0,0 +1,2 @@
source 'https://rubygems.org'
gemspec

177
agent.go

@ -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)
}

8
bin/ssh-ident

@ -0,0 +1,8 @@
#!/usr/bin/env ruby
require 'ssh-ident'
begin
SSHIdent.run
rescue => e
$stderr.puts e
exit -1
end

3
go.mod

@ -1,3 +0,0 @@
module imirhil.fr/ssh-ident
require gopkg.in/yaml.v2 v2.2.1

3
go.sum

@ -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=

74
identity.go

@ -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,
}
}

81
lib/ssh-ident.rb

@ -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'

81
lib/ssh-ident/agent.rb

@ -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

79
main.go

@ -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)
}

13
spec/spec_helper.rb

@ -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

42
spec/ssh-ident_spec.rb

@ -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

25
ssh-ident.gemspec

@ -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

45
utils.go

@ -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…
Cancel
Save