Add alpha SSH config parser

The error handling is nonexistent and there's no easy way to get
data out. But we can parse a SSH config file into a Go struct, and
roundtrip that struct back to a file that looks (roughly) the same.
This commit is contained in:
Kevin Burke
2017-04-16 21:13:13 -07:00
commit 29f594a81c
9 changed files with 701 additions and 0 deletions

3
Makefile Normal file
View File

@@ -0,0 +1,3 @@
test:
go test -timeout=10ms ./...

151
config.go Normal file
View File

@@ -0,0 +1,151 @@
package ssh_config
import (
"bytes"
"errors"
"fmt"
"io"
"os"
"runtime"
"strings"
)
func User(hostname string) string {
return ""
}
type ConfigFinder struct {
IgnoreSystemConfig bool
IgnoreUserConfig bool
}
func (c *ConfigFinder) User(hostname string) string {
return ""
}
var DefaultFinder = &ConfigFinder{IgnoreSystemConfig: false, IgnoreUserConfig: false}
func parseFile(filename string) (*Config, error) {
f, err := os.Open(filename)
if err != nil {
return nil, err
}
defer f.Close()
return LoadReader(f)
}
func LoadReader(r io.Reader) (c *Config, err error) {
defer func() {
if r := recover(); r != nil {
if _, ok := r.(runtime.Error); ok {
panic(r)
}
err = errors.New(r.(string))
}
}()
c = parseSSH(lexSSH(r))
return c, err
}
// Config represents an SSH config file.
type Config struct {
position Position
Hosts []*Host
}
func (c *Config) String() string {
var buf bytes.Buffer
for i := range c.Hosts {
buf.WriteString(c.Hosts[i].String())
}
return buf.String()
}
type Host struct {
// A list of host patterns that should match this host.
Patterns []string
// A Node is either a key/value pair or a comment line.
Nodes []Node
// EOLComment is the comment (if any) terminating the Host line.
EOLComment string
leadingSpace uint16 // TODO: handle spaces vs tabs here.
// The file starts with an implicit "Host *" declaration.
implicit bool
}
func (h *Host) String() string {
var buf bytes.Buffer
if h.implicit == false {
buf.WriteString(strings.Repeat(" ", int(h.leadingSpace)))
buf.WriteString("Host ")
buf.WriteString(strings.Join(h.Patterns, " "))
if h.EOLComment != "" {
buf.WriteString(" #")
buf.WriteString(h.EOLComment)
}
buf.WriteByte('\n')
}
for i := range h.Nodes {
//fmt.Printf("%q\n", h.Nodes[i].String())
buf.WriteString(h.Nodes[i].String())
buf.WriteByte('\n')
}
return buf.String()
}
type Node interface {
Pos() Position
String() string
}
type KV struct {
Key string
Value string
Comment string
leadingSpace uint16 // Space before the key. TODO handle spaces vs tabs.
position Position
}
func (k *KV) Pos() Position {
return k.position
}
func (k *KV) String() string {
if k == nil {
return ""
}
line := fmt.Sprintf("%s%s %s", strings.Repeat(" ", int(k.leadingSpace)), k.Key, k.Value)
if k.Comment != "" {
line += " #" + k.Comment
}
return line
}
type Empty struct {
Comment string
leadingSpace uint16 // TODO handle spaces vs tabs.
position Position
}
func (e *Empty) Pos() Position {
return e.position
}
func (e *Empty) String() string {
if e == nil {
return ""
}
if e.Comment == "" {
return ""
}
return fmt.Sprintf("%s#%s", strings.Repeat(" ", int(e.leadingSpace)), e.Comment)
}
func newConfig() *Config {
return &Config{
Hosts: []*Host{
&Host{implicit: true, Patterns: []string{"*"}, Nodes: make([]Node, 0)},
},
}
}

31
config_test.go Normal file
View File

@@ -0,0 +1,31 @@
package ssh_config
import (
"bytes"
"io/ioutil"
"testing"
)
func loadFile(t *testing.T, filename string) []byte {
data, err := ioutil.ReadFile(filename)
if err != nil {
t.Fatal(err)
}
return data
}
var files = []string{"testdata/config1", "testdata/config2"}
func TestLoadReader(t *testing.T) {
for _, filename := range files {
data := loadFile(t, filename)
cfg, err := LoadReader(bytes.NewReader(data))
if err != nil {
t.Fatal(err)
}
out := cfg.String()
if out != string(data) {
t.Errorf("out != data: out: %q\ndata: %q", out, string(data))
}
}
}

