Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2accc7abd4 | ||
|
|
3c10578584 | ||
|
|
511470087b | ||
|
|
017ee2ab39 | ||
|
|
b093f61fb5 | ||
|
|
bd158819d3 | ||
|
|
86f6e87efe | ||
|
|
e377cac8e6 | ||
|
|
0fbcc0dd41 | ||
|
|
1fdf37dc07 | ||
|
|
4cf73e3410 | ||
|
|
328bb0153b | ||
|
|
1ddd6867b6 | ||
|
|
2becd5eec2 | ||
|
|
571b37da6b | ||
|
|
01d464f4c5 | ||
|
|
bf184c621d | ||
|
|
f4309f843b | ||
|
|
cbdc231cbf | ||
|
|
0f0a8dd9bb | ||
|
|
4189eb8154 | ||
|
|
1d6349767d |
15
CHANGELOG.md
15
CHANGELOG.md
@@ -1,5 +1,20 @@
|
||||
# Changelog
|
||||
|
||||
## v1.5.0 (2017-12-02)
|
||||
|
||||
* Create Session objects on each connections (history)
|
||||
* Connection history
|
||||
* Audit log
|
||||
* Add dynamic strict host key checking (learning on the first time, strict on the next ones)
|
||||
* Add-back MySQL support (experimental)
|
||||
* Fix some backup/restore bugs
|
||||
|
||||
## v1.4.0 (2017-11-24)
|
||||
|
||||
* Add 'key setup' command (easy SSH key installation)
|
||||
* Add Updated and Created fields in 'ls' commands
|
||||
* Add `--aes-key` option to encrypt sensitive data
|
||||
|
||||
## v1.3.0 (2017-11-23)
|
||||
|
||||
* More details in 'ls' commands
|
||||
|
||||
3
Makefile
3
Makefile
@@ -4,6 +4,7 @@ GIT_BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD)
|
||||
LDFLAGS ?= -X main.GIT_SHA=$(GIT_SHA) -X main.GIT_TAG=$(GIT_TAG) -X main.GIT_BRANCH=$(GIT_BRANCH)
|
||||
VERSION ?= $(shell grep 'VERSION =' main.go | cut -d'"' -f2)
|
||||
PORT ?= 2222
|
||||
AES_KEY ?= my-dummy-aes-key
|
||||
|
||||
.PHONY: install
|
||||
install:
|
||||
@@ -24,7 +25,7 @@ _docker_install:
|
||||
.PHONY: dev
|
||||
dev:
|
||||
-go get github.com/githubnemo/CompileDaemon
|
||||
CompileDaemon -exclude-dir=.git -exclude=".#*" -color=true -command="./sshportal --demo --debug --bind-address=:$(PORT)" .
|
||||
CompileDaemon -exclude-dir=.git -exclude=".#*" -color=true -command="./sshportal --debug --bind-address=:$(PORT) --aes-key=$(AES_KEY) $(EXTRA_RUN_OPTS)" .
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
|
||||
105
README.md
105
README.md
@@ -31,6 +31,13 @@ Jump host/Jump server without the jump, a.k.a Transparent SSH bastion
|
||||
* ACL management
|
||||
* Connect to host using key or password
|
||||
* Admin commands can be run directly or in an interactive shell
|
||||
* User Roles
|
||||
* User invitations
|
||||
* Easy authorized_keys installation
|
||||
* Sensitive data encryption
|
||||
* Session management
|
||||
* Audit log
|
||||
* Host Keys verifications shared across users
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -46,7 +53,7 @@ Link your SSH key with the admin account
|
||||
|
||||
```console
|
||||
$ ssh localhost -p 2222 -l invite:BpLnfgDsc2WD8F2q
|
||||
Welcome Administrator!
|
||||
Welcome admin!
|
||||
|
||||
Your key is now associated with the user "admin@sshportal".
|
||||
Shared connection to localhost closed.
|
||||
@@ -80,28 +87,18 @@ List hosts
|
||||
|
||||
```console
|
||||
config> host ls
|
||||
ID | NAME | URL | KEY | PASS | GROUPS | COMMENT
|
||||
+----+------+-------------------------+---------+------+--------+---------+
|
||||
1 | foo | bart@foo.example.org:22 | default | | 1 |
|
||||
ID | NAME | URL | KEY | PASS | GROUPS | COMMENT
|
||||
+----+------+-------------------------+---------+------+---------+---------+
|
||||
1 | foo | bart@foo.example.org:22 | default | | default |
|
||||
Total: 1 hosts.
|
||||
config>
|
||||
```
|
||||
|
||||
Get the default key in authorized_keys format
|
||||
Add the key to the server
|
||||
|
||||
```console
|
||||
config> key inspect default
|
||||
[...]
|
||||
"PubKey": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCvUP/8FedyIe+a+RWU4KvJ1+iZwtWmY9czJubLwN4RcjKHQMzLqWC7pKZHAABCZjLJjVD/3Zb53jZwbh7mysAkocundMpvUL5+Yb4a8lDiflXkdXT9fZCx+ibJBk4jRnKLGIneSzVtFEerEwQKKnKQoCgPkZwCDaL/jHhDlOmAvxqAJrjiy42HXwppX2UuF8zujs6OKHRYJ/Q1vo0caa6/o1eoyXE9OrOwIk+IcAN3YIQi/B1BOlZOQBzHIZz83AFlD2TcPhyYcbxPyKGih84Zr3rQaaP1WiaiPqxzp3s5OhTLthc5XtCSLzmRSLvgC2eFdNhBDB5KLtO2khBkz5ID",
|
||||
[...]
|
||||
config>
|
||||
```
|
||||
|
||||
Add this key to the server
|
||||
|
||||
```console
|
||||
$ ssh bart@foo.example.org
|
||||
> umask 077; mkdir -p .ssh; echo ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCvUP/8FedyIe+a+RWU4KvJ1+iZwtWmY9czJubLwN4RcjKHQMzLqWC7pKZHAABCZjLJjVD/3Zb53jZwbh7mysAkocundMpvUL5+Yb4a8lDiflXkdXT9fZCx+ibJBk4jRnKLGIneSzVtFEerEwQKKnKQoCgPkZwCDaL/jHhDlOmAvxqAJrjiy42HXwppX2UuF8zujs6OKHRYJ/Q1vo0caa6/o1eoyXE9OrOwIk+IcAN3YIQi/B1BOlZOQBzHIZz83AFlD2TcPhyYcbxPyKGih84Zr3rQaaP1WiaiPqxzp3s5OhTLthc5XtCSLzmRSLvgC2eFdNhBDB5KLtO2khBkz5ID >> .ssh/authorized_keys
|
||||
$ ssh bart@foo.example.org "$(ssh localhost -p 2222 -l admin key setup default)"
|
||||
$
|
||||
```
|
||||
|
||||
Profit
|
||||
@@ -122,7 +119,7 @@ config>
|
||||
|
||||
## CLI
|
||||
|
||||
sshportal embeds a configuration CLI.
|
||||
`sshportal` embeds a configuration CLI.
|
||||
|
||||
By default, the configuration user is `admin`, (can be changed using `--config-user=<value>` when starting the server.
|
||||
|
||||
@@ -147,16 +144,21 @@ acl update [-h] [--comment=<value>] [--action=<value>] [--weight=<value>] [--ass
|
||||
|
||||
# config management
|
||||
config help
|
||||
config backup [-h] [--indent]
|
||||
config restore [-h] [--confirm]
|
||||
config backup [-h] [--indent] [--decrypt]
|
||||
config restore [-h] [--confirm] [--decrypt]
|
||||
|
||||
# event management
|
||||
event help
|
||||
event ls [-h]
|
||||
event inspect [-h] EVENT...
|
||||
|
||||
# host management
|
||||
host help
|
||||
host create [-h] [--name=<value>] [--password=<value>] [--fingerprint=<value>] [--comment=<value>] [--key=KEY] [--group=HOSTGROUP...] <username>[:<password>]@<host>[:<port>]
|
||||
host inspect [-h] HOST...
|
||||
host create [-h] [--name=<value>] [--password=<value>] [--comment=<value>] [--key=KEY] [--group=HOSTGROUP...] <username>[:<password>]@<host>[:<port>]
|
||||
host inspect [-h] [--decrypt] HOST...
|
||||
host ls [-h]
|
||||
host rm [-h] HOST...
|
||||
host update [-h] [--name=<value>] [--comment=<value>] [--fingerprint=<value>] [--key=KEY] [--assign-group=HOSTGROUP...] [--unassign-group=HOSTGROUP...] HOST...
|
||||
host update [-h] [--name=<value>] [--comment=<value>] [--key=KEY] [--assign-group=HOSTGROUP...] [--unassign-group=HOSTGROUP...] HOST...
|
||||
|
||||
# hostgroup management
|
||||
hostgroup help
|
||||
@@ -168,9 +170,15 @@ hostgroup rm [-h] HOSTGROUP...
|
||||
# key management
|
||||
key help
|
||||
key create [-h] [--name=<value>] [--type=<value>] [--length=<value>] [--comment=<value>]
|
||||
key inspect [-h] KEY...
|
||||
key inspect [-h] [--decrypt] KEY...
|
||||
key ls [-h]
|
||||
key rm [-h] KEY...
|
||||
key setup [-h] KEY
|
||||
|
||||
# session management
|
||||
session help
|
||||
session ls [-h]
|
||||
session inspect [-h] SESSION...
|
||||
|
||||
# user management
|
||||
user help
|
||||
@@ -203,7 +211,7 @@ An [automated build is setup on the Docker Hub](https://hub.docker.com/r/moul/ss
|
||||
```console
|
||||
# Start a server in background
|
||||
# mount `pwd` to persist the sqlite database file
|
||||
docker run -p 2222:2222 -d --name=sshportal -v "$(pwd):$(pwd)" -w "$(pwd)" moul/sshportal:v1.3.0
|
||||
docker run -p 2222:2222 -d --name=sshportal -v "$(pwd):$(pwd)" -w "$(pwd)" moul/sshportal:v1.5.0
|
||||
|
||||
# check logs (mandatory on first run to get the administrator invite token)
|
||||
docker logs -f sshportal
|
||||
@@ -212,7 +220,7 @@ docker logs -f sshportal
|
||||
The easier way to upgrade sshportal is to do the following:
|
||||
|
||||
```sh
|
||||
# we consider you were using the version v1.2.0 and you want to use the new version v1.3.0
|
||||
# we consider you were using an old version and you want to use the new version v1.5.0
|
||||
|
||||
# stop and rename the last working container + backup the database
|
||||
docker stop sshportal
|
||||
@@ -220,7 +228,7 @@ docker rename sshportal sshportal_old
|
||||
cp sshportal.db sshportal.db.bkp
|
||||
|
||||
# run the new version
|
||||
docker run -p 2222:2222 -d --name=sshportal -v "$(pwd):$(pwd)" -w "$(pwd)" moul/sshportal:v1.3.0
|
||||
docker run -p 2222:2222 -d --name=sshportal -v "$(pwd):$(pwd)" -w "$(pwd)" moul/sshportal:v1.5.0
|
||||
# check the logs for migration or cross-version incompabitility errors
|
||||
docker logs -f sshportal
|
||||
```
|
||||
@@ -246,16 +254,39 @@ Get the latest version using GO.
|
||||
go get -u github.com/moul/sshportal
|
||||
```
|
||||
|
||||
## portal alias (.ssh/config)
|
||||
|
||||
Edit your `~/.ssh/config` file (create it first if needed)
|
||||
|
||||
```ini
|
||||
Host portal
|
||||
User admin
|
||||
Port 2222 # portal port
|
||||
HostName 127.0.0.1 # portal hostname
|
||||
```
|
||||
|
||||
```bash
|
||||
# you can now run a shell using this:
|
||||
ssh portal
|
||||
# instead of this:
|
||||
ssh localhost -p 2222 -l admin
|
||||
|
||||
# or connect to hosts using this:
|
||||
ssh hostname@portal
|
||||
# instead of this:
|
||||
ssh localhost -p 2222 -l hostname
|
||||
```
|
||||
|
||||
## Backup / Restore
|
||||
|
||||
sshportal embeds built-in backup/restore methods which basically import/export JSON objects:
|
||||
|
||||
```sh
|
||||
# Backup
|
||||
ssh admin@sshportal config backup > sshportal.bkp
|
||||
ssh portal config backup > sshportal.bkp
|
||||
|
||||
# Restore
|
||||
ssh admin@sshportal config restore < sshportal.bkp
|
||||
ssh portal config restore < sshportal.bkp
|
||||
```
|
||||
|
||||
This method is particularly useful as it should be resistant against future DB schema changes (expected during development phase).
|
||||
@@ -269,3 +300,19 @@ sqlite3 sshportal.db .dump > sshportal.sql.bkp
|
||||
# or just the immortal cp
|
||||
cp sshportal.db sshportal.db.bkp
|
||||
```
|
||||
|
||||
## Demo data
|
||||
|
||||
The following servers are freely available, without external registration,
|
||||
it makes it easier to quickly test `sshportal` without configuring your own servers to accept sshportal connections.
|
||||
|
||||
```
|
||||
ssh portal host create new@sdf.org
|
||||
ssh sdf@portal
|
||||
|
||||
ssh portal host create test@whoami.filippo.io
|
||||
ssh whoami@portal
|
||||
|
||||
ssh portal host create test@chat.shazow.net
|
||||
ssh chat@portal
|
||||
```
|
||||
|
||||
83
crypto.go
83
crypto.go
@@ -2,11 +2,15 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
gossh "golang.org/x/crypto/ssh"
|
||||
@@ -47,3 +51,82 @@ func NewSSHKey(keyType string, length uint) (*SSHKey, error) {
|
||||
|
||||
return &key, nil
|
||||
}
|
||||
|
||||
func encrypt(key []byte, text string) (string, error) {
|
||||
plaintext := []byte(text)
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ciphertext := make([]byte, aes.BlockSize+len(plaintext))
|
||||
iv := ciphertext[:aes.BlockSize]
|
||||
if _, err := io.ReadFull(rand.Reader, iv); err != nil {
|
||||
return "", err
|
||||
}
|
||||
stream := cipher.NewCFBEncrypter(block, iv)
|
||||
stream.XORKeyStream(ciphertext[aes.BlockSize:], plaintext)
|
||||
return base64.URLEncoding.EncodeToString(ciphertext), nil
|
||||
}
|
||||
|
||||
func decrypt(key []byte, cryptoText string) (string, error) {
|
||||
ciphertext, _ := base64.URLEncoding.DecodeString(cryptoText)
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(ciphertext) < aes.BlockSize {
|
||||
return "", fmt.Errorf("ciphertext too short")
|
||||
}
|
||||
iv := ciphertext[:aes.BlockSize]
|
||||
ciphertext = ciphertext[aes.BlockSize:]
|
||||
stream := cipher.NewCFBDecrypter(block, iv)
|
||||
stream.XORKeyStream(ciphertext, ciphertext)
|
||||
return fmt.Sprintf("%s", ciphertext), nil
|
||||
}
|
||||
|
||||
func safeDecrypt(key []byte, cryptoText string) string {
|
||||
if len(key) == 0 {
|
||||
return cryptoText
|
||||
}
|
||||
out, err := decrypt(key, cryptoText)
|
||||
if err != nil {
|
||||
return cryptoText
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func HostEncrypt(aesKey string, host *Host) error {
|
||||
if aesKey == "" {
|
||||
return nil
|
||||
}
|
||||
var err error
|
||||
if host.Password != "" {
|
||||
if host.Password, err = encrypt([]byte(aesKey), host.Password); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func HostDecrypt(aesKey string, host *Host) {
|
||||
if aesKey == "" {
|
||||
return
|
||||
}
|
||||
if host.Password != "" {
|
||||
host.Password = safeDecrypt([]byte(aesKey), host.Password)
|
||||
}
|
||||
}
|
||||
|
||||
func SSHKeyEncrypt(aesKey string, key *SSHKey) error {
|
||||
if aesKey == "" {
|
||||
return nil
|
||||
}
|
||||
var err error
|
||||
key.PrivKey, err = encrypt([]byte(aesKey), key.PrivKey)
|
||||
return err
|
||||
}
|
||||
func SSHKeyDecrypt(aesKey string, key *SSHKey) {
|
||||
if aesKey == "" {
|
||||
return
|
||||
}
|
||||
key.PrivKey = safeDecrypt([]byte(aesKey), key.PrivKey)
|
||||
}
|
||||
|
||||
113
db.go
113
db.go
@@ -1,7 +1,10 @@
|
||||
//go:generate stringer -type=SessionStatus
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -21,7 +24,10 @@ type Config struct {
|
||||
HostGroups []*HostGroup `json:"host_groups"`
|
||||
ACLs []*ACL `json:"acls"`
|
||||
Settings []*Setting `json:"settings"`
|
||||
Date time.Time `json:"date"`
|
||||
Events []*Event `json:"events"`
|
||||
Sessions []*Session `json:"sessions"`
|
||||
// FIXME: add latest migration
|
||||
Date time.Time `json:"date"`
|
||||
}
|
||||
|
||||
type Setting struct {
|
||||
@@ -30,6 +36,7 @@ type Setting struct {
|
||||
Value string `valid:"required"`
|
||||
}
|
||||
|
||||
// SSHKey defines a ssh client key (used by sshportal to connect to remote hosts)
|
||||
type SSHKey struct {
|
||||
// FIXME: use uuid for ID
|
||||
gorm.Model
|
||||
@@ -46,23 +53,25 @@ type SSHKey struct {
|
||||
type Host struct {
|
||||
// FIXME: use uuid for ID
|
||||
gorm.Model
|
||||
Name string `gorm:"size:32" valid:"required,length(1|32),unix_user"`
|
||||
Addr string `valid:"required"`
|
||||
User string `valid:"optional"`
|
||||
Password string `valid:"optional"`
|
||||
SSHKey *SSHKey `gorm:"ForeignKey:SSHKeyID"`
|
||||
SSHKeyID uint `gorm:"index"`
|
||||
Groups []*HostGroup `gorm:"many2many:host_host_groups;"`
|
||||
Fingerprint string `valid:"optional"` // FIXME: replace with hostKey ?
|
||||
Comment string `valid:"optional"`
|
||||
Name string `gorm:"size:32" valid:"required,length(1|32),unix_user"`
|
||||
Addr string `valid:"required"`
|
||||
User string `valid:"optional"`
|
||||
Password string `valid:"optional"`
|
||||
SSHKey *SSHKey `gorm:"ForeignKey:SSHKeyID"` // SSHKey used to connect by the client
|
||||
SSHKeyID uint `gorm:"index"`
|
||||
HostKey []byte `sql:"size:10000" valid:"optional"`
|
||||
Groups []*HostGroup `gorm:"many2many:host_host_groups;"`
|
||||
Comment string `valid:"optional"`
|
||||
}
|
||||
|
||||
// UserKey defines a user public key used by sshportal to identify the user
|
||||
type UserKey struct {
|
||||
gorm.Model
|
||||
Key []byte `sql:"size:10000" valid:"required,length(1|10000)"`
|
||||
UserID uint ``
|
||||
User *User `gorm:"ForeignKey:UserID"`
|
||||
Comment string `valid:"optional"`
|
||||
Key []byte `sql:"size:10000" valid:"required,length(1|10000)"`
|
||||
AuthorizedKey string `sql:"size:10000" valid:"required,length(1|10000)"`
|
||||
UserID uint ``
|
||||
User *User `gorm:"ForeignKey:UserID"`
|
||||
Comment string `valid:"optional"`
|
||||
}
|
||||
|
||||
type UserRole struct {
|
||||
@@ -109,6 +118,37 @@ type ACL struct {
|
||||
Comment string `valid:"optional"`
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
gorm.Model
|
||||
StoppedAt *time.Time `sql:"index" valid:"optional"`
|
||||
Status string `valid:"required"`
|
||||
User *User `gorm:"ForeignKey:UserID"`
|
||||
Host *Host `gorm:"ForeignKey:HostID"`
|
||||
UserID uint `valid:"optional"`
|
||||
HostID uint `valid:"optional"`
|
||||
ErrMsg string `valid:"optional"`
|
||||
Comment string `valid:"optional"`
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
gorm.Model
|
||||
Author *User `gorm:"ForeignKey:AuthorID"`
|
||||
AuthorID uint `valid:"optional"`
|
||||
Domain string `valid:"required"`
|
||||
Action string `valid:"required"`
|
||||
Entity string `valid:"optional"`
|
||||
Args []byte `sql:"size:10000" valid:"optional,length(1|10000)" json:"-"`
|
||||
ArgsMap map[string]interface{} `gorm:"-" json:"Args"`
|
||||
}
|
||||
|
||||
type SessionStatus string
|
||||
|
||||
const (
|
||||
SessionStatusUnknown SessionStatus = "unknown"
|
||||
SessionStatusActive = "active"
|
||||
SessionStatusClosed = "closed"
|
||||
)
|
||||
|
||||
func init() {
|
||||
unixUserRegexp := regexp.MustCompile("[a-z_][a-z0-9_-]*")
|
||||
|
||||
@@ -244,3 +284,48 @@ func UserRolesPreload(db *gorm.DB) *gorm.DB {
|
||||
func UserRolesByIdentifiers(db *gorm.DB, identifiers []string) *gorm.DB {
|
||||
return db.Where("id IN (?)", identifiers).Or("name IN (?)", identifiers)
|
||||
}
|
||||
|
||||
// Session helpers
|
||||
func SessionsPreload(db *gorm.DB) *gorm.DB {
|
||||
return db.Preload("User").Preload("Host")
|
||||
}
|
||||
func SessionsByIdentifiers(db *gorm.DB, identifiers []string) *gorm.DB {
|
||||
return db.Where("id IN (?)", identifiers)
|
||||
}
|
||||
|
||||
// Events helpers
|
||||
func EventsPreload(db *gorm.DB) *gorm.DB {
|
||||
return db.Preload("Author")
|
||||
}
|
||||
func EventsByIdentifiers(db *gorm.DB, identifiers []string) *gorm.DB {
|
||||
return db.Where("id IN (?)", identifiers)
|
||||
}
|
||||
|
||||
func NewEvent(domain, action string) *Event {
|
||||
return &Event{
|
||||
Domain: domain,
|
||||
Action: action,
|
||||
ArgsMap: map[string]interface{}{},
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Event) Log(db *gorm.DB) {
|
||||
if len(e.ArgsMap) > 0 {
|
||||
e.Args, _ = json.Marshal(e.ArgsMap)
|
||||
}
|
||||
log.Printf("event: %v", e)
|
||||
if err := db.Create(e).Error; err != nil {
|
||||
log.Printf("warning: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Event) SetAuthor(user *User) *Event {
|
||||
e.Author = user
|
||||
e.AuthorID = user.ID
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *Event) SetArg(name string, value interface{}) *Event {
|
||||
e.ArgsMap[name] = value
|
||||
return e
|
||||
}
|
||||
|
||||
206
dbinit.go
206
dbinit.go
@@ -4,12 +4,16 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/go-gormigrate/gormigrate"
|
||||
"github.com/jinzhu/gorm"
|
||||
gossh "golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
func dbInit(db *gorm.DB) error {
|
||||
db.Callback().Delete().Replace("gorm:delete", hardDeleteCallback)
|
||||
|
||||
m := gormigrate.New(db, gormigrate.DefaultOptions, []*gormigrate.Migration{
|
||||
{
|
||||
ID: "1",
|
||||
@@ -24,8 +28,7 @@ func dbInit(db *gorm.DB) error {
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return tx.DropTable("settings").Error
|
||||
},
|
||||
},
|
||||
{
|
||||
}, {
|
||||
ID: "2",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
type SSHKey struct {
|
||||
@@ -152,7 +155,7 @@ func dbInit(db *gorm.DB) error {
|
||||
ID: "9",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
db.Model(&Setting{}).RemoveIndex("uix_settings_name")
|
||||
return db.Model(&Setting{}).Where(`"deleted_at" IS NULL`).AddUniqueIndex("uix_settings_name", "name").Error
|
||||
return db.Model(&Setting{}).AddUniqueIndex("uix_settings_name", "name").Error
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return db.Model(&Setting{}).RemoveIndex("uix_settings_name").Error
|
||||
@@ -161,7 +164,7 @@ func dbInit(db *gorm.DB) error {
|
||||
ID: "10",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
db.Model(&SSHKey{}).RemoveIndex("uix_keys_name")
|
||||
return db.Model(&SSHKey{}).Where(`"deleted_at" IS NULL`).AddUniqueIndex("uix_keys_name", "name").Error
|
||||
return db.Model(&SSHKey{}).AddUniqueIndex("uix_keys_name", "name").Error
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return db.Model(&SSHKey{}).RemoveIndex("uix_keys_name").Error
|
||||
@@ -170,7 +173,7 @@ func dbInit(db *gorm.DB) error {
|
||||
ID: "11",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
db.Model(&Host{}).RemoveIndex("uix_hosts_name")
|
||||
return db.Model(&Host{}).Where(`"deleted_at" IS NULL`).AddUniqueIndex("uix_hosts_name", "name").Error
|
||||
return db.Model(&Host{}).AddUniqueIndex("uix_hosts_name", "name").Error
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return db.Model(&Host{}).RemoveIndex("uix_hosts_name").Error
|
||||
@@ -179,7 +182,7 @@ func dbInit(db *gorm.DB) error {
|
||||
ID: "12",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
db.Model(&User{}).RemoveIndex("uix_users_name")
|
||||
return db.Model(&User{}).Where(`"deleted_at" IS NULL`).AddUniqueIndex("uix_users_name", "name").Error
|
||||
return db.Model(&User{}).AddUniqueIndex("uix_users_name", "name").Error
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return db.Model(&User{}).RemoveIndex("uix_users_name").Error
|
||||
@@ -188,7 +191,7 @@ func dbInit(db *gorm.DB) error {
|
||||
ID: "13",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
db.Model(&UserGroup{}).RemoveIndex("uix_usergroups_name")
|
||||
return db.Model(&UserGroup{}).Where(`"deleted_at" IS NULL`).AddUniqueIndex("uix_usergroups_name", "name").Error
|
||||
return db.Model(&UserGroup{}).AddUniqueIndex("uix_usergroups_name", "name").Error
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return db.Model(&UserGroup{}).RemoveIndex("uix_usergroups_name").Error
|
||||
@@ -197,7 +200,7 @@ func dbInit(db *gorm.DB) error {
|
||||
ID: "14",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
db.Model(&HostGroup{}).RemoveIndex("uix_hostgroups_name")
|
||||
return db.Model(&HostGroup{}).Where(`"deleted_at" IS NULL`).AddUniqueIndex("uix_hostgroups_name", "name").Error
|
||||
return db.Model(&HostGroup{}).AddUniqueIndex("uix_hostgroups_name", "name").Error
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return db.Model(&HostGroup{}).RemoveIndex("uix_hostgroups_name").Error
|
||||
@@ -292,11 +295,148 @@ func dbInit(db *gorm.DB) error {
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return tx.Where("name = ?", "listhosts").Delete(&UserRole{}).Error
|
||||
},
|
||||
}, {
|
||||
ID: "21",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
type Session struct {
|
||||
gorm.Model
|
||||
StoppedAt time.Time `valid:"optional"`
|
||||
Status string `valid:"required"`
|
||||
User *User `gorm:"ForeignKey:UserID"`
|
||||
Host *Host `gorm:"ForeignKey:HostID"`
|
||||
UserID uint `valid:"optional"`
|
||||
HostID uint `valid:"optional"`
|
||||
ErrMsg string `valid:"optional"`
|
||||
Comment string `valid:"optional"`
|
||||
}
|
||||
return tx.AutoMigrate(&Session{}).Error
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return tx.DropTable("sessions").Error
|
||||
},
|
||||
}, {
|
||||
ID: "22",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
type Event struct {
|
||||
gorm.Model
|
||||
Author *User `gorm:"ForeignKey:AuthorID"`
|
||||
AuthorID uint `valid:"optional"`
|
||||
Domain string `valid:"required"`
|
||||
Action string `valid:"required"`
|
||||
Entity string `valid:"optional"`
|
||||
Args []byte `sql:"size:10000" valid:"optional,length(1|10000)"`
|
||||
}
|
||||
return tx.AutoMigrate(&Event{}).Error
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return tx.DropTable("events").Error
|
||||
},
|
||||
}, {
|
||||
ID: "23",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
type UserKey struct {
|
||||
gorm.Model
|
||||
Key []byte `sql:"size:10000" valid:"required,length(1|10000)"`
|
||||
AuthorizedKey string `sql:"size:10000" valid:"required,length(1|10000)"`
|
||||
UserID uint ``
|
||||
User *User `gorm:"ForeignKey:UserID"`
|
||||
Comment string `valid:"optional"`
|
||||
}
|
||||
return tx.AutoMigrate(&UserKey{}).Error
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return fmt.Errorf("not implemented")
|
||||
},
|
||||
}, {
|
||||
ID: "24",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
var userKeys []UserKey
|
||||
if err := db.Find(&userKeys).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, userKey := range userKeys {
|
||||
key, err := gossh.ParsePublicKey(userKey.Key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
userKey.AuthorizedKey = string(gossh.MarshalAuthorizedKey(key))
|
||||
if err := db.Model(&userKey).Updates(&userKey).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return fmt.Errorf("not implemented")
|
||||
},
|
||||
}, {
|
||||
ID: "25",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
type Host struct {
|
||||
// FIXME: use uuid for ID
|
||||
gorm.Model
|
||||
Name string `gorm:"size:32" valid:"required,length(1|32),unix_user"`
|
||||
Addr string `valid:"required"`
|
||||
User string `valid:"optional"`
|
||||
Password string `valid:"optional"`
|
||||
SSHKey *SSHKey `gorm:"ForeignKey:SSHKeyID"` // SSHKey used to connect by the client
|
||||
SSHKeyID uint `gorm:"index"`
|
||||
HostKey []byte `sql:"size:10000" valid:"optional"`
|
||||
Groups []*HostGroup `gorm:"many2many:host_host_groups;"`
|
||||
Fingerprint string `valid:"optional"` // FIXME: replace with hostKey ?
|
||||
Comment string `valid:"optional"`
|
||||
}
|
||||
return tx.AutoMigrate(&Host{}).Error
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return fmt.Errorf("not implemented")
|
||||
},
|
||||
}, {
|
||||
ID: "26",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
type Session struct {
|
||||
gorm.Model
|
||||
StoppedAt *time.Time `sql:"index" valid:"optional"`
|
||||
Status string `valid:"required"`
|
||||
User *User `gorm:"ForeignKey:UserID"`
|
||||
Host *Host `gorm:"ForeignKey:HostID"`
|
||||
UserID uint `valid:"optional"`
|
||||
HostID uint `valid:"optional"`
|
||||
ErrMsg string `valid:"optional"`
|
||||
Comment string `valid:"optional"`
|
||||
}
|
||||
return tx.AutoMigrate(&Session{}).Error
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return fmt.Errorf("not implemented")
|
||||
},
|
||||
}, {
|
||||
ID: "27",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
var sessions []Session
|
||||
if err := db.Find(&sessions).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, session := range sessions {
|
||||
if session.StoppedAt != nil && session.StoppedAt.IsZero() {
|
||||
if err := db.Model(&session).Updates(map[string]interface{}{"stopped_at": nil}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return fmt.Errorf("not implemented")
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := m.Migrate(); err != nil {
|
||||
return err
|
||||
}
|
||||
NewEvent("system", "migrated").Log(db)
|
||||
|
||||
// create default ssh key
|
||||
var count uint
|
||||
@@ -380,7 +520,7 @@ func dbInit(db *gorm.DB) error {
|
||||
return err
|
||||
}
|
||||
user := User{
|
||||
Name: "Administrator",
|
||||
Name: "admin",
|
||||
Email: "admin@sshportal",
|
||||
Comment: "created by sshportal",
|
||||
Roles: []*UserRole{&adminRole},
|
||||
@@ -408,29 +548,37 @@ func dbInit(db *gorm.DB) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func dbDemo(db *gorm.DB) error {
|
||||
var hostGroup HostGroup
|
||||
if err := HostGroupsByIdentifiers(db, []string{"default"}).First(&hostGroup).Error; err != nil {
|
||||
// close unclosed connections
|
||||
if err := db.Table("sessions").Where("status = ?", "active").Updates(&Session{
|
||||
Status: SessionStatusClosed,
|
||||
ErrMsg: "sshportal was halted while the connection was still active",
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var key SSHKey
|
||||
if err := SSHKeysByIdentifiers(db, []string{"default"}).First(&key).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var (
|
||||
host1 = Host{Name: "sdf", Addr: "sdf.org:22", User: "new", SSHKeyID: key.ID, Groups: []*HostGroup{&hostGroup}}
|
||||
host2 = Host{Name: "whoami", Addr: "whoami.filippo.io:22", User: "test", SSHKeyID: key.ID, Groups: []*HostGroup{&hostGroup}}
|
||||
host3 = Host{Name: "ssh-chat", Addr: "chat.shazow.net:22", User: "test", SSHKeyID: key.ID, Fingerprint: "MD5:e5:d5:d1:75:90:38:42:f6:c7:03:d7:d0:56:7d:6a:db", Groups: []*HostGroup{&hostGroup}}
|
||||
)
|
||||
|
||||
// FIXME: check if hosts exist to avoid `UNIQUE constraint` error
|
||||
db.FirstOrCreate(&host1)
|
||||
db.FirstOrCreate(&host2)
|
||||
db.FirstOrCreate(&host3)
|
||||
return nil
|
||||
}
|
||||
|
||||
func hardDeleteCallback(scope *gorm.Scope) {
|
||||
if !scope.HasError() {
|
||||
var extraOption string
|
||||
if str, ok := scope.Get("gorm:delete_option"); ok {
|
||||
extraOption = fmt.Sprint(str)
|
||||
}
|
||||
|
||||
scope.Raw(fmt.Sprintf(
|
||||
"DELETE FROM %v%v%v",
|
||||
scope.QuotedTableName(),
|
||||
addExtraSpaceIfExist(scope.CombinedConditionSql()),
|
||||
addExtraSpaceIfExist(extraOption),
|
||||
)).Exec()
|
||||
}
|
||||
}
|
||||
|
||||
func addExtraSpaceIfExist(str string) string {
|
||||
if str != "" {
|
||||
return " " + str
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -76,7 +76,12 @@ xssh -l admin host ls
|
||||
xssh -l admin config backup --indent > backup-1
|
||||
xssh -l admin config restore --confirm < backup-1
|
||||
xssh -l admin config backup --indent > backup-2
|
||||
diff <(cat backup-1 | grep -v '"date":') <(cat backup-2 | grep -v '"date":')
|
||||
(
|
||||
cat backup-1 | grep -v '"date":' > backup-1.clean
|
||||
cat backup-2 | grep -v '"date":' > backup-2.clean
|
||||
set -xe
|
||||
diff backup-1.clean backup-2.clean
|
||||
)
|
||||
|
||||
# post cleanup
|
||||
#cleanup
|
||||
|
||||
67
main.go
67
main.go
@@ -20,7 +20,7 @@ import (
|
||||
|
||||
var (
|
||||
// VERSION should be updated by hand at each release
|
||||
VERSION = "1.3.0"
|
||||
VERSION = "1.5.0"
|
||||
// GIT_TAG will be overwritten automatically by the build system
|
||||
GIT_TAG string
|
||||
// GIT_SHA will be overwritten automatically by the build system
|
||||
@@ -52,15 +52,11 @@ func main() {
|
||||
Value: ":2222",
|
||||
Usage: "SSH server bind address",
|
||||
},
|
||||
cli.BoolFlag{
|
||||
Name: "demo",
|
||||
Usage: "*unsafe* - demo mode: accept all connections",
|
||||
},
|
||||
/*cli.StringFlag{
|
||||
cli.StringFlag{
|
||||
Name: "db-driver",
|
||||
Value: "sqlite3",
|
||||
Usage: "GORM driver (sqlite3)",
|
||||
},*/
|
||||
},
|
||||
cli.StringFlag{
|
||||
Name: "db-conn",
|
||||
Value: "./sshportal.db",
|
||||
@@ -75,6 +71,10 @@ func main() {
|
||||
Usage: "SSH user that spawns a configuration shell",
|
||||
Value: "admin",
|
||||
},
|
||||
cli.StringFlag{
|
||||
Name: "aes-key",
|
||||
Usage: "Encrypt sensitive data in database (length: 16, 24 or 32)",
|
||||
},
|
||||
}
|
||||
app.Action = server
|
||||
if err := app.Run(os.Args); err != nil {
|
||||
@@ -83,8 +83,13 @@ func main() {
|
||||
}
|
||||
|
||||
func server(c *cli.Context) error {
|
||||
switch len(c.String("aes-key")) {
|
||||
case 0, 16, 24, 32:
|
||||
default:
|
||||
return fmt.Errorf("invalid aes key size, should be 16 or 24, 32")
|
||||
}
|
||||
// db
|
||||
db, err := gorm.Open("sqlite3", c.String("db-conn"))
|
||||
db, err := gorm.Open(c.String("db-driver"), c.String("db-conn"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -98,11 +103,6 @@ func server(c *cli.Context) error {
|
||||
if err := dbInit(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if c.Bool("demo") {
|
||||
if err := dbDemo(db); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// ssh server
|
||||
ssh.Handle(func(s ssh.Session) {
|
||||
@@ -152,11 +152,35 @@ func server(c *cli.Context) error {
|
||||
return
|
||||
}
|
||||
|
||||
// decrypt key and password
|
||||
HostDecrypt(c.String("aes-key"), host)
|
||||
SSHKeyDecrypt(c.String("aes-key"), host.SSHKey)
|
||||
|
||||
switch action {
|
||||
case "allow":
|
||||
if err := proxy(s, host); err != nil {
|
||||
fmt.Fprintf(s, "error: %v\n", err)
|
||||
sess := Session{
|
||||
UserID: currentUser.ID,
|
||||
HostID: host.ID,
|
||||
Status: SessionStatusActive,
|
||||
}
|
||||
if err := db.Create(&sess).Error; err != nil {
|
||||
fmt.Fprintf(s, "error: %v\n", err)
|
||||
return
|
||||
}
|
||||
err := proxy(s, host, DynamicHostKey(db, host))
|
||||
sessUpdate := Session{}
|
||||
if err != nil {
|
||||
fmt.Fprintf(s, "error: %v\n", err)
|
||||
sessUpdate.ErrMsg = fmt.Sprintf("%v", err)
|
||||
switch sessUpdate.ErrMsg {
|
||||
case "lch closed the connection", "rch closed the connection":
|
||||
sessUpdate.ErrMsg = ""
|
||||
}
|
||||
}
|
||||
sessUpdate.Status = SessionStatusClosed
|
||||
now := time.Now()
|
||||
sessUpdate.StoppedAt = &now
|
||||
db.Model(&sess).Updates(&sessUpdate)
|
||||
case "deny":
|
||||
fmt.Fprintf(s, "You don't have permission to that host.\n")
|
||||
default:
|
||||
@@ -175,7 +199,7 @@ func server(c *cli.Context) error {
|
||||
)
|
||||
|
||||
// lookup user by key
|
||||
db.Where("key = ?", key.Marshal()).First(&userKey)
|
||||
db.Where("authorized_key = ?", string(gossh.MarshalAuthorizedKey(key))).First(&userKey)
|
||||
if userKey.UserID > 0 {
|
||||
db.Preload("Roles").Where("id = ?", userKey.UserID).First(&user)
|
||||
if strings.HasPrefix(username, "invite:") {
|
||||
@@ -193,15 +217,16 @@ func server(c *cli.Context) error {
|
||||
}
|
||||
if user.ID > 0 {
|
||||
userKey = UserKey{
|
||||
UserID: user.ID,
|
||||
Key: key.Marshal(),
|
||||
Comment: "created by sshportal",
|
||||
UserID: user.ID,
|
||||
Key: key.Marshal(),
|
||||
Comment: "created by sshportal",
|
||||
AuthorizedKey: string(gossh.MarshalAuthorizedKey(key)),
|
||||
}
|
||||
db.Create(&userKey)
|
||||
|
||||
// token is only usable once
|
||||
user.InviteToken = ""
|
||||
db.Update(&user)
|
||||
db.Model(&user).Updates(&user)
|
||||
|
||||
ctx.SetValue(messageContextKey, fmt.Sprintf("Welcome %s!\n\nYour key is now associated with the user %q.\n", user.Name, user.Email))
|
||||
ctx.SetValue(userContextKey, user)
|
||||
@@ -223,6 +248,8 @@ func server(c *cli.Context) error {
|
||||
if err := SSHKeysByIdentifiers(db, []string{"host"}).First(&key).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
SSHKeyDecrypt(c.String("aes-key"), &key)
|
||||
|
||||
signer, err := gossh.ParsePrivateKey([]byte(key.PrivKey))
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
10
proxy.go
10
proxy.go
@@ -10,8 +10,8 @@ import (
|
||||
gossh "golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
func proxy(s ssh.Session, host *Host) error {
|
||||
config, err := host.ClientConfig(s)
|
||||
func proxy(s ssh.Session, host *Host, hk gossh.HostKeyCallback) error {
|
||||
config, err := host.ClientConfig(s, hk)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -27,7 +27,7 @@ func proxy(s ssh.Session, host *Host) error {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Println("SSH Connectin established")
|
||||
log.Println("SSH Connection established")
|
||||
return pipe(s.MaskedReqs(), rreqs, s, rch)
|
||||
}
|
||||
|
||||
@@ -76,10 +76,10 @@ func pipe(lreqs, rreqs <-chan *gossh.Request, lch, rch gossh.Channel) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (host *Host) ClientConfig(_ ssh.Session) (*gossh.ClientConfig, error) {
|
||||
func (host *Host) ClientConfig(_ ssh.Session, hk gossh.HostKeyCallback) (*gossh.ClientConfig, error) {
|
||||
config := gossh.ClientConfig{
|
||||
User: host.User,
|
||||
HostKeyCallback: gossh.InsecureIgnoreHostKey(),
|
||||
HostKeyCallback: hk,
|
||||
Auth: []gossh.AuthMethod{},
|
||||
}
|
||||
if host.SSHKey != nil {
|
||||
|
||||
371
shell.go
371
shell.go
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
shlex "github.com/anmitsu/go-shlex"
|
||||
"github.com/asaskevich/govalidator"
|
||||
humanize "github.com/dustin/go-humanize"
|
||||
"github.com/gliderlabs/ssh"
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/moby/moby/pkg/namesgenerator"
|
||||
@@ -58,7 +59,7 @@ GLOBAL OPTIONS:
|
||||
app.Commands = []cli.Command{
|
||||
{
|
||||
Name: "acl",
|
||||
Usage: "Manages acls",
|
||||
Usage: "Manages ACLs",
|
||||
Subcommands: []cli.Command{
|
||||
{
|
||||
Name: "create",
|
||||
@@ -117,7 +118,7 @@ GLOBAL OPTIONS:
|
||||
},
|
||||
}, {
|
||||
Name: "inspect",
|
||||
Usage: "Shows detailed information on one or more acls",
|
||||
Usage: "Shows detailed information on one or more ACLs",
|
||||
ArgsUsage: "ACL...",
|
||||
Action: func(c *cli.Context) error {
|
||||
if c.NArg() < 1 {
|
||||
@@ -138,19 +139,19 @@ GLOBAL OPTIONS:
|
||||
},
|
||||
}, {
|
||||
Name: "ls",
|
||||
Usage: "Lists acls",
|
||||
Usage: "Lists ACLs",
|
||||
Action: func(c *cli.Context) error {
|
||||
if err := UserCheckRoles(myself, []string{"admin"}); err != nil {
|
||||
return err
|
||||
}
|
||||
var acls []ACL
|
||||
if err := db.Preload("UserGroups").Preload("HostGroups").Find(&acls).Error; err != nil {
|
||||
if err := db.Order("created_at desc").Preload("UserGroups").Preload("HostGroups").Find(&acls).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
table := tablewriter.NewWriter(s)
|
||||
table.SetHeader([]string{"ID", "Weight", "User groups", "Host groups", "Host pattern", "Action", "Comment"})
|
||||
table.SetHeader([]string{"ID", "Weight", "User groups", "Host groups", "Host pattern", "Action", "Updated", "Created", "Comment"})
|
||||
table.SetBorder(false)
|
||||
table.SetCaption(true, fmt.Sprintf("Total: %d acls.", len(acls)))
|
||||
table.SetCaption(true, fmt.Sprintf("Total: %d ACLs.", len(acls)))
|
||||
for _, acl := range acls {
|
||||
userGroups := []string{}
|
||||
hostGroups := []string{}
|
||||
@@ -168,6 +169,8 @@ GLOBAL OPTIONS:
|
||||
strings.Join(hostGroups, ", "),
|
||||
acl.HostPattern,
|
||||
acl.Action,
|
||||
humanize.Time(acl.UpdatedAt),
|
||||
humanize.Time(acl.CreatedAt),
|
||||
acl.Comment,
|
||||
})
|
||||
}
|
||||
@@ -176,7 +179,7 @@ GLOBAL OPTIONS:
|
||||
},
|
||||
}, {
|
||||
Name: "rm",
|
||||
Usage: "Removes one or more acls",
|
||||
Usage: "Removes one or more ACLs",
|
||||
ArgsUsage: "ACL...",
|
||||
Action: func(c *cli.Context) error {
|
||||
if c.NArg() < 1 {
|
||||
@@ -274,6 +277,7 @@ GLOBAL OPTIONS:
|
||||
Usage: "Dumps a backup",
|
||||
Flags: []cli.Flag{
|
||||
cli.BoolFlag{Name: "indent", Usage: "uses indented JSON"},
|
||||
cli.BoolFlag{Name: "decrypt", Usage: "decrypt sensitive data"},
|
||||
},
|
||||
Description: "ssh admin@portal config backup > sshportal.bkp",
|
||||
Action: func(c *cli.Context) error {
|
||||
@@ -282,33 +286,62 @@ GLOBAL OPTIONS:
|
||||
}
|
||||
|
||||
config := Config{}
|
||||
if err := db.Find(&config.Hosts).Error; err != nil {
|
||||
if err := HostsPreload(db).Find(&config.Hosts).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.Find(&config.SSHKeys).Error; err != nil {
|
||||
|
||||
if err := SSHKeysPreload(db).Find(&config.SSHKeys).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.Find(&config.Hosts).Error; err != nil {
|
||||
for _, key := range config.SSHKeys {
|
||||
SSHKeyDecrypt(globalContext.String("aes-key"), key)
|
||||
}
|
||||
if !c.Bool("decrypt") {
|
||||
for _, key := range config.SSHKeys {
|
||||
if err := SSHKeyEncrypt(globalContext.String("aes-key"), key); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := HostsPreload(db).Find(&config.Hosts).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.Find(&config.UserKeys).Error; err != nil {
|
||||
for _, host := range config.Hosts {
|
||||
HostDecrypt(globalContext.String("aes-key"), host)
|
||||
}
|
||||
if !c.Bool("decrypt") {
|
||||
for _, host := range config.Hosts {
|
||||
if err := HostEncrypt(globalContext.String("aes-key"), host); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := UserKeysPreload(db).Find(&config.UserKeys).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.Find(&config.Users).Error; err != nil {
|
||||
if err := UsersPreload(db).Find(&config.Users).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.Find(&config.UserGroups).Error; err != nil {
|
||||
if err := UserGroupsPreload(db).Find(&config.UserGroups).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.Find(&config.HostGroups).Error; err != nil {
|
||||
if err := HostGroupsPreload(db).Find(&config.HostGroups).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.Find(&config.ACLs).Error; err != nil {
|
||||
if err := ACLsPreload(db).Find(&config.ACLs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.Find(&config.Settings).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := SessionsPreload(db).Find(&config.Sessions).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := EventsPreload(db).Find(&config.Events).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
config.Date = time.Now()
|
||||
enc := json.NewEncoder(s)
|
||||
if c.Bool("indent") {
|
||||
@@ -322,6 +355,7 @@ GLOBAL OPTIONS:
|
||||
Description: "ssh admin@portal config restore < sshportal.bkp",
|
||||
Flags: []cli.Flag{
|
||||
cli.BoolFlag{Name: "confirm", Usage: "yes, I want to replace everything with this backup!"},
|
||||
cli.BoolFlag{Name: "decrypt", Usage: "do not encrypt sensitive data"},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
if err := UserCheckRoles(myself, []string{"admin"}); err != nil {
|
||||
@@ -344,6 +378,8 @@ GLOBAL OPTIONS:
|
||||
fmt.Fprintf(s, "* %d Userkeys\n", len(config.UserKeys))
|
||||
fmt.Fprintf(s, "* %d Users\n", len(config.Users))
|
||||
fmt.Fprintf(s, "* %d Settings\n", len(config.Settings))
|
||||
fmt.Fprintf(s, "* %d Sessions\n", len(config.Sessions))
|
||||
fmt.Fprintf(s, "* %d Events\n", len(config.Events))
|
||||
|
||||
if !c.Bool("confirm") {
|
||||
fmt.Fprintf(s, "restore will erase and replace everything in the database.\nIf you are ok, add the '--confirm' to the restore command\n")
|
||||
@@ -352,57 +388,109 @@ GLOBAL OPTIONS:
|
||||
|
||||
tx := db.Begin()
|
||||
|
||||
// FIXME: handle different migrations:
|
||||
// 1. drop tables
|
||||
// 2. apply migrations `1` to `<backup-migration-id>`
|
||||
// 3. restore data
|
||||
// 4. continues migrations
|
||||
|
||||
// FIXME: tell the administrator to restart the server
|
||||
// if the master host key changed
|
||||
|
||||
// FIXME: do everything in a transaction
|
||||
for _, tableName := range []string{"hosts", "users", "acls", "host_groups", "user_groups", "ssh_keys", "user_keys", "settings"} {
|
||||
tableNames := []string{
|
||||
"acls",
|
||||
"events",
|
||||
"host_group_acls",
|
||||
"host_groups",
|
||||
"host_host_groups",
|
||||
"hosts",
|
||||
//"migrations",
|
||||
"sessions",
|
||||
"settings",
|
||||
"ssh_keys",
|
||||
"user_group_acls",
|
||||
"user_groups",
|
||||
"user_keys",
|
||||
"user_roles",
|
||||
"user_user_groups",
|
||||
"user_user_roles",
|
||||
"users",
|
||||
}
|
||||
for _, tableName := range tableNames {
|
||||
if err := tx.Exec(fmt.Sprintf("DELETE FROM %s;", tableName)).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, host := range config.Hosts {
|
||||
if err := tx.Create(&host).Error; err != nil {
|
||||
HostDecrypt(globalContext.String("aes-key"), host)
|
||||
if !c.Bool("decrypt") {
|
||||
if err := HostEncrypt(globalContext.String("aes-key"), host); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.FirstOrCreate(&host).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, user := range config.Users {
|
||||
if err := tx.Create(&user).Error; err != nil {
|
||||
if err := tx.FirstOrCreate(&user).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, acl := range config.ACLs {
|
||||
if err := tx.Create(&acl).Error; err != nil {
|
||||
if err := tx.FirstOrCreate(&acl).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, hostGroup := range config.HostGroups {
|
||||
if err := tx.Create(&hostGroup).Error; err != nil {
|
||||
if err := tx.FirstOrCreate(&hostGroup).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, userGroup := range config.UserGroups {
|
||||
if err := tx.Create(&userGroup).Error; err != nil {
|
||||
if err := tx.FirstOrCreate(&userGroup).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, sshKey := range config.SSHKeys {
|
||||
if err := tx.Create(&sshKey).Error; err != nil {
|
||||
SSHKeyDecrypt(globalContext.String("aes-key"), sshKey)
|
||||
if !c.Bool("decrypt") {
|
||||
if err := SSHKeyEncrypt(globalContext.String("aes-key"), sshKey); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.FirstOrCreate(&sshKey).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, userKey := range config.UserKeys {
|
||||
if err := tx.Create(&userKey).Error; err != nil {
|
||||
if err := tx.FirstOrCreate(&userKey).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, setting := range config.Settings {
|
||||
if err := tx.Create(&setting).Error; err != nil {
|
||||
if err := tx.FirstOrCreate(&setting).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, session := range config.Sessions {
|
||||
if err := tx.FirstOrCreate(&session).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, event := range config.Events {
|
||||
if err := tx.FirstOrCreate(&event).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
@@ -417,6 +505,76 @@ GLOBAL OPTIONS:
|
||||
},
|
||||
},
|
||||
},
|
||||
}, {
|
||||
Name: "event",
|
||||
Usage: "Manages events",
|
||||
Subcommands: []cli.Command{
|
||||
{
|
||||
Name: "inspect",
|
||||
Usage: "Shows detailed information on one or more events",
|
||||
ArgsUsage: "EVENT...",
|
||||
Action: func(c *cli.Context) error {
|
||||
if c.NArg() < 1 {
|
||||
return cli.ShowSubcommandHelp(c)
|
||||
}
|
||||
|
||||
if err := UserCheckRoles(myself, []string{"admin"}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var events []*Event
|
||||
if err := EventsPreload(EventsByIdentifiers(db, c.Args())).Find(&events).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, event := range events {
|
||||
if len(event.Args) > 0 {
|
||||
if err := json.Unmarshal(event.Args, &event.ArgsMap); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enc := json.NewEncoder(s)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(events)
|
||||
},
|
||||
}, {
|
||||
Name: "ls",
|
||||
Usage: "Lists events",
|
||||
Action: func(c *cli.Context) error {
|
||||
if err := UserCheckRoles(myself, []string{"admin"}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var events []Event
|
||||
if err := db.Order("created_at desc").Preload("Author").Find(&events).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
table := tablewriter.NewWriter(s)
|
||||
table.SetHeader([]string{"ID", "Author", "Domain", "Action", "Entity", "Args", "Date"})
|
||||
table.SetBorder(false)
|
||||
table.SetCaption(true, fmt.Sprintf("Total: %d events.", len(events)))
|
||||
for _, event := range events {
|
||||
author := ""
|
||||
if event.Author != nil {
|
||||
author = event.Author.Name
|
||||
}
|
||||
table.Append([]string{
|
||||
fmt.Sprintf("%d", event.ID),
|
||||
author,
|
||||
event.Domain,
|
||||
event.Action,
|
||||
event.Entity,
|
||||
wrapText(string(event.Args), 30),
|
||||
humanize.Time(event.CreatedAt),
|
||||
})
|
||||
}
|
||||
table.Render()
|
||||
return nil
|
||||
},
|
||||
},
|
||||
},
|
||||
}, {
|
||||
Name: "host",
|
||||
Usage: "Manages hosts",
|
||||
@@ -429,7 +587,6 @@ GLOBAL OPTIONS:
|
||||
Flags: []cli.Flag{
|
||||
cli.StringFlag{Name: "name, n", Usage: "Assigns a name to the host"},
|
||||
cli.StringFlag{Name: "password, p", Usage: "If present, sshportal will use password-based authentication"},
|
||||
cli.StringFlag{Name: "fingerprint, f", Usage: "SSH host key fingerprint"},
|
||||
cli.StringFlag{Name: "comment, c"},
|
||||
cli.StringFlag{Name: "key, k", Usage: "`KEY` to use for authentication"},
|
||||
cli.StringSliceFlag{Name: "group, g", Usage: "Assigns the host to `HOSTGROUPS` (default: \"default\")"},
|
||||
@@ -450,7 +607,6 @@ GLOBAL OPTIONS:
|
||||
if c.String("password") != "" {
|
||||
host.Password = c.String("password")
|
||||
}
|
||||
host.Fingerprint = c.String("fingerprint")
|
||||
host.Name = strings.Split(host.Hostname(), ".")[0]
|
||||
|
||||
if c.String("name") != "" {
|
||||
@@ -484,6 +640,11 @@ GLOBAL OPTIONS:
|
||||
return err
|
||||
}
|
||||
|
||||
// encrypt
|
||||
if err := HostEncrypt(globalContext.String("aes-key"), host); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := db.Create(&host).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -494,6 +655,9 @@ GLOBAL OPTIONS:
|
||||
Name: "inspect",
|
||||
Usage: "Shows detailed information on one or more hosts",
|
||||
ArgsUsage: "HOST...",
|
||||
Flags: []cli.Flag{
|
||||
cli.BoolFlag{Name: "decrypt", Usage: "Decrypt sensitive data"},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
if c.NArg() < 1 {
|
||||
return cli.ShowSubcommandHelp(c)
|
||||
@@ -503,7 +667,7 @@ GLOBAL OPTIONS:
|
||||
return err
|
||||
}
|
||||
|
||||
var hosts []Host
|
||||
var hosts []*Host
|
||||
db = db.Preload("Groups")
|
||||
if UserHasRole(myself, "admin") {
|
||||
db = db.Preload("SSHKey")
|
||||
@@ -512,6 +676,12 @@ GLOBAL OPTIONS:
|
||||
return err
|
||||
}
|
||||
|
||||
if c.Bool("decrypt") {
|
||||
for _, host := range hosts {
|
||||
HostDecrypt(globalContext.String("aes-keuy"), host)
|
||||
}
|
||||
}
|
||||
|
||||
enc := json.NewEncoder(s)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(hosts)
|
||||
@@ -525,11 +695,11 @@ GLOBAL OPTIONS:
|
||||
}
|
||||
|
||||
var hosts []*Host
|
||||
if err := db.Preload("Groups").Find(&hosts).Error; err != nil {
|
||||
if err := db.Order("created_at desc").Preload("Groups").Find(&hosts).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
table := tablewriter.NewWriter(s)
|
||||
table.SetHeader([]string{"ID", "Name", "URL", "Key", "Pass", "Groups", "Comment"})
|
||||
table.SetHeader([]string{"ID", "Name", "URL", "Key", "Pass", "Groups", "Updated", "Created", "Comment"})
|
||||
table.SetBorder(false)
|
||||
table.SetCaption(true, fmt.Sprintf("Total: %d hosts.", len(hosts)))
|
||||
for _, host := range hosts {
|
||||
@@ -553,9 +723,10 @@ GLOBAL OPTIONS:
|
||||
authKey,
|
||||
authPass,
|
||||
strings.Join(groupNames, ", "),
|
||||
humanize.Time(host.UpdatedAt),
|
||||
humanize.Time(host.CreatedAt),
|
||||
host.Comment,
|
||||
//FIXME: add some stats about last access time etc
|
||||
//FIXME: add creation date
|
||||
})
|
||||
}
|
||||
table.Render()
|
||||
@@ -583,7 +754,6 @@ GLOBAL OPTIONS:
|
||||
Flags: []cli.Flag{
|
||||
cli.StringFlag{Name: "name, n", Usage: "Rename the host"},
|
||||
cli.StringFlag{Name: "password, p", Usage: "Update/set a password, use \"none\" to unset"},
|
||||
cli.StringFlag{Name: "fingerprint, f", Usage: "Update/set a host fingerprint, use \"none\" to unset"},
|
||||
cli.StringFlag{Name: "comment, c", Usage: "Update/set a host comment"},
|
||||
cli.StringFlag{Name: "key, k", Usage: "Link a `KEY` to use for authentication"},
|
||||
cli.StringSliceFlag{Name: "assign-group, g", Usage: "Assign the host to a new `HOSTGROUPS`"},
|
||||
@@ -611,7 +781,7 @@ GLOBAL OPTIONS:
|
||||
for _, host := range hosts {
|
||||
model := tx.Model(&host)
|
||||
// simple fields
|
||||
for _, fieldname := range []string{"name", "comment", "password", "fingerprint"} {
|
||||
for _, fieldname := range []string{"name", "comment", "password"} {
|
||||
if c.String(fieldname) != "" {
|
||||
if err := model.Update(fieldname, c.String(fieldname)).Error; err != nil {
|
||||
tx.Rollback()
|
||||
@@ -718,11 +888,11 @@ GLOBAL OPTIONS:
|
||||
}
|
||||
|
||||
var hostGroups []*HostGroup
|
||||
if err := db.Preload("ACLs").Preload("Hosts").Find(&hostGroups).Error; err != nil {
|
||||
if err := db.Order("created_at desc").Preload("ACLs").Preload("Hosts").Find(&hostGroups).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
table := tablewriter.NewWriter(s)
|
||||
table.SetHeader([]string{"ID", "Name", "Hosts", "ACLs", "Comment"})
|
||||
table.SetHeader([]string{"ID", "Name", "Hosts", "ACLs", "Updated", "Created", "Comment"})
|
||||
table.SetBorder(false)
|
||||
table.SetCaption(true, fmt.Sprintf("Total: %d host groups.", len(hostGroups)))
|
||||
for _, hostGroup := range hostGroups {
|
||||
@@ -732,6 +902,8 @@ GLOBAL OPTIONS:
|
||||
hostGroup.Name,
|
||||
fmt.Sprintf("%d", len(hostGroup.Hosts)),
|
||||
fmt.Sprintf("%d", len(hostGroup.ACLs)),
|
||||
humanize.Time(hostGroup.UpdatedAt),
|
||||
humanize.Time(hostGroup.CreatedAt),
|
||||
hostGroup.Comment,
|
||||
})
|
||||
}
|
||||
@@ -816,6 +988,11 @@ GLOBAL OPTIONS:
|
||||
}
|
||||
|
||||
key, err := NewSSHKey(c.String("type"), c.Uint("length"))
|
||||
if globalContext.String("aes-key") != "" {
|
||||
if err := SSHKeyEncrypt(globalContext.String("aes-key"), key); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -838,6 +1015,9 @@ GLOBAL OPTIONS:
|
||||
Name: "inspect",
|
||||
Usage: "Shows detailed information on one or more keys",
|
||||
ArgsUsage: "KEY...",
|
||||
Flags: []cli.Flag{
|
||||
cli.BoolFlag{Name: "decrypt", Usage: "Decrypt sensitive data"},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
if c.NArg() < 1 {
|
||||
return cli.ShowSubcommandHelp(c)
|
||||
@@ -847,11 +1027,17 @@ GLOBAL OPTIONS:
|
||||
return err
|
||||
}
|
||||
|
||||
var keys []SSHKey
|
||||
var keys []*SSHKey
|
||||
if err := SSHKeysByIdentifiers(db, c.Args()).Find(&keys).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if c.Bool("decrypt") {
|
||||
for _, key := range keys {
|
||||
SSHKeyDecrypt(globalContext.String("aes-key"), key)
|
||||
}
|
||||
}
|
||||
|
||||
enc := json.NewEncoder(s)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(keys)
|
||||
@@ -865,11 +1051,11 @@ GLOBAL OPTIONS:
|
||||
}
|
||||
|
||||
var keys []SSHKey
|
||||
if err := db.Preload("Hosts").Find(&keys).Error; err != nil {
|
||||
if err := db.Order("created_at desc").Preload("Hosts").Find(&keys).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
table := tablewriter.NewWriter(s)
|
||||
table.SetHeader([]string{"ID", "Name", "Type", "Length", "Hosts", "Comment"})
|
||||
table.SetHeader([]string{"ID", "Name", "Type", "Length", "Hosts", "Updated", "Created", "Comment"})
|
||||
table.SetBorder(false)
|
||||
table.SetCaption(true, fmt.Sprintf("Total: %d keys.", len(keys)))
|
||||
for _, key := range keys {
|
||||
@@ -880,9 +1066,10 @@ GLOBAL OPTIONS:
|
||||
fmt.Sprintf("%d", key.Length),
|
||||
//key.Fingerprint,
|
||||
fmt.Sprintf("%d", len(key.Hosts)),
|
||||
humanize.Time(key.UpdatedAt),
|
||||
humanize.Time(key.CreatedAt),
|
||||
key.Comment,
|
||||
//FIXME: add some stats
|
||||
//FIXME: add creation date
|
||||
})
|
||||
}
|
||||
table.Render()
|
||||
@@ -903,6 +1090,24 @@ GLOBAL OPTIONS:
|
||||
|
||||
return SSHKeysByIdentifiers(db, c.Args()).Delete(&SSHKey{}).Error
|
||||
},
|
||||
}, {
|
||||
Name: "setup",
|
||||
Usage: "Return shell command to install key on remote host",
|
||||
ArgsUsage: "KEY",
|
||||
Action: func(c *cli.Context) error {
|
||||
if c.NArg() != 1 {
|
||||
return cli.ShowSubcommandHelp(c)
|
||||
}
|
||||
|
||||
// not checking roles, everyone with an account can see how to enroll new hosts
|
||||
|
||||
var key SSHKey
|
||||
if err := SSHKeysByIdentifiers(db, c.Args()).First(&key).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(s, "umask 077; mkdir -p .ssh; echo %s sshportal >> .ssh/authorized_keys\n", key.PubKey)
|
||||
return nil
|
||||
},
|
||||
},
|
||||
},
|
||||
}, {
|
||||
@@ -994,11 +1199,11 @@ GLOBAL OPTIONS:
|
||||
}
|
||||
|
||||
var users []User
|
||||
if err := db.Preload("Groups").Preload("Roles").Preload("Keys").Find(&users).Error; err != nil {
|
||||
if err := db.Order("created_at desc").Preload("Groups").Preload("Roles").Preload("Keys").Find(&users).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
table := tablewriter.NewWriter(s)
|
||||
table.SetHeader([]string{"ID", "Name", "Email", "Roles", "Keys", "Groups", "Comment"})
|
||||
table.SetHeader([]string{"ID", "Name", "Email", "Roles", "Keys", "Groups", "Updated", "Created", "Comment"})
|
||||
table.SetBorder(false)
|
||||
table.SetCaption(true, fmt.Sprintf("Total: %d users.", len(users)))
|
||||
for _, user := range users {
|
||||
@@ -1017,9 +1222,9 @@ GLOBAL OPTIONS:
|
||||
strings.Join(roleNames, ", "),
|
||||
fmt.Sprintf("%d", len(user.Keys)),
|
||||
strings.Join(groupNames, ", "),
|
||||
humanize.Time(user.UpdatedAt),
|
||||
humanize.Time(user.CreatedAt),
|
||||
user.Comment,
|
||||
//FIXME: add some stats about last access time etc
|
||||
//FIXME: add creation date
|
||||
})
|
||||
}
|
||||
table.Render()
|
||||
@@ -1194,22 +1399,24 @@ GLOBAL OPTIONS:
|
||||
}
|
||||
|
||||
var userGroups []*UserGroup
|
||||
if err := db.Preload("ACLs").Preload("Users").Find(&userGroups).Error; err != nil {
|
||||
if err := db.Order("created_at desc").Preload("ACLs").Preload("Users").Find(&userGroups).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
table := tablewriter.NewWriter(s)
|
||||
table.SetHeader([]string{"ID", "Name", "Users", "ACLs", "Comment"})
|
||||
table.SetHeader([]string{"ID", "Name", "Users", "ACLs", "Update", "Create", "Comment"})
|
||||
table.SetBorder(false)
|
||||
table.SetCaption(true, fmt.Sprintf("Total: %d user groups.", len(userGroups)))
|
||||
for _, userGroup := range userGroups {
|
||||
// FIXME: add more stats (amount of users, linked usergroups, ...)
|
||||
table.Append([]string{
|
||||
fmt.Sprintf("%d", userGroup.ID),
|
||||
userGroup.Name,
|
||||
fmt.Sprintf("%d", len(userGroup.Users)),
|
||||
fmt.Sprintf("%d", len(userGroup.ACLs)),
|
||||
humanize.Time(userGroup.UpdatedAt),
|
||||
humanize.Time(userGroup.CreatedAt),
|
||||
userGroup.Comment,
|
||||
})
|
||||
// FIXME: add more stats (amount of users, linked usergroups, ...)
|
||||
}
|
||||
table.Render()
|
||||
return nil
|
||||
@@ -1317,11 +1524,11 @@ GLOBAL OPTIONS:
|
||||
}
|
||||
|
||||
var userkeys []UserKey
|
||||
if err := db.Preload("User").Find(&userkeys).Error; err != nil {
|
||||
if err := db.Order("created_at desc").Preload("User").Find(&userkeys).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
table := tablewriter.NewWriter(s)
|
||||
table.SetHeader([]string{"ID", "User", "Comment"})
|
||||
table.SetHeader([]string{"ID", "User", "Updated", "Created", "Comment"})
|
||||
table.SetBorder(false)
|
||||
table.SetCaption(true, fmt.Sprintf("Total: %d userkeys.", len(userkeys)))
|
||||
for _, userkey := range userkeys {
|
||||
@@ -1329,6 +1536,8 @@ GLOBAL OPTIONS:
|
||||
fmt.Sprintf("%d", userkey.ID),
|
||||
userkey.User.Email,
|
||||
// FIXME: add fingerprint
|
||||
humanize.Time(userkey.UpdatedAt),
|
||||
humanize.Time(userkey.CreatedAt),
|
||||
userkey.Comment,
|
||||
})
|
||||
}
|
||||
@@ -1352,6 +1561,72 @@ GLOBAL OPTIONS:
|
||||
},
|
||||
},
|
||||
},
|
||||
}, {
|
||||
Name: "session",
|
||||
Usage: "Manages sessions",
|
||||
Subcommands: []cli.Command{
|
||||
{
|
||||
Name: "inspect",
|
||||
Usage: "Shows detailed information on one or more sessions",
|
||||
ArgsUsage: "SESSION...",
|
||||
Action: func(c *cli.Context) error {
|
||||
if c.NArg() < 1 {
|
||||
return cli.ShowSubcommandHelp(c)
|
||||
}
|
||||
|
||||
if err := UserCheckRoles(myself, []string{"admin"}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var sessions []Session
|
||||
if err := SessionsPreload(SessionsByIdentifiers(db, c.Args())).Find(&sessions).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
enc := json.NewEncoder(s)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(sessions)
|
||||
},
|
||||
}, {
|
||||
Name: "ls",
|
||||
Usage: "Lists sessions",
|
||||
Action: func(c *cli.Context) error {
|
||||
if err := UserCheckRoles(myself, []string{"admin"}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var sessions []Session
|
||||
if err := db.Order("created_at desc").Preload("User").Preload("Host").Find(&sessions).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
table := tablewriter.NewWriter(s)
|
||||
table.SetHeader([]string{"ID", "User", "Host", "Status", "Start", "Duration", "Error", "Comment"})
|
||||
table.SetBorder(false)
|
||||
table.SetCaption(true, fmt.Sprintf("Total: %d sessions.", len(sessions)))
|
||||
for _, session := range sessions {
|
||||
var duration string
|
||||
if session.StoppedAt.IsZero() {
|
||||
duration = humanize.RelTime(session.CreatedAt, time.Now(), "", "")
|
||||
} else {
|
||||
duration = humanize.RelTime(session.CreatedAt, *session.StoppedAt, "", "")
|
||||
}
|
||||
duration = strings.Replace(duration, "now", "1 second", 1)
|
||||
table.Append([]string{
|
||||
fmt.Sprintf("%d", session.ID),
|
||||
session.User.Name,
|
||||
session.Host.Name,
|
||||
session.Status,
|
||||
humanize.Time(session.CreatedAt),
|
||||
duration,
|
||||
wrapText(session.ErrMsg, 30),
|
||||
session.Comment,
|
||||
})
|
||||
}
|
||||
table.Render()
|
||||
return nil
|
||||
},
|
||||
},
|
||||
},
|
||||
}, {
|
||||
Name: "version",
|
||||
Usage: "Shows the SSHPortal version information",
|
||||
@@ -1385,6 +1660,7 @@ GLOBAL OPTIONS:
|
||||
s.Exit(0)
|
||||
return nil
|
||||
}
|
||||
NewEvent("shell", words[0]).SetAuthor(&myself).SetArg("interactive", true).SetArg("args", words[1:]).Log(db)
|
||||
if err := app.Run(append([]string{"config"}, words...)); err != nil {
|
||||
if cliErr, ok := err.(*cli.ExitError); ok {
|
||||
if cliErr.ExitCode() != 0 {
|
||||
@@ -1397,6 +1673,7 @@ GLOBAL OPTIONS:
|
||||
}
|
||||
}
|
||||
} else { // oneshot mode
|
||||
NewEvent("shell", sshCommand[0]).SetAuthor(&myself).SetArg("interactive", false).SetArg("args", sshCommand[1:]).Log(db)
|
||||
if err := app.Run(append([]string{"config"}, sshCommand...)); err != nil {
|
||||
if errMsg := err.Error(); errMsg != "" {
|
||||
io.WriteString(s, fmt.Sprintf("error: %s\n", errMsg))
|
||||
|
||||
36
ssh.go
Normal file
36
ssh.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
|
||||
"github.com/jinzhu/gorm"
|
||||
gossh "golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
type dynamicHostKey struct {
|
||||
db *gorm.DB
|
||||
host *Host
|
||||
}
|
||||
|
||||
func (d *dynamicHostKey) check(hostname string, remote net.Addr, key gossh.PublicKey) error {
|
||||
if len(d.host.HostKey) == 0 {
|
||||
log.Println("Discovering host fingerprint...")
|
||||
return d.db.Model(d.host).Update("HostKey", key.Marshal()).Error
|
||||
}
|
||||
|
||||
if !bytes.Equal(d.host.HostKey, key.Marshal()) {
|
||||
return fmt.Errorf("ssh: host key mismatch")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DynamicHostKey returns a function for use in
|
||||
// ClientConfig.HostKeyCallback to dynamically learn or accept host key.
|
||||
func DynamicHostKey(db *gorm.DB, host *Host) gossh.HostKeyCallback {
|
||||
// FIXME: forward interactively the host key checking
|
||||
hk := &dynamicHostKey{db, host}
|
||||
return hk.check
|
||||
}
|
||||
7
util.go
7
util.go
@@ -11,3 +11,10 @@ func RandStringBytes(n int) string {
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func wrapText(in string, length int) string {
|
||||
if len(in) <= length {
|
||||
return in
|
||||
}
|
||||
return in[0:length-3] + "..."
|
||||
}
|
||||
|
||||
21
vendor/github.com/dustin/go-humanize/LICENSE
generated
vendored
Normal file
21
vendor/github.com/dustin/go-humanize/LICENSE
generated
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
Copyright (c) 2005-2008 Dustin Sallings <dustin@spy.net>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
<http://www.opensource.org/licenses/mit-license.php>
|
||||
92
vendor/github.com/dustin/go-humanize/README.markdown
generated
vendored
Normal file
92
vendor/github.com/dustin/go-humanize/README.markdown
generated
vendored
Normal file
@@ -0,0 +1,92 @@
|
||||
# Humane Units [](https://travis-ci.org/dustin/go-humanize) [](https://godoc.org/github.com/dustin/go-humanize)
|
||||
|
||||
Just a few functions for helping humanize times and sizes.
|
||||
|
||||
`go get` it as `github.com/dustin/go-humanize`, import it as
|
||||
`"github.com/dustin/go-humanize"`, use it as `humanize`
|
||||
|
||||
See [godoc](https://godoc.org/github.com/dustin/go-humanize) for
|
||||
complete documentation.
|
||||
|
||||
## Sizes
|
||||
|
||||
This lets you take numbers like `82854982` and convert them to useful
|
||||
strings like, `83MB` or `79MiB` (whichever you prefer).
|
||||
|
||||
Example:
|
||||
|
||||
```go
|
||||
fmt.Printf("That file is %s.", humanize.Bytes(82854982))
|
||||
```
|
||||
|
||||
## Times
|
||||
|
||||
This lets you take a `time.Time` and spit it out in relative terms.
|
||||
For example, `12 seconds ago` or `3 days from now`.
|
||||
|
||||
Example:
|
||||
|
||||
```go
|
||||
fmt.Printf("This was touched %s", humanize.Time(someTimeInstance))
|
||||
```
|
||||
|
||||
Thanks to Kyle Lemons for the time implementation from an IRC
|
||||
conversation one day. It's pretty neat.
|
||||
|
||||
## Ordinals
|
||||
|
||||
From a [mailing list discussion][odisc] where a user wanted to be able
|
||||
to label ordinals.
|
||||
|
||||
0 -> 0th
|
||||
1 -> 1st
|
||||
2 -> 2nd
|
||||
3 -> 3rd
|
||||
4 -> 4th
|
||||
[...]
|
||||
|
||||
Example:
|
||||
|
||||
```go
|
||||
fmt.Printf("You're my %s best friend.", humanize.Ordinal(193))
|
||||
```
|
||||
|
||||
## Commas
|
||||
|
||||
Want to shove commas into numbers? Be my guest.
|
||||
|
||||
0 -> 0
|
||||
100 -> 100
|
||||
1000 -> 1,000
|
||||
1000000000 -> 1,000,000,000
|
||||
-100000 -> -100,000
|
||||
|
||||
Example:
|
||||
|
||||
```go
|
||||
fmt.Printf("You owe $%s.\n", humanize.Comma(6582491))
|
||||
```
|
||||
|
||||
## Ftoa
|
||||
|
||||
Nicer float64 formatter that removes trailing zeros.
|
||||
|
||||
```go
|
||||
fmt.Printf("%f", 2.24) // 2.240000
|
||||
fmt.Printf("%s", humanize.Ftoa(2.24)) // 2.24
|
||||
fmt.Printf("%f", 2.0) // 2.000000
|
||||
fmt.Printf("%s", humanize.Ftoa(2.0)) // 2
|
||||
```
|
||||
|
||||
## SI notation
|
||||
|
||||
Format numbers with [SI notation][sinotation].
|
||||
|
||||
Example:
|
||||
|
||||
```go
|
||||
humanize.SI(0.00000000223, "M") // 2.23nM
|
||||
```
|
||||
|
||||
[odisc]: https://groups.google.com/d/topic/golang-nuts/l8NhI74jl-4/discussion
|
||||
[sinotation]: http://en.wikipedia.org/wiki/Metric_prefix
|
||||
31
vendor/github.com/dustin/go-humanize/big.go
generated
vendored
Normal file
31
vendor/github.com/dustin/go-humanize/big.go
generated
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
package humanize
|
||||
|
||||
import (
|
||||
"math/big"
|
||||
)
|
||||
|
||||
// order of magnitude (to a max order)
|
||||
func oomm(n, b *big.Int, maxmag int) (float64, int) {
|
||||
mag := 0
|
||||
m := &big.Int{}
|
||||
for n.Cmp(b) >= 0 {
|
||||
n.DivMod(n, b, m)
|
||||
mag++
|
||||
if mag == maxmag && maxmag >= 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return float64(n.Int64()) + (float64(m.Int64()) / float64(b.Int64())), mag
|
||||
}
|
||||
|
||||
// total order of magnitude
|
||||
// (same as above, but with no upper limit)
|
||||
func oom(n, b *big.Int) (float64, int) {
|
||||
mag := 0
|
||||
m := &big.Int{}
|
||||
for n.Cmp(b) >= 0 {
|
||||
n.DivMod(n, b, m)
|
||||
mag++
|
||||
}
|
||||
return float64(n.Int64()) + (float64(m.Int64()) / float64(b.Int64())), mag
|
||||
}
|
||||
173
vendor/github.com/dustin/go-humanize/bigbytes.go
generated
vendored
Normal file
173
vendor/github.com/dustin/go-humanize/bigbytes.go
generated
vendored
Normal file
@@ -0,0 +1,173 @@
|
||||
package humanize
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
var (
|
||||
bigIECExp = big.NewInt(1024)
|
||||
|
||||
// BigByte is one byte in bit.Ints
|
||||
BigByte = big.NewInt(1)
|
||||
// BigKiByte is 1,024 bytes in bit.Ints
|
||||
BigKiByte = (&big.Int{}).Mul(BigByte, bigIECExp)
|
||||
// BigMiByte is 1,024 k bytes in bit.Ints
|
||||
BigMiByte = (&big.Int{}).Mul(BigKiByte, bigIECExp)
|
||||
// BigGiByte is 1,024 m bytes in bit.Ints
|
||||
BigGiByte = (&big.Int{}).Mul(BigMiByte, bigIECExp)
|
||||
// BigTiByte is 1,024 g bytes in bit.Ints
|
||||
BigTiByte = (&big.Int{}).Mul(BigGiByte, bigIECExp)
|
||||
// BigPiByte is 1,024 t bytes in bit.Ints
|
||||
BigPiByte = (&big.Int{}).Mul(BigTiByte, bigIECExp)
|
||||
// BigEiByte is 1,024 p bytes in bit.Ints
|
||||
BigEiByte = (&big.Int{}).Mul(BigPiByte, bigIECExp)
|
||||
// BigZiByte is 1,024 e bytes in bit.Ints
|
||||
BigZiByte = (&big.Int{}).Mul(BigEiByte, bigIECExp)
|
||||
// BigYiByte is 1,024 z bytes in bit.Ints
|
||||
BigYiByte = (&big.Int{}).Mul(BigZiByte, bigIECExp)
|
||||
)
|
||||
|
||||
var (
|
||||
bigSIExp = big.NewInt(1000)
|
||||
|
||||
// BigSIByte is one SI byte in big.Ints
|
||||
BigSIByte = big.NewInt(1)
|
||||
// BigKByte is 1,000 SI bytes in big.Ints
|
||||
BigKByte = (&big.Int{}).Mul(BigSIByte, bigSIExp)
|
||||
// BigMByte is 1,000 SI k bytes in big.Ints
|
||||
BigMByte = (&big.Int{}).Mul(BigKByte, bigSIExp)
|
||||
// BigGByte is 1,000 SI m bytes in big.Ints
|
||||
BigGByte = (&big.Int{}).Mul(BigMByte, bigSIExp)
|
||||
// BigTByte is 1,000 SI g bytes in big.Ints
|
||||
BigTByte = (&big.Int{}).Mul(BigGByte, bigSIExp)
|
||||
// BigPByte is 1,000 SI t bytes in big.Ints
|
||||
BigPByte = (&big.Int{}).Mul(BigTByte, bigSIExp)
|
||||
// BigEByte is 1,000 SI p bytes in big.Ints
|
||||
BigEByte = (&big.Int{}).Mul(BigPByte, bigSIExp)
|
||||
// BigZByte is 1,000 SI e bytes in big.Ints
|
||||
BigZByte = (&big.Int{}).Mul(BigEByte, bigSIExp)
|
||||
// BigYByte is 1,000 SI z bytes in big.Ints
|
||||
BigYByte = (&big.Int{}).Mul(BigZByte, bigSIExp)
|
||||
)
|
||||
|
||||
var bigBytesSizeTable = map[string]*big.Int{
|
||||
"b": BigByte,
|
||||
"kib": BigKiByte,
|
||||
"kb": BigKByte,
|
||||
"mib": BigMiByte,
|
||||
"mb": BigMByte,
|
||||
"gib": BigGiByte,
|
||||
"gb": BigGByte,
|
||||
"tib": BigTiByte,
|
||||
"tb": BigTByte,
|
||||
"pib": BigPiByte,
|
||||
"pb": BigPByte,
|
||||
"eib": BigEiByte,
|
||||
"eb": BigEByte,
|
||||
"zib": BigZiByte,
|
||||
"zb": BigZByte,
|
||||
"yib": BigYiByte,
|
||||
"yb": BigYByte,
|
||||
// Without suffix
|
||||
"": BigByte,
|
||||
"ki": BigKiByte,
|
||||
"k": BigKByte,
|
||||
"mi": BigMiByte,
|
||||
"m": BigMByte,
|
||||
"gi": BigGiByte,
|
||||
"g": BigGByte,
|
||||
"ti": BigTiByte,
|
||||
"t": BigTByte,
|
||||
"pi": BigPiByte,
|
||||
"p": BigPByte,
|
||||
"ei": BigEiByte,
|
||||
"e": BigEByte,
|
||||
"z": BigZByte,
|
||||
"zi": BigZiByte,
|
||||
"y": BigYByte,
|
||||
"yi": BigYiByte,
|
||||
}
|
||||
|
||||
var ten = big.NewInt(10)
|
||||
|
||||
func humanateBigBytes(s, base *big.Int, sizes []string) string {
|
||||
if s.Cmp(ten) < 0 {
|
||||
return fmt.Sprintf("%d B", s)
|
||||
}
|
||||
c := (&big.Int{}).Set(s)
|
||||
val, mag := oomm(c, base, len(sizes)-1)
|
||||
suffix := sizes[mag]
|
||||
f := "%.0f %s"
|
||||
if val < 10 {
|
||||
f = "%.1f %s"
|
||||
}
|
||||
|
||||
return fmt.Sprintf(f, val, suffix)
|
||||
|
||||
}
|
||||
|
||||
// BigBytes produces a human readable representation of an SI size.
|
||||
//
|
||||
// See also: ParseBigBytes.
|
||||
//
|
||||
// BigBytes(82854982) -> 83MB
|
||||
func BigBytes(s *big.Int) string {
|
||||
sizes := []string{"B", "kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"}
|
||||
return humanateBigBytes(s, bigSIExp, sizes)
|
||||
}
|
||||
|
||||
// BigIBytes produces a human readable representation of an IEC size.
|
||||
//
|
||||
// See also: ParseBigBytes.
|
||||
//
|
||||
// BigIBytes(82854982) -> 79MiB
|
||||
func BigIBytes(s *big.Int) string {
|
||||
sizes := []string{"B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"}
|
||||
return humanateBigBytes(s, bigIECExp, sizes)
|
||||
}
|
||||
|
||||
// ParseBigBytes parses a string representation of bytes into the number
|
||||
// of bytes it represents.
|
||||
//
|
||||
// See also: BigBytes, BigIBytes.
|
||||
//
|
||||
// ParseBigBytes("42MB") -> 42000000, nil
|
||||
// ParseBigBytes("42mib") -> 44040192, nil
|
||||
func ParseBigBytes(s string) (*big.Int, error) {
|
||||
lastDigit := 0
|
||||
hasComma := false
|
||||
for _, r := range s {
|
||||
if !(unicode.IsDigit(r) || r == '.' || r == ',') {
|
||||
break
|
||||
}
|
||||
if r == ',' {
|
||||
hasComma = true
|
||||
}
|
||||
lastDigit++
|
||||
}
|
||||
|
||||
num := s[:lastDigit]
|
||||
if hasComma {
|
||||
num = strings.Replace(num, ",", "", -1)
|
||||
}
|
||||
|
||||
val := &big.Rat{}
|
||||
_, err := fmt.Sscanf(num, "%f", val)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
extra := strings.ToLower(strings.TrimSpace(s[lastDigit:]))
|
||||
if m, ok := bigBytesSizeTable[extra]; ok {
|
||||
mv := (&big.Rat{}).SetInt(m)
|
||||
val.Mul(val, mv)
|
||||
rv := &big.Int{}
|
||||
rv.Div(val.Num(), val.Denom())
|
||||
return rv, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("unhandled size name: %v", extra)
|
||||
}
|
||||
143
vendor/github.com/dustin/go-humanize/bytes.go
generated
vendored
Normal file
143
vendor/github.com/dustin/go-humanize/bytes.go
generated
vendored
Normal file
@@ -0,0 +1,143 @@
|
||||
package humanize
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// IEC Sizes.
|
||||
// kibis of bits
|
||||
const (
|
||||
Byte = 1 << (iota * 10)
|
||||
KiByte
|
||||
MiByte
|
||||
GiByte
|
||||
TiByte
|
||||
PiByte
|
||||
EiByte
|
||||
)
|
||||
|
||||
// SI Sizes.
|
||||
const (
|
||||
IByte = 1
|
||||
KByte = IByte * 1000
|
||||
MByte = KByte * 1000
|
||||
GByte = MByte * 1000
|
||||
TByte = GByte * 1000
|
||||
PByte = TByte * 1000
|
||||
EByte = PByte * 1000
|
||||
)
|
||||
|
||||
var bytesSizeTable = map[string]uint64{
|
||||
"b": Byte,
|
||||
"kib": KiByte,
|
||||
"kb": KByte,
|
||||
"mib": MiByte,
|
||||
"mb": MByte,
|
||||
"gib": GiByte,
|
||||
"gb": GByte,
|
||||
"tib": TiByte,
|
||||
"tb": TByte,
|
||||
"pib": PiByte,
|
||||
"pb": PByte,
|
||||
"eib": EiByte,
|
||||
"eb": EByte,
|
||||
// Without suffix
|
||||
"": Byte,
|
||||
"ki": KiByte,
|
||||
"k": KByte,
|
||||
"mi": MiByte,
|
||||
"m": MByte,
|
||||
"gi": GiByte,
|
||||
"g": GByte,
|
||||
"ti": TiByte,
|
||||
"t": TByte,
|
||||
"pi": PiByte,
|
||||
"p": PByte,
|
||||
"ei": EiByte,
|
||||
"e": EByte,
|
||||
}
|
||||
|
||||
func logn(n, b float64) float64 {
|
||||
return math.Log(n) / math.Log(b)
|
||||
}
|
||||
|
||||
func humanateBytes(s uint64, base float64, sizes []string) string {
|
||||
if s < 10 {
|
||||
return fmt.Sprintf("%d B", s)
|
||||
}
|
||||
e := math.Floor(logn(float64(s), base))
|
||||
suffix := sizes[int(e)]
|
||||
val := math.Floor(float64(s)/math.Pow(base, e)*10+0.5) / 10
|
||||
f := "%.0f %s"
|
||||
if val < 10 {
|
||||
f = "%.1f %s"
|
||||
}
|
||||
|
||||
return fmt.Sprintf(f, val, suffix)
|
||||
}
|
||||
|
||||
// Bytes produces a human readable representation of an SI size.
|
||||
//
|
||||
// See also: ParseBytes.
|
||||
//
|
||||
// Bytes(82854982) -> 83MB
|
||||
func Bytes(s uint64) string {
|
||||
sizes := []string{"B", "kB", "MB", "GB", "TB", "PB", "EB"}
|
||||
return humanateBytes(s, 1000, sizes)
|
||||
}
|
||||
|
||||
// IBytes produces a human readable representation of an IEC size.
|
||||
//
|
||||
// See also: ParseBytes.
|
||||
//
|
||||
// IBytes(82854982) -> 79MiB
|
||||
func IBytes(s uint64) string {
|
||||
sizes := []string{"B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"}
|
||||
return humanateBytes(s, 1024, sizes)
|
||||
}
|
||||
|
||||
// ParseBytes parses a string representation of bytes into the number
|
||||
// of bytes it represents.
|
||||
//
|
||||
// See Also: Bytes, IBytes.
|
||||
//
|
||||
// ParseBytes("42MB") -> 42000000, nil
|
||||
// ParseBytes("42mib") -> 44040192, nil
|
||||
func ParseBytes(s string) (uint64, error) {
|
||||
lastDigit := 0
|
||||
hasComma := false
|
||||
for _, r := range s {
|
||||
if !(unicode.IsDigit(r) || r == '.' || r == ',') {
|
||||
break
|
||||
}
|
||||
if r == ',' {
|
||||
hasComma = true
|
||||
}
|
||||
lastDigit++
|
||||
}
|
||||
|
||||
num := s[:lastDigit]
|
||||
if hasComma {
|
||||
num = strings.Replace(num, ",", "", -1)
|
||||
}
|
||||
|
||||
f, err := strconv.ParseFloat(num, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
extra := strings.ToLower(strings.TrimSpace(s[lastDigit:]))
|
||||
if m, ok := bytesSizeTable[extra]; ok {
|
||||
f *= float64(m)
|
||||
if f >= math.MaxUint64 {
|
||||
return 0, fmt.Errorf("too large: %v", s)
|
||||
}
|
||||
return uint64(f), nil
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf("unhandled size name: %v", extra)
|
||||
}
|
||||
108
vendor/github.com/dustin/go-humanize/comma.go
generated
vendored
Normal file
108
vendor/github.com/dustin/go-humanize/comma.go
generated
vendored
Normal file
@@ -0,0 +1,108 @@
|
||||
package humanize
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"math"
|
||||
"math/big"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Comma produces a string form of the given number in base 10 with
|
||||
// commas after every three orders of magnitude.
|
||||
//
|
||||
// e.g. Comma(834142) -> 834,142
|
||||
func Comma(v int64) string {
|
||||
sign := ""
|
||||
|
||||
// minin64 can't be negated to a usable value, so it has to be special cased.
|
||||
if v == math.MinInt64 {
|
||||
return "-9,223,372,036,854,775,808"
|
||||
}
|
||||
|
||||
if v < 0 {
|
||||
sign = "-"
|
||||
v = 0 - v
|
||||
}
|
||||
|
||||
parts := []string{"", "", "", "", "", "", ""}
|
||||
j := len(parts) - 1
|
||||
|
||||
for v > 999 {
|
||||
parts[j] = strconv.FormatInt(v%1000, 10)
|
||||
switch len(parts[j]) {
|
||||
case 2:
|
||||
parts[j] = "0" + parts[j]
|
||||
case 1:
|
||||
parts[j] = "00" + parts[j]
|
||||
}
|
||||
v = v / 1000
|
||||
j--
|
||||
}
|
||||
parts[j] = strconv.Itoa(int(v))
|
||||
return sign + strings.Join(parts[j:], ",")
|
||||
}
|
||||
|
||||
// Commaf produces a string form of the given number in base 10 with
|
||||
// commas after every three orders of magnitude.
|
||||
//
|
||||
// e.g. Commaf(834142.32) -> 834,142.32
|
||||
func Commaf(v float64) string {
|
||||
buf := &bytes.Buffer{}
|
||||
if v < 0 {
|
||||
buf.Write([]byte{'-'})
|
||||
v = 0 - v
|
||||
}
|
||||
|
||||
comma := []byte{','}
|
||||
|
||||
parts := strings.Split(strconv.FormatFloat(v, 'f', -1, 64), ".")
|
||||
pos := 0
|
||||
if len(parts[0])%3 != 0 {
|
||||
pos += len(parts[0]) % 3
|
||||
buf.WriteString(parts[0][:pos])
|
||||
buf.Write(comma)
|
||||
}
|
||||
for ; pos < len(parts[0]); pos += 3 {
|
||||
buf.WriteString(parts[0][pos : pos+3])
|
||||
buf.Write(comma)
|
||||
}
|
||||
buf.Truncate(buf.Len() - 1)
|
||||
|
||||
if len(parts) > 1 {
|
||||
buf.Write([]byte{'.'})
|
||||
buf.WriteString(parts[1])
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// BigComma produces a string form of the given big.Int in base 10
|
||||
// with commas after every three orders of magnitude.
|
||||
func BigComma(b *big.Int) string {
|
||||
sign := ""
|
||||
if b.Sign() < 0 {
|
||||
sign = "-"
|
||||
b.Abs(b)
|
||||
}
|
||||
|
||||
athousand := big.NewInt(1000)
|
||||
c := (&big.Int{}).Set(b)
|
||||
_, m := oom(c, athousand)
|
||||
parts := make([]string, m+1)
|
||||
j := len(parts) - 1
|
||||
|
||||
mod := &big.Int{}
|
||||
for b.Cmp(athousand) >= 0 {
|
||||
b.DivMod(b, athousand, mod)
|
||||
parts[j] = strconv.FormatInt(mod.Int64(), 10)
|
||||
switch len(parts[j]) {
|
||||
case 2:
|
||||
parts[j] = "0" + parts[j]
|
||||
case 1:
|
||||
parts[j] = "00" + parts[j]
|
||||
}
|
||||
j--
|
||||
}
|
||||
parts[j] = strconv.Itoa(int(b.Int64()))
|
||||
return sign + strings.Join(parts[j:], ",")
|
||||
}
|
||||
40
vendor/github.com/dustin/go-humanize/commaf.go
generated
vendored
Normal file
40
vendor/github.com/dustin/go-humanize/commaf.go
generated
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
// +build go1.6
|
||||
|
||||
package humanize
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"math/big"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// BigCommaf produces a string form of the given big.Float in base 10
|
||||
// with commas after every three orders of magnitude.
|
||||
func BigCommaf(v *big.Float) string {
|
||||
buf := &bytes.Buffer{}
|
||||
if v.Sign() < 0 {
|
||||
buf.Write([]byte{'-'})
|
||||
v.Abs(v)
|
||||
}
|
||||
|
||||
comma := []byte{','}
|
||||
|
||||
parts := strings.Split(v.Text('f', -1), ".")
|
||||
pos := 0
|
||||
if len(parts[0])%3 != 0 {
|
||||
pos += len(parts[0]) % 3
|
||||
buf.WriteString(parts[0][:pos])
|
||||
buf.Write(comma)
|
||||
}
|
||||
for ; pos < len(parts[0]); pos += 3 {
|
||||
buf.WriteString(parts[0][pos : pos+3])
|
||||
buf.Write(comma)
|
||||
}
|
||||
buf.Truncate(buf.Len() - 1)
|
||||
|
||||
if len(parts) > 1 {
|
||||
buf.Write([]byte{'.'})
|
||||
buf.WriteString(parts[1])
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
23
vendor/github.com/dustin/go-humanize/ftoa.go
generated
vendored
Normal file
23
vendor/github.com/dustin/go-humanize/ftoa.go
generated
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
package humanize
|
||||
|
||||
import "strconv"
|
||||
|
||||
func stripTrailingZeros(s string) string {
|
||||
offset := len(s) - 1
|
||||
for offset > 0 {
|
||||
if s[offset] == '.' {
|
||||
offset--
|
||||
break
|
||||
}
|
||||
if s[offset] != '0' {
|
||||
break
|
||||
}
|
||||
offset--
|
||||
}
|
||||
return s[:offset+1]
|
||||
}
|
||||
|
||||
// Ftoa converts a float to a string with no trailing zeros.
|
||||
func Ftoa(num float64) string {
|
||||
return stripTrailingZeros(strconv.FormatFloat(num, 'f', 6, 64))
|
||||
}
|
||||
8
vendor/github.com/dustin/go-humanize/humanize.go
generated
vendored
Normal file
8
vendor/github.com/dustin/go-humanize/humanize.go
generated
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
Package humanize converts boring ugly numbers to human-friendly strings and back.
|
||||
|
||||
Durations can be turned into strings such as "3 days ago", numbers
|
||||
representing sizes like 82854982 into useful strings like, "83MB" or
|
||||
"79MiB" (whichever you prefer).
|
||||
*/
|
||||
package humanize
|
||||
192
vendor/github.com/dustin/go-humanize/number.go
generated
vendored
Normal file
192
vendor/github.com/dustin/go-humanize/number.go
generated
vendored
Normal file
@@ -0,0 +1,192 @@
|
||||
package humanize
|
||||
|
||||
/*
|
||||
Slightly adapted from the source to fit go-humanize.
|
||||
|
||||
Author: https://github.com/gorhill
|
||||
Source: https://gist.github.com/gorhill/5285193
|
||||
|
||||
*/
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
var (
|
||||
renderFloatPrecisionMultipliers = [...]float64{
|
||||
1,
|
||||
10,
|
||||
100,
|
||||
1000,
|
||||
10000,
|
||||
100000,
|
||||
1000000,
|
||||
10000000,
|
||||
100000000,
|
||||
1000000000,
|
||||
}
|
||||
|
||||
renderFloatPrecisionRounders = [...]float64{
|
||||
0.5,
|
||||
0.05,
|
||||
0.005,
|
||||
0.0005,
|
||||
0.00005,
|
||||
0.000005,
|
||||
0.0000005,
|
||||
0.00000005,
|
||||
0.000000005,
|
||||
0.0000000005,
|
||||
}
|
||||
)
|
||||
|
||||
// FormatFloat produces a formatted number as string based on the following user-specified criteria:
|
||||
// * thousands separator
|
||||
// * decimal separator
|
||||
// * decimal precision
|
||||
//
|
||||
// Usage: s := RenderFloat(format, n)
|
||||
// The format parameter tells how to render the number n.
|
||||
//
|
||||
// See examples: http://play.golang.org/p/LXc1Ddm1lJ
|
||||
//
|
||||
// Examples of format strings, given n = 12345.6789:
|
||||
// "#,###.##" => "12,345.67"
|
||||
// "#,###." => "12,345"
|
||||
// "#,###" => "12345,678"
|
||||
// "#\u202F###,##" => "12 345,68"
|
||||
// "#.###,###### => 12.345,678900
|
||||
// "" (aka default format) => 12,345.67
|
||||
//
|
||||
// The highest precision allowed is 9 digits after the decimal symbol.
|
||||
// There is also a version for integer number, FormatInteger(),
|
||||
// which is convenient for calls within template.
|
||||
func FormatFloat(format string, n float64) string {
|
||||
// Special cases:
|
||||
// NaN = "NaN"
|
||||
// +Inf = "+Infinity"
|
||||
// -Inf = "-Infinity"
|
||||
if math.IsNaN(n) {
|
||||
return "NaN"
|
||||
}
|
||||
if n > math.MaxFloat64 {
|
||||
return "Infinity"
|
||||
}
|
||||
if n < -math.MaxFloat64 {
|
||||
return "-Infinity"
|
||||
}
|
||||
|
||||
// default format
|
||||
precision := 2
|
||||
decimalStr := "."
|
||||
thousandStr := ","
|
||||
positiveStr := ""
|
||||
negativeStr := "-"
|
||||
|
||||
if len(format) > 0 {
|
||||
format := []rune(format)
|
||||
|
||||
// If there is an explicit format directive,
|
||||
// then default values are these:
|
||||
precision = 9
|
||||
thousandStr = ""
|
||||
|
||||
// collect indices of meaningful formatting directives
|
||||
formatIndx := []int{}
|
||||
for i, char := range format {
|
||||
if char != '#' && char != '0' {
|
||||
formatIndx = append(formatIndx, i)
|
||||
}
|
||||
}
|
||||
|
||||
if len(formatIndx) > 0 {
|
||||
// Directive at index 0:
|
||||
// Must be a '+'
|
||||
// Raise an error if not the case
|
||||
// index: 0123456789
|
||||
// +0.000,000
|
||||
// +000,000.0
|
||||
// +0000.00
|
||||
// +0000
|
||||
if formatIndx[0] == 0 {
|
||||
if format[formatIndx[0]] != '+' {
|
||||
panic("RenderFloat(): invalid positive sign directive")
|
||||
}
|
||||
positiveStr = "+"
|
||||
formatIndx = formatIndx[1:]
|
||||
}
|
||||
|
||||
// Two directives:
|
||||
// First is thousands separator
|
||||
// Raise an error if not followed by 3-digit
|
||||
// 0123456789
|
||||
// 0.000,000
|
||||
// 000,000.00
|
||||
if len(formatIndx) == 2 {
|
||||
if (formatIndx[1] - formatIndx[0]) != 4 {
|
||||
panic("RenderFloat(): thousands separator directive must be followed by 3 digit-specifiers")
|
||||
}
|
||||
thousandStr = string(format[formatIndx[0]])
|
||||
formatIndx = formatIndx[1:]
|
||||
}
|
||||
|
||||
// One directive:
|
||||
// Directive is decimal separator
|
||||
// The number of digit-specifier following the separator indicates wanted precision
|
||||
// 0123456789
|
||||
// 0.00
|
||||
// 000,0000
|
||||
if len(formatIndx) == 1 {
|
||||
decimalStr = string(format[formatIndx[0]])
|
||||
precision = len(format) - formatIndx[0] - 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// generate sign part
|
||||
var signStr string
|
||||
if n >= 0.000000001 {
|
||||
signStr = positiveStr
|
||||
} else if n <= -0.000000001 {
|
||||
signStr = negativeStr
|
||||
n = -n
|
||||
} else {
|
||||
signStr = ""
|
||||
n = 0.0
|
||||
}
|
||||
|
||||
// split number into integer and fractional parts
|
||||
intf, fracf := math.Modf(n + renderFloatPrecisionRounders[precision])
|
||||
|
||||
// generate integer part string
|
||||
intStr := strconv.FormatInt(int64(intf), 10)
|
||||
|
||||
// add thousand separator if required
|
||||
if len(thousandStr) > 0 {
|
||||
for i := len(intStr); i > 3; {
|
||||
i -= 3
|
||||
intStr = intStr[:i] + thousandStr + intStr[i:]
|
||||
}
|
||||
}
|
||||
|
||||
// no fractional part, we can leave now
|
||||
if precision == 0 {
|
||||
return signStr + intStr
|
||||
}
|
||||
|
||||
// generate fractional part
|
||||
fracStr := strconv.Itoa(int(fracf * renderFloatPrecisionMultipliers[precision]))
|
||||
// may need padding
|
||||
if len(fracStr) < precision {
|
||||
fracStr = "000000000000000"[:precision-len(fracStr)] + fracStr
|
||||
}
|
||||
|
||||
return signStr + intStr + decimalStr + fracStr
|
||||
}
|
||||
|
||||
// FormatInteger produces a formatted number as string.
|
||||
// See FormatFloat.
|
||||
func FormatInteger(format string, n int) string {
|
||||
return FormatFloat(format, float64(n))
|
||||
}
|
||||
25
vendor/github.com/dustin/go-humanize/ordinals.go
generated
vendored
Normal file
25
vendor/github.com/dustin/go-humanize/ordinals.go
generated
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
package humanize
|
||||
|
||||
import "strconv"
|
||||
|
||||
// Ordinal gives you the input number in a rank/ordinal format.
|
||||
//
|
||||
// Ordinal(3) -> 3rd
|
||||
func Ordinal(x int) string {
|
||||
suffix := "th"
|
||||
switch x % 10 {
|
||||
case 1:
|
||||
if x%100 != 11 {
|
||||
suffix = "st"
|
||||
}
|
||||
case 2:
|
||||
if x%100 != 12 {
|
||||
suffix = "nd"
|
||||
}
|
||||
case 3:
|
||||
if x%100 != 13 {
|
||||
suffix = "rd"
|
||||
}
|
||||
}
|
||||
return strconv.Itoa(x) + suffix
|
||||
}
|
||||
113
vendor/github.com/dustin/go-humanize/si.go
generated
vendored
Normal file
113
vendor/github.com/dustin/go-humanize/si.go
generated
vendored
Normal file
@@ -0,0 +1,113 @@
|
||||
package humanize
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
"regexp"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
var siPrefixTable = map[float64]string{
|
||||
-24: "y", // yocto
|
||||
-21: "z", // zepto
|
||||
-18: "a", // atto
|
||||
-15: "f", // femto
|
||||
-12: "p", // pico
|
||||
-9: "n", // nano
|
||||
-6: "µ", // micro
|
||||
-3: "m", // milli
|
||||
0: "",
|
||||
3: "k", // kilo
|
||||
6: "M", // mega
|
||||
9: "G", // giga
|
||||
12: "T", // tera
|
||||
15: "P", // peta
|
||||
18: "E", // exa
|
||||
21: "Z", // zetta
|
||||
24: "Y", // yotta
|
||||
}
|
||||
|
||||
var revSIPrefixTable = revfmap(siPrefixTable)
|
||||
|
||||
// revfmap reverses the map and precomputes the power multiplier
|
||||
func revfmap(in map[float64]string) map[string]float64 {
|
||||
rv := map[string]float64{}
|
||||
for k, v := range in {
|
||||
rv[v] = math.Pow(10, k)
|
||||
}
|
||||
return rv
|
||||
}
|
||||
|
||||
var riParseRegex *regexp.Regexp
|
||||
|
||||
func init() {
|
||||
ri := `^([\-0-9.]+)\s?([`
|
||||
for _, v := range siPrefixTable {
|
||||
ri += v
|
||||
}
|
||||
ri += `]?)(.*)`
|
||||
|
||||
riParseRegex = regexp.MustCompile(ri)
|
||||
}
|
||||
|
||||
// ComputeSI finds the most appropriate SI prefix for the given number
|
||||
// and returns the prefix along with the value adjusted to be within
|
||||
// that prefix.
|
||||
//
|
||||
// See also: SI, ParseSI.
|
||||
//
|
||||
// e.g. ComputeSI(2.2345e-12) -> (2.2345, "p")
|
||||
func ComputeSI(input float64) (float64, string) {
|
||||
if input == 0 {
|
||||
return 0, ""
|
||||
}
|
||||
mag := math.Abs(input)
|
||||
exponent := math.Floor(logn(mag, 10))
|
||||
exponent = math.Floor(exponent/3) * 3
|
||||
|
||||
value := mag / math.Pow(10, exponent)
|
||||
|
||||
// Handle special case where value is exactly 1000.0
|
||||
// Should return 1M instead of 1000k
|
||||
if value == 1000.0 {
|
||||
exponent += 3
|
||||
value = mag / math.Pow(10, exponent)
|
||||
}
|
||||
|
||||
value = math.Copysign(value, input)
|
||||
|
||||
prefix := siPrefixTable[exponent]
|
||||
return value, prefix
|
||||
}
|
||||
|
||||
// SI returns a string with default formatting.
|
||||
//
|
||||
// SI uses Ftoa to format float value, removing trailing zeros.
|
||||
//
|
||||
// See also: ComputeSI, ParseSI.
|
||||
//
|
||||
// e.g. SI(1000000, B) -> 1MB
|
||||
// e.g. SI(2.2345e-12, "F") -> 2.2345pF
|
||||
func SI(input float64, unit string) string {
|
||||
value, prefix := ComputeSI(input)
|
||||
return Ftoa(value) + " " + prefix + unit
|
||||
}
|
||||
|
||||
var errInvalid = errors.New("invalid input")
|
||||
|
||||
// ParseSI parses an SI string back into the number and unit.
|
||||
//
|
||||
// See also: SI, ComputeSI.
|
||||
//
|
||||
// e.g. ParseSI(2.2345pF) -> (2.2345e-12, "F", nil)
|
||||
func ParseSI(input string) (float64, string, error) {
|
||||
found := riParseRegex.FindStringSubmatch(input)
|
||||
if len(found) != 4 {
|
||||
return 0, "", errInvalid
|
||||
}
|
||||
mag := revSIPrefixTable[found[2]]
|
||||
unit := found[3]
|
||||
|
||||
base, err := strconv.ParseFloat(found[1], 64)
|
||||
return base * mag, unit, err
|
||||
}
|
||||
117
vendor/github.com/dustin/go-humanize/times.go
generated
vendored
Normal file
117
vendor/github.com/dustin/go-humanize/times.go
generated
vendored
Normal file
@@ -0,0 +1,117 @@
|
||||
package humanize
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Seconds-based time units
|
||||
const (
|
||||
Day = 24 * time.Hour
|
||||
Week = 7 * Day
|
||||
Month = 30 * Day
|
||||
Year = 12 * Month
|
||||
LongTime = 37 * Year
|
||||
)
|
||||
|
||||
// Time formats a time into a relative string.
|
||||
//
|
||||
// Time(someT) -> "3 weeks ago"
|
||||
func Time(then time.Time) string {
|
||||
return RelTime(then, time.Now(), "ago", "from now")
|
||||
}
|
||||
|
||||
// A RelTimeMagnitude struct contains a relative time point at which
|
||||
// the relative format of time will switch to a new format string. A
|
||||
// slice of these in ascending order by their "D" field is passed to
|
||||
// CustomRelTime to format durations.
|
||||
//
|
||||
// The Format field is a string that may contain a "%s" which will be
|
||||
// replaced with the appropriate signed label (e.g. "ago" or "from
|
||||
// now") and a "%d" that will be replaced by the quantity.
|
||||
//
|
||||
// The DivBy field is the amount of time the time difference must be
|
||||
// divided by in order to display correctly.
|
||||
//
|
||||
// e.g. if D is 2*time.Minute and you want to display "%d minutes %s"
|
||||
// DivBy should be time.Minute so whatever the duration is will be
|
||||
// expressed in minutes.
|
||||
type RelTimeMagnitude struct {
|
||||
D time.Duration
|
||||
Format string
|
||||
DivBy time.Duration
|
||||
}
|
||||
|
||||
var defaultMagnitudes = []RelTimeMagnitude{
|
||||
{time.Second, "now", time.Second},
|
||||
{2 * time.Second, "1 second %s", 1},
|
||||
{time.Minute, "%d seconds %s", time.Second},
|
||||
{2 * time.Minute, "1 minute %s", 1},
|
||||
{time.Hour, "%d minutes %s", time.Minute},
|
||||
{2 * time.Hour, "1 hour %s", 1},
|
||||
{Day, "%d hours %s", time.Hour},
|
||||
{2 * Day, "1 day %s", 1},
|
||||
{Week, "%d days %s", Day},
|
||||
{2 * Week, "1 week %s", 1},
|
||||
{Month, "%d weeks %s", Week},
|
||||
{2 * Month, "1 month %s", 1},
|
||||
{Year, "%d months %s", Month},
|
||||
{18 * Month, "1 year %s", 1},
|
||||
{2 * Year, "2 years %s", 1},
|
||||
{LongTime, "%d years %s", Year},
|
||||
{math.MaxInt64, "a long while %s", 1},
|
||||
}
|
||||
|
||||
// RelTime formats a time into a relative string.
|
||||
//
|
||||
// It takes two times and two labels. In addition to the generic time
|
||||
// delta string (e.g. 5 minutes), the labels are used applied so that
|
||||
// the label corresponding to the smaller time is applied.
|
||||
//
|
||||
// RelTime(timeInPast, timeInFuture, "earlier", "later") -> "3 weeks earlier"
|
||||
func RelTime(a, b time.Time, albl, blbl string) string {
|
||||
return CustomRelTime(a, b, albl, blbl, defaultMagnitudes)
|
||||
}
|
||||
|
||||
// CustomRelTime formats a time into a relative string.
|
||||
//
|
||||
// It takes two times two labels and a table of relative time formats.
|
||||
// In addition to the generic time delta string (e.g. 5 minutes), the
|
||||
// labels are used applied so that the label corresponding to the
|
||||
// smaller time is applied.
|
||||
func CustomRelTime(a, b time.Time, albl, blbl string, magnitudes []RelTimeMagnitude) string {
|
||||
lbl := albl
|
||||
diff := b.Sub(a)
|
||||
|
||||
if a.After(b) {
|
||||
lbl = blbl
|
||||
diff = a.Sub(b)
|
||||
}
|
||||
|
||||
n := sort.Search(len(magnitudes), func(i int) bool {
|
||||
return magnitudes[i].D >= diff
|
||||
})
|
||||
|
||||
if n >= len(magnitudes) {
|
||||
n = len(magnitudes) - 1
|
||||
}
|
||||
mag := magnitudes[n]
|
||||
args := []interface{}{}
|
||||
escaped := false
|
||||
for _, ch := range mag.Format {
|
||||
if escaped {
|
||||
switch ch {
|
||||
case 's':
|
||||
args = append(args, lbl)
|
||||
case 'd':
|
||||
args = append(args, diff/mag.DivBy)
|
||||
}
|
||||
escaped = false
|
||||
} else {
|
||||
escaped = ch == '%'
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf(mag.Format, args...)
|
||||
}
|
||||
6
vendor/vendor.json
vendored
6
vendor/vendor.json
vendored
@@ -14,6 +14,12 @@
|
||||
"revision": "2c14e1cca90af49b3b21fe2d7aaa8cc7f9da2ff8",
|
||||
"revisionTime": "2017-04-02T15:00:40Z"
|
||||
},
|
||||
{
|
||||
"checksumSHA1": "zkENTbOfU8YoxPfFwVAhTz516Dg=",
|
||||
"path": "github.com/dustin/go-humanize",
|
||||
"revision": "7a41df006ff9af79a29f0ffa9c5f21fbe6314a2d",
|
||||
"revisionTime": "2017-01-10T07:11:07Z"
|
||||
},
|
||||
{
|
||||
"checksumSHA1": "3z3RDSBixjg5A0XPPwAfrpoajoQ=",
|
||||
"path": "github.com/gliderlabs/ssh",
|
||||
|
||||
Reference in New Issue
Block a user