Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion config.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,4 @@ sessions:
directory: sessions
enabled: true
sshcert:
graceperiod: 4h
graceperiod: 4h
98 changes: 98 additions & 0 deletions config/alertsystem.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package config

import (
"math"
"time"
)

func Alert(c chan AlertInfo, env *Env) {
go listenForLoginAlert(c, env)
}

func listenForLoginAlert(c chan AlertInfo, env *Env) {
// key is username, value is last login time
sshLoginData := make(map[string]time.Time)
// key is string version of IP network address and val is bool
seenNetworks := make(map[string]bool)
sshLoginThresholdData := New()
const maxDataPoints = 1000000
var doAlert bool

for alertInfo := range c {
networkBeenSeen := seenNetworks[alertInfo.IP.String()]
if !networkBeenSeen {
alertInfo.NewNetwork = true
doAlert = true
}
if lastLoginTime, ok := sshLoginData[alertInfo.User]; ok {
timeSince := float64(alertInfo.Timestamp.Sub(lastLoginTime))

addToDeque(sshLoginThresholdData, timeSince, maxDataPoints)
mean := float64(mean(sshLoginThresholdData))
threshold := (3 * stdev(sshLoginThresholdData, mean)) + mean

if timeSince > threshold {
alertInfo.BeenAWhile = true
doAlert = true
}
} else { // first login for this user
alertInfo.FirstLogin = true
doAlert = true
}
sshLoginData[alertInfo.User] = alertInfo.Timestamp
if doAlert || !alertInfo.Success {
go printAlert(alertInfo, env)
}
}
}

// deque is fixed size so it doesn't just keep growing infinitely with more logins.
func addToDeque(deque *Deque, timeSince float64, maxDataPoints int) {
if deque.Size() >= maxDataPoints {
deque.PopRight()
}
deque.PushLeft(timeSince)
}

func mean(deque *Deque) float64 {
sum := 0.0
for idx := 0; idx < deque.Size(); idx++ {
val := deque.PopRight().(float64)
sum += float64(val)
deque.PushLeft(val)
}
return sum / float64(deque.Size())
}

func stdev(deque *Deque, mean float64) float64 {
sum := 0.0
for idx := 0; idx < deque.Size(); idx++ {
val := deque.PopRight().(float64)
sum += (mean - val) * (mean - val)
deque.PushLeft(val)
}
return math.Sqrt(sum / float64(deque.Size()))
}

func printAlert(alertInfo AlertInfo, env *Env) {
alertString := "ALERT!\n"
if alertInfo.NewNetwork {
alertString += "User just attempted connection from a new network.\n"
}
if !alertInfo.Success {
alertString += "User unsuccessfully attempted SSH connection.\n"
}
if alertInfo.BeenAWhile {
alertString += "This is the first time this user has attempted SSH connection in a long time.\n"
}
if alertInfo.FirstLogin {
alertString += "This user has never attempted SSH connection before.\n"
}
alertString += "SSH login details for this alert:\n"
alertString += "User: " + alertInfo.User + "\n"
alertString += "Timestamp: " + alertInfo.Timestamp.Format("Mon Jan _2 15:04:05 2006") + "\n"
alertString += "Network IP: " + alertInfo.IP.String() + "\n"
alertString += "If this information is expected, you may ignore this alert."
// atomic.
env.Blue.Println(alertString)
}
155 changes: 155 additions & 0 deletions config/deque.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
// CookieJar - A contestant's algorithm toolbox
// Copyright (c) 2013 Peter Szilagyi. All rights reserved.
//
// CookieJar is dual licensed: you can redistribute it and/or modify it under
// the terms of the GNU General Public License as published by the Free Software
// Foundation, either version 3 of the License, or (at your option) any later
// version.
//
// The toolbox is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
// more details.
//
// Alternatively, the CookieJar toolbox may be used in accordance with the terms
// and conditions contained in a signed written agreement between you and the
// author(s).

// Package deque implements a double ended queue supporting arbitrary types
// (even a mixture).
//
// Internally it uses a dynamically growing circular slice of blocks, resulting
// in faster resizes than a simple dynamic array/slice would allow, yet less gc
// overhead.
package config

// The size of a block of data
const blockSize = 4096

// Double ended queue data structure.
type Deque struct {
leftIdx int
leftOff int
rightIdx int
rightOff int

blocks [][]interface{}
left []interface{}
right []interface{}
}