207
lexer.go Normal file
View File

@@ -0,0 +1,207 @@
package ssh_config
import (
"io"
buffruneio "github.com/pelletier/go-buffruneio"
)
// Define state functions
type sshLexStateFn func() sshLexStateFn
type sshLexer struct {
input *buffruneio.Reader // Textual source
buffer []rune // Runes composing the current token
tokens chan token
depth int
line uint32
col uint16
endbufferLine uint32
endbufferCol uint16
}
func (s *sshLexer) lexComment(previousState sshLexStateFn) sshLexStateFn {
return func() sshLexStateFn {
growingString := ""
for next := s.peek(); next != '\n' && next != eof; next = s.peek() {
if next == '\r' && s.follow("\r\n") {
break
}
growingString += string(next)
s.next()
}
s.emitWithValue(tokenComment, growingString)
s.skip()
return previousState
}
}
func (s *sshLexer) lexKey() sshLexStateFn {
growingString := ""
for r := s.peek(); isKeyChar(r); r = s.peek() {
// simplified a lot here
if isSpace(r) {
s.emitWithValue(tokenKey, growingString)
s.skip()
return s.lexRvalue
}
growingString += string(r)
s.next()
}
s.emitWithValue(tokenKey, growingString)
return s.lexVoid
}
func (s *sshLexer) lexRvalue() sshLexStateFn {
growingString := ""
for {
next := s.peek()
switch next {
case '\n':
s.emitWithValue(tokenString, growingString)
s.skip()
return s.lexVoid
case '#':
s.emitWithValue(tokenString, growingString)
s.skip()
return s.lexComment(s.lexVoid)
case eof:
s.next()
}
if next == eof {
break
}
growingString += string(next)
s.next()
}
s.emit(tokenEOF)
return nil
}
func (s *sshLexer) read() rune {
r, _, err := s.input.ReadRune()
if err != nil {
panic(err)
}
if r == '\n' {
s.endbufferLine++
s.endbufferCol = 1
} else {
s.endbufferCol++
}
return r
}
func (s *sshLexer) next() rune {
r := s.read()
if r != eof {
s.buffer = append(s.buffer, r)
}
return r
}
func (s *sshLexer) lexVoid() sshLexStateFn {
for {
next := s.peek()
switch next {
case '#':
s.skip()
return s.lexComment(s.lexVoid)
case '\r':
fallthrough
case '\n':
s.emit(tokenEmptyLine)
s.skip()
continue
}
if isSpace(next) {
s.skip()
}
if isKeyStartChar(next) {
return s.lexKey
}
// removed IsKeyStartChar and lexKey. probably will need to readd
if next == eof {
s.next()
break
}
}
s.emit(tokenEOF)
return nil
}
func (s *sshLexer) ignore() {
s.buffer = make([]rune, 0)
s.line = s.endbufferLine
s.col = s.endbufferCol
}
func (s *sshLexer) skip() {
s.next()
s.ignore()
}
func (s *sshLexer) emit(t tokenType) {
s.emitWithValue(t, string(s.buffer))
}
func (s *sshLexer) emitWithValue(t tokenType, value string) {
tok := token{
Position: Position{s.line, s.col},
typ: t,
val: value,
}
s.tokens <- tok
s.ignore()
}
func (s *sshLexer) peek() rune {
r, _, err := s.input.ReadRune()
if err != nil {
panic(err)
}
s.input.UnreadRune()
return r
}
func (s *sshLexer) follow(next string) bool {
for _, expectedRune := range next {
r, _, err := s.input.ReadRune()
defer s.input.UnreadRune()
if err != nil {
panic(err)
}
if expectedRune != r {
return false
}
}
return true
}
func (s *sshLexer) run() {
for state := s.lexVoid; state != nil; {
state = state()
}
close(s.tokens)
}
func lexSSH(input io.Reader) chan token {
bufferedInput := buffruneio.NewReader(input)
l := &sshLexer{
input: bufferedInput,
tokens: make(chan token),
line: 1,
col: 1,
endbufferLine: 1,
endbufferCol: 1,
}
go l.run()
return l.tokens
}

