🎉 remove redis dependency

- Remove mandrill
- Remove redis
- Sessions & more transparency into who's logged into your user
- Session is stored in cookies (map of two integers.)
- Encrypted sessions, configurable keys (auto-generated)
This commit is contained in:
Matej Kramny 2016-04-30 14:28:47 +02:00
parent 8fd27ee16b
commit b2eb39d605
15 changed files with 186 additions and 255 deletions

View File

@ -26,23 +26,6 @@ Please open an issue, tell us you database version & configuration.
---
```
PING to redis unsuccessful
... panic here ...
```
The program cannot reach your redis instance. Check the configuration and test manually with:
```
nc <IP> 6379
PING
+PONG
```
if `netcat` returns immediately, the port is not reachable.
---
## [Milestones](https://github.com/ansible-semaphore/semaphore/milestones)
## [Releases](https://github.com/ansible-semaphore/semaphore/releases)

View File

@ -12,9 +12,17 @@ var Mysql *gorp.DbMap
// Mysql database
func Connect() error {
url := util.Config.MySQL.Username + ":" + util.Config.MySQL.Password + "@tcp(" + util.Config.MySQL.Hostname + ")/?parseTime=true&interpolateParams=true"
db, err := connect()
if err != nil {
return err
}
db, err := sql.Open("mysql", url)
if err := db.Ping(); err != nil {
if err := createDb(); err != nil {
return err
}
db, err = connect()
if err != nil {
return err
}
@ -22,15 +30,31 @@ func Connect() error {
if err := db.Ping(); err != nil {
return err
}
if _, err := db.Exec("create database if not exists " + util.Config.MySQL.DbName); err != nil {
return err
}
if _, err := db.Exec("use " + util.Config.MySQL.DbName); err != nil {
return err
}
Mysql = &gorp.DbMap{Db: db, Dialect: gorp.MySQLDialect{Engine: "InnoDB", Encoding: "UTF8"}}
return nil
}
func createDb() error {
cfg := util.Config.MySQL
url := cfg.Username + ":" + cfg.Password + "@tcp(" + cfg.Hostname + ")/?parseTime=true&interpolateParams=true"
db, err := sql.Open("mysql", url)
if err != nil {
return err
}
if _, err := db.Exec("create database if not exists " + cfg.DbName); err != nil {
return err
}
return nil
}
func connect() (*sql.DB, error) {
cfg := util.Config.MySQL
url := cfg.Username + ":" + cfg.Password + "@tcp(" + cfg.Hostname + ")/" + cfg.DbName + "?parseTime=true&interpolateParams=true"
return sql.Open("mysql", url)
}

View File

@ -1,27 +0,0 @@
package database
import (
"fmt"
"time"
"github.com/ansible-semaphore/semaphore/util"
"gopkg.in/redis.v3"
)
// Redis pool
var Redis *redis.Client
func init() {
Redis = redis.NewClient(&redis.Options{
MaxRetries: 2,
DialTimeout: 10 * time.Second,
Addr: util.Config.SessionDb,
})
}
func RedisPing() {
if _, err := Redis.Ping().Result(); err != nil {
fmt.Println("PING to redis unsuccessful")
panic(err)
}
}

View File

@ -0,0 +1,12 @@
CREATE TABLE `session` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` int(11) NOT NULL,
`created` datetime NOT NULL,
`last_active` datetime NOT NULL,
`ip` varchar(15) NOT NULL DEFAULT '',
`user_agent` text NOT NULL,
`expired` tinyint(1) NOT NULL DEFAULT '0',
PRIMARY KEY (`id`),
KEY `user_id` (`user_id`),
KEY `expired` (`expired`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

View File

@ -1,11 +1,3 @@
redis:
name: redis:latest
image: redis
expose:
- 6379
volumes:
- /tmp/redis:/data
mongodb:
image: mongo:latest
command: mongod --smallfiles --directoryperdb --noprealloc

20
main.go
View File

@ -37,7 +37,6 @@ func main() {
fmt.Printf("Semaphore %v\n", util.Version)
fmt.Printf("Port %v\n", util.Config.Port)
fmt.Printf("MySQL %v@%v %v\n", util.Config.MySQL.Username, util.Config.MySQL.Hostname, util.Config.MySQL.DbName)
fmt.Printf("Redis %v\n", util.Config.SessionDb)
fmt.Printf("Tmp Path (projects home) %v\n", util.Config.TmpPath)
if err := database.Connect(); err != nil {
@ -47,7 +46,6 @@ func main() {
models.SetupDBLink()
defer database.Mysql.Db.Close()
database.RedisPing()
if util.Migration {
fmt.Println("\n Running DB Migrations")
@ -78,16 +76,17 @@ func doSetup() int {
Hello! You will now be guided through a setup to:
1. Set up configuration for a MySQL/MariaDB database
2. Set up redis for session storage
3. Set up a path for your playbooks (auto-created)
4. Run database Migrations
5. Set up initial seamphore user & password
2. Set up a path for your playbooks (auto-created)
3. Run database Migrations
4. Set up initial seamphore user & password
`)
var b []byte
setup := util.ScanSetup()
setup := util.NewConfig()
for true {
setup.Scan()
var err error
b, err = json.MarshalIndent(&setup, " ", "\t")
if err != nil {
@ -104,9 +103,11 @@ func doSetup() int {
}
fmt.Println()
setup = util.ScanSetup()
setup = util.NewConfig()
}
setup.GenerateCookieSecrets()
fmt.Printf(" Running: mkdir -p %v..\n", setup.TmpPath)
os.MkdirAll(setup.TmpPath, 0755)
@ -124,9 +125,6 @@ func doSetup() int {
os.Exit(1)
}
fmt.Println(" Pinging redis..")
database.RedisPing()
fmt.Println("\n Running DB Migrations..")
if err := migration.MigrateAll(); err != nil {
fmt.Printf("\n Database migrations failed!\n %v\n", err.Error())

View File

@ -60,5 +60,6 @@ func init() {
{Major: 1, Minor: 2},
{Major: 1, Minor: 3},
{Major: 1, Minor: 4},
{Major: 1, Minor: 5},
}
}

View File

@ -1,29 +1,13 @@
package models
import (
"encoding/json"
)
import "time"
type Session struct {
ID string `json:"-"`
UserID *int `json:"user_id"`
}
func (session *Session) Encode() []byte {
js, err := json.Marshal(session)
if err != nil {
panic(err)
}
return js
}
func DecodeSession(ID string, sess string) (Session, error) {
var session Session
err := json.Unmarshal([]byte(sess), &session)
session.ID = ID
return session, err
ID int `db:"id" json:"id"`
UserID int `db:"user_id" json:"user_id"`
Created time.Time `db:"created" json:"created"`
LastActive time.Time `db:"last_active" json:"last_active"`
IP string `db:"ip" json:"ip"`
UserAgent string `db:"user_agent" json:"user_agent"`
Expired bool `db:"expired" json:"expired"`
}

View File

@ -13,4 +13,5 @@ func SetupDBLink() {
database.Mysql.AddTableWithName(TaskOutput{}, "task__output").SetUniqueTogether("task_id", "time")
database.Mysql.AddTableWithName(Template{}, "project__template").SetKeys(true, "id")
database.Mysql.AddTableWithName(User{}, "user").SetKeys(true, "id")
database.Mysql.AddTableWithName(Session{}, "session").SetKeys(true, "id")
}

View File

@ -17,7 +17,6 @@
- nodejs
- npm
- mongodb-server
- redis-server
- ansible
- runit
- npm: name={{ item }} global=yes

View File

@ -1,12 +1,8 @@
package routes
import (
"crypto/rand"
"encoding/base64"
"database/sql"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
@ -14,72 +10,71 @@ import (
"github.com/ansible-semaphore/semaphore/models"
"github.com/ansible-semaphore/semaphore/util"
"github.com/gin-gonic/gin"
"gopkg.in/redis.v3"
)
func resetSessionExpiry(sessionID string, ttl time.Duration) {
var cmd *redis.BoolCmd
if ttl == 0 {
cmd = database.Redis.Persist(sessionID)
} else {
cmd = database.Redis.Expire(sessionID, ttl)
}
if err := cmd.Err(); err != nil {
fmt.Println("Cannot reset session expiry:", err)
}
}
func authentication(c *gin.Context) {
var redisKey string
ttl := 7 * 24 * time.Hour
var userID int
if authHeader := strings.ToLower(c.Request.Header.Get("authorization")); len(authHeader) > 0 {
redisKey = "token-session:" + strings.Replace(authHeader, "bearer ", "", 1)
ttl = 0
var token models.APIToken
if err := database.Mysql.SelectOne(&token, "select * from user__token where id=? and expired=0", strings.Replace(authHeader, "bearer ", "", 1)); err != nil {
if err == sql.ErrNoRows {
c.AbortWithStatus(403)
return
}
panic(err)
}
userID = token.UserID
} else {
// fetch session from cookie
cookie, err := c.Request.Cookie("semaphore")
if err != nil {
// create cookie
new_cookie := make([]byte, 32)
if _, err := io.ReadFull(rand.Reader, new_cookie); err != nil {
panic(err)
}
cookie_value := url.QueryEscape(base64.URLEncoding.EncodeToString(new_cookie))
cookie = &http.Cookie{Name: "semaphore", Value: cookie_value, Path: "/", HttpOnly: true}
http.SetCookie(c.Writer, cookie)
}
redisKey = "session:" + cookie.Value
}
s, err := database.Redis.Get(redisKey).Result()
if err == redis.Nil {
// create a session
temp_session := models.Session{}
s = string(temp_session.Encode())
if err := database.Redis.Set(redisKey, s, 0).Err(); err != nil {
panic(err)
}
} else if err != nil {
fmt.Println("Cannot get session from redis:", err)
c.AbortWithStatus(500)
c.AbortWithStatus(403)
return
}
sess, err := models.DecodeSession(redisKey, s)
if err != nil {
fmt.Println("Cannot decode session:", err)
util.AuthFailed(c)
value := make(map[string]interface{})
if err = util.Cookie.Decode("semaphore", cookie.Value, &value); err != nil {
c.AbortWithStatus(403)
panic(err)
}
user, ok := value["user"]
sessionVal, okSession := value["session"]
if !ok || !okSession {
c.AbortWithStatus(403)
return
}
if sess.UserID != nil {
user, err := models.FetchUser(*sess.UserID)
userID = user.(int)
sessionID := sessionVal.(int)
// fetch session
var session models.Session
if err := database.Mysql.SelectOne(&session, "select * from session where id=? and user_id=? and expired=0", sessionID, userID); err != nil {
c.AbortWithStatus(403)
return
}
if time.Now().Sub(session.LastActive).Hours() > 7*24 {
// more than week old unused session
// destroy.
if _, err := database.Mysql.Exec("update session set expired=1 where id=?", sessionID); err != nil {
panic(err)
}
c.AbortWithStatus(403)
return
}
if _, err := database.Mysql.Exec("update session set last_active=NOW() where id=?", sessionID); err != nil {
panic(err)
}
}
user, err := models.FetchUser(userID)
if err != nil {
fmt.Println("Can't find user", err)
c.AbortWithStatus(403)
@ -88,19 +83,3 @@ func authentication(c *gin.Context) {
c.Set("user", user)
}
// reset session expiry
go resetSessionExpiry(redisKey, ttl)
c.Set("session", sess)
c.Next()
}
func MustAuthenticate(c *gin.Context) {
if _, exists := c.Get("user"); !exists {
util.AuthFailed(c)
return
}
c.Next()
}

View File

@ -2,16 +2,17 @@ package routes
import (
"database/sql"
"net/http"
"net/mail"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
"github.com/ansible-semaphore/semaphore/database"
"github.com/ansible-semaphore/semaphore/models"
"github.com/ansible-semaphore/semaphore/util"
"github.com/gin-gonic/gin"
sq "github.com/masterminds/squirrel"
"golang.org/x/crypto/bcrypt"
)
func login(c *gin.Context) {
@ -53,22 +54,36 @@ func login(c *gin.Context) {
return
}
session := c.MustGet("session").(models.Session)
session.UserID = &user.ID
status := database.Redis.Set(session.ID, string(session.Encode()), 7*24*time.Hour)
if err := status.Err(); err != nil {
session := models.Session{
UserID: user.ID,
Created: time.Now(),
LastActive: time.Now(),
IP: c.ClientIP(),
UserAgent: c.Request.Header.Get("user-agent"),
Expired: false,
}
if err := database.Mysql.Insert(&session); err != nil {
panic(err)
}
encoded, err := util.Cookie.Encode("semaphore", map[string]interface{}{
"user": user.ID,
"session": session.ID,
})
if err != nil {
panic(err)
}
http.SetCookie(c.Writer, &http.Cookie{
Name: "semaphore",
Value: encoded,
Path: "/",
})
c.AbortWithStatus(204)
}
func logout(c *gin.Context) {
session := c.MustGet("session").(models.Session)
if err := database.Redis.Del(session.ID).Err(); err != nil {
panic(err)
}
c.SetCookie("semaphore", "", -1, "/", "", false, true)
c.AbortWithStatus(204)
}

View File

@ -21,14 +21,12 @@ func Route(r *gin.Engine) {
// set up the namespace
api := r.Group("/api")
api.Use(authentication)
func(api *gin.RouterGroup) {
api.POST("/login", login)
api.POST("/logout", logout)
}(api.Group("/auth"))
api.Use(MustAuthenticate)
api.Use(authentication)
api.GET("/ws", sockets.Handler)

View File

@ -45,13 +45,6 @@ func createAPIToken(c *gin.Context) {
panic(err)
}
temp_session := models.Session{
UserID: &user.ID,
}
if err := database.Redis.Set("token-session:"+token.ID, temp_session.Encode(), 0).Err(); err != nil {
panic(err)
}
c.JSON(201, token)
}
@ -69,11 +62,9 @@ func expireAPIToken(c *gin.Context) {
panic(err)
}
if affected > 0 {
// remove from redis
if err := database.Redis.Del("token-session:" + tokenID).Err(); err != nil {
panic(err)
}
if affected == 0 {
c.AbortWithStatus(400)
return
}
c.AbortWithStatus(204)

View File

@ -1,20 +1,20 @@
package util
import (
"encoding/base64"
"encoding/json"
"flag"
"fmt"
"os"
"path"
"golang.org/x/crypto/bcrypt"
"github.com/bugsnag/bugsnag-go"
"github.com/gin-gonic/gin"
"github.com/mattbaird/gochimp"
"github.com/gorilla/securecookie"
"golang.org/x/crypto/bcrypt"
)
var mandrillAPI *gochimp.MandrillAPI
var Cookie *securecookie.SecureCookie
var Migration bool
var InteractiveSetup bool
var Upgrade bool
@ -26,25 +26,25 @@ type mySQLConfig struct {
DbName string `json:"name"`
}
type mandrillConfig struct {
Username string `json:"username"`
Password string `json:"password"`
}
type configType struct {
MySQL mySQLConfig `json:"mysql"`
// Format as is with net.Dial
SessionDb string `json:"session_db"`
Mandrill mandrillConfig `json:"mandrill"`
// Format `:port_num` eg, :3000
Port string `json:"port"`
BugsnagKey string `json:"bugsnag_key"`
// semaphore stores projects here
TmpPath string `json:"tmp_path"`
// cookie hashing & encryption
CookieHash string `json:"cookie_hash"`
CookieEncryption string `json:"cookie_encryption"`
}
var Config configType
var Config *configType
func NewConfig() *configType {
return &configType{}
}
func init() {
flag.BoolVar(&InteractiveSetup, "setup", false, "perform interactive setup")
@ -61,16 +61,18 @@ func init() {
flag.Parse()
if printConfig {
b, _ := json.MarshalIndent(&configType{
cfg := &configType{
MySQL: mySQLConfig{
Hostname: "127.0.0.1:3306",
Username: "root",
DbName: "semaphore",
},
SessionDb: "127.0.0.1:6379",
Port: ":3000",
TmpPath: "/tmp/semaphore",
}, "", "\t")
}
cfg.GenerateCookieSecrets()
b, _ := json.MarshalIndent(cfg, "", "\t")
fmt.Println(string(b))
os.Exit(0)
@ -114,15 +116,20 @@ func init() {
Config.Port = ":3000"
}
if len(Config.Mandrill.Password) > 0 {
api, _ := gochimp.NewMandrill(Config.Mandrill.Password)
mandrillAPI = api
}
if len(Config.TmpPath) == 0 {
Config.TmpPath = "/tmp/semaphore"
}
var encryption []byte
encryption = nil
hash, _ := base64.StdEncoding.DecodeString(Config.CookieHash)
if len(Config.CookieEncryption) > 0 {
encryption, _ = base64.StdEncoding.DecodeString(Config.CookieEncryption)
}
Cookie = securecookie.New(hash, encryption)
stage := ""
if gin.Mode() == "release" {
stage = "production"
@ -138,33 +145,15 @@ func init() {
})
}
// encapsulate mandrill providing some defaults
func (conf *configType) GenerateCookieSecrets() {
hash := securecookie.GenerateRandomKey(32)
encryption := securecookie.GenerateRandomKey(32)
func MandrillMessage(important bool) gochimp.Message {
return gochimp.Message{
AutoText: true,
InlineCss: true,
Important: important,
FromName: "Semaphore Daemon",
FromEmail: "noreply@semaphore.local",
}
conf.CookieHash = base64.StdEncoding.EncodeToString(hash)
conf.CookieEncryption = base64.StdEncoding.EncodeToString(encryption)
}
func MandrillRecipient(name string, email string) gochimp.Recipient {
return gochimp.Recipient{
Email: email,
Name: name,
Type: "to",
}
}
func MandrillSend(message gochimp.Message) ([]gochimp.SendResponse, error) {
return mandrillAPI.MessageSend(message, false)
}
func ScanSetup() configType {
var conf configType
func (conf *configType) Scan() {
fmt.Print(" > DB Hostname (default 127.0.0.1:3306): ")
fmt.Scanln(&conf.MySQL.Hostname)
if len(conf.MySQL.Hostname) == 0 {
@ -186,12 +175,6 @@ func ScanSetup() configType {
conf.MySQL.DbName = "semaphore"
}
fmt.Print(" > Redis Connection (default 127.0.0.1:6379): ")
fmt.Scanln(&conf.SessionDb)
if len(conf.SessionDb) == 0 {
conf.SessionDb = "127.0.0.1:6379"
}
fmt.Print(" > Playbook path: ")
fmt.Scanln(&conf.TmpPath)
@ -199,6 +182,4 @@ func ScanSetup() configType {
conf.TmpPath = "/tmp/semaphore"
}
conf.TmpPath = path.Clean(conf.TmpPath)
return conf
}