// Creates a new, empty deque.
func New() *Deque {
result := new(Deque)
result.blocks = [][]interface{}{make([]interface{}, blockSize)}
result.right = result.blocks[0]
result.left = result.blocks[0]
return result
}

// Pushes a new element into the queue from the right, expanding it if necessary.
func (d *Deque) PushRight(data interface{}) {
d.right[d.rightOff] = data
d.rightOff++
if d.rightOff == blockSize {
d.rightOff = 0
d.rightIdx = (d.rightIdx + 1) % len(d.blocks)

// If we wrapped over to the left, insert a new block and update indices
if d.rightIdx == d.leftIdx {
buffer := make([][]interface{}, len(d.blocks)+1)
copy(buffer[:d.rightIdx], d.blocks[:d.rightIdx])
buffer[d.rightIdx] = make([]interface{}, blockSize)
copy(buffer[d.rightIdx+1:], d.blocks[d.rightIdx:])
d.blocks = buffer
d.leftIdx++
d.left = d.blocks[d.leftIdx]
}
d.right = d.blocks[d.rightIdx]
}
}

// Pops out an element from the queue from the right. Note, no bounds checking are done.
func (d *Deque) PopRight() (res interface{}) {
d.rightOff--
if d.rightOff < 0 {
d.rightOff = blockSize - 1
d.rightIdx = (d.rightIdx - 1 + len(d.blocks)) % len(d.blocks)
d.right = d.blocks[d.rightIdx]
}
res, d.right[d.rightOff] = d.right[d.rightOff], nil
return
}

// Returns the rightmost element from the deque. No bounds are checked.
func (d *Deque) Right() interface{} {
if d.rightOff > 0 {
return d.right[d.rightOff-1]
} else {
return d.blocks[(d.rightIdx-1+len(d.blocks))%len(d.blocks)][blockSize-1]
}
}

// Pushes a new element into the queue from the left, expanding it if necessary.
func (d *Deque) PushLeft(data interface{}) {
d.leftOff--
if d.leftOff < 0 {
d.leftOff = blockSize - 1
d.leftIdx = (d.leftIdx - 1 + len(d.blocks)) % len(d.blocks)

// If we wrapped over to the right, insert a new block and update indices
if d.leftIdx == d.rightIdx {
d.leftIdx++
buffer := make([][]interface{}, len(d.blocks)+1)
copy(buffer[:d.leftIdx], d.blocks[:d.leftIdx])
buffer[d.leftIdx] = make([]interface{}, blockSize)
copy(buffer[d.leftIdx+1:], d.blocks[d.leftIdx:])
d.blocks = buffer
}
d.left = d.blocks[d.leftIdx]
}
d.left[d.leftOff] = data
}

// Pops out an element from the queue from the left. Note, no bounds checking are done.
func (d *Deque) PopLeft() (res interface{}) {
res, d.left[d.leftOff] = d.left[d.leftOff], nil
d.leftOff++
if d.leftOff == blockSize {
d.leftOff = 0
d.leftIdx = (d.leftIdx + 1) % len(d.blocks)
d.left = d.blocks[d.leftIdx]
}
return
}

// Returns the leftmost element from the deque. No bounds are checked.
func (d *Deque) Left() interface{} {
return d.left[d.leftOff]
}

// Checks whether the queue is empty.
func (d *Deque) Empty() bool {
return d.leftIdx == d.rightIdx && d.leftOff == d.rightOff
}

// Returns the number of elements in the queue.
func (d *Deque) Size() int {
if d.rightIdx > d.leftIdx {
return (d.rightIdx-d.leftIdx)*blockSize - d.leftOff + d.rightOff
} else if d.rightIdx < d.leftIdx {
return (len(d.blocks)-d.leftIdx+d.rightIdx)*blockSize - d.leftOff + d.rightOff
} else {
return d.rightOff - d.leftOff
}
}

// Clears out the contents of the queue.
func (d *Deque) Reset() {
d.leftIdx = 0
d.rightIdx = 0
d.leftOff = 0
d.rightOff = 0
d.left = d.blocks[0]
d.right = d.blocks[0]
}
6 changes: 6 additions & 0 deletions config/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/jinzhu/gorm"
_ "github.com/jinzhu/gorm/dialects/mysql" // Load MySQL for GORM
_ "github.com/jinzhu/gorm/dialects/sqlite" // Load SQLite for GORM