147
parser.go Normal file
View File

@@ -0,0 +1,147 @@
package ssh_config
import (
"fmt"
"strings"
)
type sshParser struct {
flow chan token
config *Config
tokensBuffer []token
currentTable []string
seenTableKeys []string
}
type sshParserStateFn func() sshParserStateFn
// Formats and panics an error message based on a token
func (p *sshParser) raiseError(tok *token, msg string, args ...interface{}) {
panic(tok.Position.String() + ": " + fmt.Sprintf(msg, args...))
}
func (p *sshParser) run() {
for state := p.parseStart; state != nil; {
state = state()
}
}
func (p *sshParser) peek() *token {
if len(p.tokensBuffer) != 0 {
return &(p.tokensBuffer[0])
}
tok, ok := <-p.flow
if !ok {
return nil
}
p.tokensBuffer = append(p.tokensBuffer, tok)
return &tok
}
func (p *sshParser) getToken() *token {
if len(p.tokensBuffer) != 0 {
tok := p.tokensBuffer[0]
p.tokensBuffer = p.tokensBuffer[1:]
return &tok
}
tok, ok := <-p.flow
if !ok {
return nil
}
return &tok
}
func (p *sshParser) parseStart() sshParserStateFn {
tok := p.peek()
// end of stream, parsing is finished
if tok == nil {
return nil
}
switch tok.typ {
case tokenComment, tokenEmptyLine:
return p.parseComment
case tokenKey:
return p.parseKV
case tokenEOF:
return nil
default:
p.raiseError(tok, fmt.Sprintf("unexpected token %q\n", tok))
}
return nil
}
func (p *sshParser) parseKV() sshParserStateFn {
key := p.getToken()
p.assume(tokenString)
val := p.getToken()
comment := ""
tok := p.peek()
if tok.typ == tokenComment && tok.Position.Line == val.Position.Line {
tok = p.getToken()
comment = tok.val
}
if key.val == "Host" {
patterns := strings.Split(val.val, " ")
for i := range patterns {
if patterns[i] == "" {
patterns = append(patterns[:i], patterns[i+1:]...)
}
}
p.config.Hosts = append(p.config.Hosts, &Host{
Patterns: patterns,
Nodes: make([]Node, 0),
EOLComment: comment,
})
return p.parseStart
}
lastHost := p.config.Hosts[len(p.config.Hosts)-1]
kv := &KV{
Key: key.val,
Value: val.val,
Comment: comment,
leadingSpace: uint16(key.Position.Col) - 1,
position: key.Position,
}
lastHost.Nodes = append(lastHost.Nodes, kv)
return p.parseStart
}
func (p *sshParser) parseComment() sshParserStateFn {
comment := p.getToken()
lastHost := p.config.Hosts[len(p.config.Hosts)-1]
lastHost.Nodes = append(lastHost.Nodes, &Empty{
Comment: comment.val,
// account for the "#" as well
leadingSpace: comment.Position.Col - 2,
position: comment.Position,
})
return p.parseStart
}
// assume peeks at the next token and ensures it's the right type
func (p *sshParser) assume(typ tokenType) {
tok := p.peek()
if tok == nil {
p.raiseError(tok, "was expecting token %s, but token stream is empty", tok)
}
if tok.typ != typ {
p.raiseError(tok, "was expecting token %s, but got %s instead", typ, tok)
}
}
func parseSSH(flow chan token) *Config {
result := newConfig()
result.position = Position{1, 1}
parser := &sshParser{
flow: flow,
config: result,
tokensBuffer: make([]token, 0),
currentTable: make([]string, 0),
seenTableKeys: make([]string, 0),
}
parser.run()
return result
}

25
position.go Normal file
View File

@@ -0,0 +1,25 @@
package ssh_config
import "fmt"
// Position of a document element within a SSH document.
//
// Line and Col are both 1-indexed positions for the element's line number and
// column number, respectively. Values of zero or less will cause Invalid(),
// to return true.
type Position struct {
Line uint32 // line within the document
Col uint16 // column within the line
}
// String representation of the position.
// Displays 1-indexed line and column numbers.
func (p Position) String() string {
return fmt.Sprintf("(%d, %d)", p.Line, p.Col)
}
// Invalid returns whether or not the position is valid (i.e. with negative or
// null values)
func (p Position) Invalid() bool {
return p.Line <= 0 || p.Col <= 0
}

