-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #1 from cego/init
Initial MVP
- Loading branch information
Showing
3 changed files
with
372 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
/james |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,85 @@ | ||
# james | ||
|
||
james is a utility to be used as a `AuthorizedKeysCommand` for OpenSSH. | ||
|
||
It is designed to fetch a list of authorized keys from a remote HTTPS | ||
server. | ||
|
||
## Installation | ||
|
||
Grab a binary from a releases or compile from source. Nothing magical. | ||
|
||
## Running | ||
|
||
james should be added to `sshd_config` as `AuthorizedKeysCommand`. | ||
|
||
The following tokens from OpenSSH are supported: | ||
|
||
| Token | Flag | Description | Form key | | ||
|-------|------|----------------------------------------------------------|-------------| | ||
| %f | -f | The fingerprint of the key or certificate | fingerprint | | ||
| %h | -h | The home directory of the user | home | | ||
| %k | -k | The base64-encoded key or certificate for authentication | key | | ||
| %t | -t | The key or certificate type | keytype | | ||
| %U | -U | The numeric user ID of the target user | uid | | ||
| %u | -u | The username | username | | ||
|
||
### Additional flags | ||
|
||
| Flag | Default | Description | | ||
|-------------------|------------------------------------|---------------------------------------| | ||
| --url | https://github.com/[username].keys | URL to retrieve keys from | | ||
| --hostname | auto-detected | The local hostname | | ||
| --port | 22 | TCP port of the local SSH server | | ||
| --use-syslog | true | Log to syslog | | ||
| --guess-remote-ip | true | Try to guess remote IP. Requires root | | ||
|
||
## Implement the server-side | ||
|
||
james will issue a `GET` request containing the following parameters. | ||
Most optional. | ||
|
||
| Name | Description | | ||
|------------------|------------------------------------------------------------------| | ||
| service_hostname | Will try to guess or use hostname provided from --hostname | | ||
| service_port * | Will assume standard port if none provided using --port | | ||
| remote_ip * | Will only be provided if --guess-remote-ip=false is not provided | | ||
| fingerprint * | -f flag | | ||
| home * | -h flag | | ||
| key * | -k flag | | ||
| keytype * | -t flag | | ||
| uid * | -U flag | | ||
| username * | -u flag | | ||
|
||
*: Optional | ||
|
||
The complete response from the server will be written to standard | ||
output. Server failures (HTTP response code 5xx) will be retried | ||
five times with exponential backoff before giving up. | ||
|
||
## Examples | ||
|
||
### Allowing all keys from the Github user nat | ||
|
||
``` | ||
AuthorizedKeysCommand /sbin/james --url https://github.com/nat.keys --guess-remote-ip=false | ||
AuthorizedKeysCommandUser nobody | ||
``` | ||
|
||
### Retrieving keys from a Github user named as the user trying to authenticate | ||
|
||
``` | ||
AuthorizedKeysCommand /sbin/james --guess-remote-ip=false | ||
AuthorizedKeysCommandUser nobody | ||
``` | ||
|
||
### Contact something intelligent getting a list of keys | ||
|
||
``` | ||
AuthorizedKeysCommand /sbin/james --url https://ssh-gatekeeper.example.com -f %f -u %u | ||
AuthorizedKeysCommandUser root # required to guess remote IP | ||
``` | ||
|
||
## Known limitations | ||
|
||
IPv6 is currently not supported. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,286 @@ | ||
package main | ||
|
||
import ( | ||
"bufio" | ||
"fmt" | ||
"io" | ||
"log" | ||
"log/syslog" | ||
"net" | ||
"net/http" | ||
"os" | ||
"regexp" | ||
"strconv" | ||
"time" | ||
|
||
"github.com/spf13/cobra" | ||
) | ||
|
||
const ( | ||
appName = "james" | ||
) | ||
|
||
var ( | ||
fingerprint string | ||
home string | ||
key string | ||
keytype string | ||
uid string | ||
username string | ||
|
||
hostname string | ||
port uint16 | ||
|
||
url string | ||
guessRemoteIP bool | ||
|
||
rootCmd = &cobra.Command{ | ||
Use: appName, | ||
Short: "A helper for OpenSSH's AuthorizedKeysCommand", | ||
Run: root, | ||
} | ||
) | ||
|
||
func init() { | ||
helpFlag := false | ||
useSyslog := true | ||
|
||
// Trick to use '-h' for something else than help. This works by | ||
// replacing the default help flag with one with no shorthand set. | ||
rootCmd.PersistentFlags().BoolVarP(&helpFlag, "help", "", false, "Help for "+os.Args[0]) | ||
|
||
// Tokens supported by OpenSSH server. | ||
rootCmd.PersistentFlags().StringVarP(&fingerprint, "fingerprint", "f", "", "The fingerprint of the key or certificate (%f)") | ||
rootCmd.PersistentFlags().StringVarP(&home, "home", "h", "", "The home directory of the user (%h)") | ||
rootCmd.PersistentFlags().StringVarP(&key, "key", "k", "", "The base64-encoded key or certificate for authentication (%k)") | ||
rootCmd.PersistentFlags().StringVarP(&keytype, "keytype", "t", "", "The key or certificate type (%t)") | ||
rootCmd.PersistentFlags().StringVarP(&uid, "uid", "U", "", "The numeric user ID of the target user (%U)") | ||
rootCmd.PersistentFlags().StringVarP(&username, "username", "u", "", "The username (%u)") | ||
|
||
osHostname, _ := os.Hostname() | ||
rootCmd.PersistentFlags().StringVarP(&hostname, "hostname", "", osHostname, "The local hostname") | ||
rootCmd.PersistentFlags().Uint16VarP(&port, "port", "", 22, "The port SSH is listening to") | ||
|
||
rootCmd.PersistentFlags().StringVarP(&url, "url", "", "", "URL to use") | ||
rootCmd.PersistentFlags().BoolVarP(&guessRemoteIP, "guess-remote-ip", "", true, "Try to guess remote IP. Requires root") | ||
rootCmd.PersistentFlags().BoolVarP(&useSyslog, "use-syslog", "", useSyslog, "Log to syslog") | ||
|
||
if useSyslog { | ||
writer, err := syslog.New(syslog.LOG_ERR|syslog.LOG_AUTH, appName) | ||
if err != nil { | ||
log.Fatalf("Error connecting to syslog: %s", err.Error()) | ||
} | ||
|
||
log.SetOutput(writer) | ||
} | ||
} | ||
|
||
// httpDo will try a http request multiple times if the server responds | ||
// with an internal error. | ||
func httpDo(req *http.Request) (*http.Response, error) { | ||
backOff := time.Millisecond * 250 | ||
|
||
if req.Body != nil { | ||
panic("httpDo() only supports requests without body") | ||
} | ||
|
||
for retryCount := 0; retryCount < 5; retryCount++ { | ||
resp, err := http.DefaultClient.Do(req) | ||
if err != nil { | ||
return nil, err | ||
} | ||
|
||
if resp.StatusCode < 500 { | ||
return resp, err | ||
} | ||
|
||
err = resp.Body.Close() | ||
if err != nil { | ||
return nil, err | ||
} | ||
|
||
time.Sleep(backOff * time.Duration(retryCount)) | ||
} | ||
|
||
return nil, fmt.Errorf("giving up on %s", url) | ||
} | ||
|
||
func root(_ *cobra.Command, _ []string) { | ||
if url == "" { | ||
url = fmt.Sprintf("https://github.com/%s.keys", username) | ||
} | ||
|
||
req, err := http.NewRequest("GET", url, nil) | ||
if err != nil { | ||
log.Fatalf("Error: %s", err.Error()) | ||
} | ||
|
||
q := req.URL.Query() | ||
req.URL.RawQuery = q.Encode() | ||
|
||
if guessRemoteIP { | ||
sockets, err := getOpenSockets(os.Getppid()) | ||
if err != nil { | ||
log.Fatalf("Error: %s", err.Error()) | ||
} | ||
|
||
connections, err := getTCP4Connections(sockets) | ||
if err != nil { | ||
log.Fatalf("Error: %s", err.Error()) | ||
} | ||
|
||
if len(connections) != 1 { | ||
log.Fatalf("Unable to guess remote IP. %d results returned", len(connections)) | ||
} | ||
|
||
q.Add("remote_ip", connections[0].String()) | ||
} | ||
|
||
q.Add("service_hostname", hostname) | ||
|
||
if port != 0 && port != 22 { | ||
q.Add("service_port", strconv.Itoa(int(port))) | ||
} | ||
|
||
if fingerprint != "" { | ||
q.Add("fingerprint", fingerprint) | ||
} | ||
|
||
if home != "" { | ||
q.Add("home", home) | ||
} | ||
|
||
if key != "" { | ||
q.Add("key", key) | ||
} | ||
|
||
if keytype != "" { | ||
q.Add("keytype", keytype) | ||
} | ||
|
||
if uid != "" { | ||
q.Add("uid", uid) | ||
} | ||
|
||
if username != "" { | ||
q.Add("username", username) | ||
} | ||
|
||
req.URL.RawQuery = q.Encode() | ||
|
||
resp, err := httpDo(req) | ||
if err != nil { | ||
log.Fatalf("HTTP error: %s", err.Error()) | ||
} | ||
defer resp.Body.Close() | ||
|
||
_, err = io.Copy(os.Stdout, resp.Body) | ||
if err != nil { | ||
log.Fatalf("Error copying response to stdout: %s", err.Error()) | ||
} | ||
} | ||
|
||
func getOpenSockets(pid int) ([]int, error) { | ||
t := regexp.MustCompile(`socket:\[([0-9]*)\]`) | ||
|
||
fdPath := fmt.Sprintf("/proc/%d/fd", pid) | ||
|
||
f, err := os.Open(fdPath) | ||
if err != nil { | ||
return nil, err | ||
} | ||
defer f.Close() | ||
|
||
names, err := f.Readdirnames(-1) | ||
if err != nil { | ||
return nil, err | ||
} | ||
|
||
var sockets []int | ||
|
||
for _, name := range names { | ||
resolved, err := os.Readlink(fdPath + "/" + name) | ||
if err != nil { | ||
continue | ||
} | ||
|
||
// Check if it's a socket. | ||
matches := t.FindSubmatch([]byte(resolved)) | ||
|
||
if len(matches) > 1 { | ||
i, err := strconv.Atoi(string(matches[1])) | ||
if err != nil { | ||
continue | ||
} | ||
|
||
sockets = append(sockets, i) | ||
} | ||
} | ||
|
||
return sockets, nil | ||
} | ||
|
||
func hexToIP(hex string) (*net.IPAddr, error) { | ||
dec, err := strconv.ParseInt(hex, 16, 64) | ||
if err != nil { | ||
return nil, err | ||
} | ||
|
||
// Convert to network order and construct net.IP. | ||
ip := net.IP{byte(dec & 0xff), byte(dec >> 8 & 0xff), byte(dec >> 16 & 0xff), byte(dec >> 24 & 0xff)} | ||
|
||
return &net.IPAddr{IP: ip}, nil | ||
} | ||
|
||
func getTCP4Connections(sockets []int) ([]net.Addr, error) { | ||
t := regexp.MustCompile(`\w[0-9]*\:\ [0-9A-F]{8}:[0-9A-F]{4} ([0-9A-F]{8}):[0-9A-F]{4} [0-9A-F]{2} [0-9A-F]{8}:[0-9A-F]{8} [0-9A-F]{2}:[0-9A-F]{8} [0-9A-F]{8} *[0-9]* *[0-9] ([0-9]*)`) | ||
|
||
f, err := os.Open("/proc/net/tcp") | ||
if err != nil { | ||
return nil, err | ||
} | ||
defer f.Close() | ||
|
||
scanner := bufio.NewScanner(f) | ||
|
||
// Throw away the first line containing headers. | ||
scanner.Scan() | ||
|
||
var connections []net.Addr | ||
|
||
for scanner.Scan() { | ||
line := scanner.Bytes() | ||
matches := t.FindSubmatch(line) | ||
|
||
if len(matches) > 2 { | ||
inode, err := strconv.Atoi(string(matches[2])) | ||
if err != nil { | ||
return nil, err | ||
} | ||
|
||
for _, s := range sockets { | ||
if s == inode { | ||
ip, err := hexToIP(string(matches[1])) | ||
if err != nil { | ||
return nil, err | ||
} | ||
|
||
connections = append(connections, ip) | ||
} | ||
} | ||
} | ||
} | ||
|
||
if err := scanner.Err(); err != nil { | ||
return nil, err | ||
} | ||
|
||
return connections, nil | ||
} | ||
|
||
func main() { | ||
err := rootCmd.Execute() | ||
if err != nil { | ||
log.Fatalf("Error: %s", err.Error()) | ||
} | ||
} |