"github.com/spf13/viper"
"google.golang.org/api/option"
)
Expand All @@ -20,6 +21,7 @@ const configFile = "config.yml"

// Load initializes the Env pointer with data from the database and elsewhere
func Load(forceCerts bool, webAddr string, sshAddr string, sshProxyAddr string, monAddr string) *Env {

vconfig := viper.New()

vconfig.SetConfigFile(configFile)
Expand Down Expand Up @@ -80,6 +82,8 @@ func Load(forceCerts bool, webAddr string, sshAddr string, sshProxyAddr string,
logsBucket = storageClient.Bucket(bucketName)
}

alertChan := make(chan AlertInfo)

env := &Env{
ForceGeneration: forceCerts,
PKPassphrase: vconfig.GetString("pkpassphrase"),
Expand All @@ -95,12 +99,14 @@ func Load(forceCerts bool, webAddr string, sshAddr string, sshProxyAddr string,
Yellow: yellow,
Blue: blue,
Magenta: magenta,
AlertChannel: alertChan,
HTTPPort: webAddr,
SSHPort: sshAddr,
SSHProxyPort: sshProxyAddr,
MonPort: monAddr,
}

Alert(alertChan, env)
if vconfig.GetBool("debug.info.enabled") {
printDebugInfo(env)
}
Expand Down
13 changes: 13 additions & 0 deletions config/structs.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,25 @@ type Env struct {
Yellow *ColorLog
Blue *ColorLog
Magenta *ColorLog
AlertChannel chan AlertInfo
SSHPort string
SSHProxyPort string
HTTPPort string
MonPort string
}

//used for email alerts on new logins, unsuccessful logins, and auths that occur after a long period of inactivity.
type AlertInfo struct {
User string
IP net.IP
Timestamp time.Time
LoginType string
Success bool
FirstLogin bool
NewNetwork bool
BeenAWhile bool
}

// WsClient is a struct that contains a websockets underlying data object
type WsClient struct {
Client *websocket.Conn
Expand Down
32 changes: 30 additions & 2 deletions ssh/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,8 @@ import (
"sync"
"time"

"github.com/notion/bastion/proxyprotocol"

"github.com/notion/bastion/config"
"github.com/notion/bastion/proxyprotocol"
"golang.org/x/crypto/ssh"
)

Expand Down Expand Up @@ -139,6 +138,35 @@ func handleSession(newChannel ssh.NewChannel, SSHConn *ssh.ServerConn, proxyAddr
serverClient.ProxyTo = host

rawProxyConn, err := net.Dial("tcp", proxyAddr)

ipAddr := rawProxyConn.RemoteAddr().(*net.TCPAddr).IP
var network net.IP
if ipAddr.To4() != nil {
mask := net.CIDRMask(24, 32)
network = ipAddr.Mask(mask)
} else {
mask := net.CIDRMask(48, 128)
network = ipAddr.Mask(mask)
}

alertInfo := &config.AlertInfo{
User: SSHConn.User(),
IP: network,
Timestamp: time.Now(),
LoginType: "ssh",
Success: true,
FirstLogin: false,
NewNetwork: false,
BeenAWhile: false,
}

if !authed {
alertInfo.Success = false
env.AlertChannel <- *alertInfo
} else {
env.AlertChannel <- *alertInfo
}

if err != nil {
env.Red.Println("Unable to establish connection to TCP Socket:", err)
serverClient.Errors = append(serverClient.Errors, fmt.Errorf("Unable to establish remote TCP Socket: %s", err))
Expand Down
3 changes: 1 addition & 2 deletions web/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ func authMiddleware(env *config.Env) func(c *gin.Context) {
return func(c *gin.Context) {
path := strings.TrimSpace(c.Request.URL.Path)
session := sessions.Default(c)

auth := session.Get("loggedin")
otpAuth := session.Get("otpauthed")
if otpAuth != nil {
Expand Down Expand Up @@ -117,7 +116,7 @@ func checkOtp(env *config.Env) func(c *gin.Context) {
}
}

func setupotp(env *config.Env) func(c *gin.Context) {
func setupOtp(env *config.Env) func(c *gin.Context) {
return func(c *gin.Context) {
session := sessions.Default(c)
sessionUser := session.Get("user").(*config.User)
Expand Down
Loading