39
testdata/config1 vendored Normal file
View File

@@ -0,0 +1,39 @@
Host localhost 127.0.0.1 # A comment at the end of a host line.
NoHostAuthenticationForLocalhost yes
# A comment
# A comment with leading spaces.
Host wap
User root
KexAlgorithms diffie-hellman-group1-sha1
Host [some stuff behind a NAT]
Compression yes
ProxyCommand ssh -qW %h:%p [NATrouter]
Host wopr # there are 2 proxies available for this one...
User root
ProxyCommand sh -c "ssh proxy1 -qW %h:22 || ssh proxy2 -qW %h:22"
Host dhcp-??
UserKnownHostsFile /dev/null
StrictHostKeyChecking no
User root
Host [my boxes] [*.mydomain]
ForwardAgent yes
ForwardX11 yes
ForwardX11Trusted yes
Host *
#ControlMaster auto
#ControlPath /tmp/ssh-master-%C
#ControlPath /tmp/ssh-%u-%r@%h:%p
#ControlPersist yes
ForwardX11Timeout 52w
XAuthLocation /usr/bin/xauth
SendEnv LANG LC_*
HostKeyAlgorithms ssh-ed25519,ssh-rsa
AddressFamily inet
#UpdateHostKeys ask

50
testdata/config2 vendored Normal file
View File

@@ -0,0 +1,50 @@
# $OpenBSD: ssh_config,v 1.30 2016/02/20 23:06:23 sobrado Exp $
# This is the ssh client system-wide configuration file. See
# ssh_config(5) for more information. This file provides defaults for
# users, and the values can be changed in per-user configuration files
# or on the command line.
# Configuration data is parsed as follows:
# 1. command line options
# 2. user-specific file
# 3. system-wide file
# Any configuration value is only changed the first time it is set.
# Thus, host-specific definitions should be at the beginning of the
# configuration file, and defaults at the end.
# Site-wide defaults for some commonly used options. For a comprehensive
# list of available options, their meanings and defaults, please see the
# ssh_config(5) man page.
# Host *
# ForwardAgent no
# ForwardX11 no
# RhostsRSAAuthentication no
# RSAAuthentication yes
# PasswordAuthentication yes
# HostbasedAuthentication no
# GSSAPIAuthentication no
# GSSAPIDelegateCredentials no
# BatchMode no
# CheckHostIP yes
# AddressFamily any
# ConnectTimeout 0
# StrictHostKeyChecking ask
# IdentityFile ~/.ssh/identity
# IdentityFile ~/.ssh/id_rsa
# IdentityFile ~/.ssh/id_dsa
# IdentityFile ~/.ssh/id_ecdsa
# IdentityFile ~/.ssh/id_ed25519
# Port 22
# Protocol 2
# Cipher 3des
# Ciphers aes128-ctr,aes192-ctr,aes256-ctr,arcfour256,arcfour128,aes128-cbc,3des-cbc
# MACs hmac-md5,hmac-sha1,umac-64@openssh.com,hmac-ripemd160
# EscapeChar ~
# Tunnel no
# TunnelDevice any:any
# PermitLocalCommand no
# VisualHostKey no
# ProxyCommand ssh -q -W %h:%p gateway.example.com
# RekeyLimit 1G 1h

48
token.go Normal file
View File

@@ -0,0 +1,48 @@
package ssh_config
import "fmt"
type token struct {
Position
typ tokenType
val string
}
func (t token) String() string {
switch t.typ {
case tokenEOF:
return "EOF"
}
return fmt.Sprintf("%q", t.val)
}
type tokenType int
const (
eof = -(iota + 1)
)
const (
tokenError tokenType = iota
tokenEOF
tokenEmptyLine
tokenComment
tokenKey
tokenString
)
func isSpace(r rune) bool {
return r == ' ' || r == '\t'
}
func isKeyStartChar(r rune) bool {
return !(isSpace(r) || r == '\r' || r == '\n' || r == eof)
}
// I'm not sure that this is correct
func isKeyChar(r rune) bool {
// Keys start with the first character that isn't whitespace or [ and end
// with the last non-whitespace character before the equals sign. Keys
// cannot contain a # character."
return !(r == '\r' || r == '\n' || r == eof || r == '=')
}