Compare commits

16 Commits

17 changed files with 222 additions and 226 deletions

2
.gitignore vendored
View File

@@ -1,4 +1,4 @@
dospin.json dospin.json
test_config.json test_config.yaml
dospin dospin
main main

View File

@@ -1,4 +1,4 @@
Copyright 2016 gtalent2@gmail.com Copyright 2016-2017 gtalent2@gmail.com
This Source Code Form is subject to the terms of the Mozilla Public This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this License, v. 2.0. If a copy of the MPL was not distributed with this

View File

@@ -1,5 +1,5 @@
/* /*
Copyright 2016 gtalent2@gmail.com Copyright 2016-2017 gtalent2@gmail.com
This Source Code Form is subject to the terms of the Mozilla Public This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this License, v. 2.0. If a copy of the MPL was not distributed with this
@@ -10,6 +10,7 @@ package main
import ( import (
"flag" "flag"
"log" "log"
"os"
) )
const ( const (
@@ -18,16 +19,16 @@ const (
) )
type cmdOptions struct { type cmdOptions struct {
config string config string
cmd string logFile string
varStateDir string cmd string
} }
func parseCmdOptions() cmdOptions { func parseCmdOptions() cmdOptions {
var o cmdOptions var o cmdOptions
flag.StringVar(&o.cmd, "cmd", CMD_SERVE, "Mode to run command in ("+CMD_SERVE+","+CMD_SPINDOWNALL+")") flag.StringVar(&o.cmd, "cmd", CMD_SERVE, "Mode to run command in ("+CMD_SERVE+","+CMD_SPINDOWNALL+")")
flag.StringVar(&o.config, "config", "/etc/dospin.json", "Path to the dospin config file") flag.StringVar(&o.config, "config", "dospin.yaml", "Path to the dospin config file")
flag.StringVar(&o.varStateDir, "varstate", "/var/lib/dospin", "Path to the var state directory") flag.StringVar(&o.logFile, "logFile", "stdout", "Path to the dospin log file")
flag.Parse() flag.Parse()
return o return o
} }
@@ -47,6 +48,16 @@ func spindownAll(opts cmdOptions) {
} }
func runServer(opts cmdOptions) { func runServer(opts cmdOptions) {
if opts.logFile != "stdout" {
logFile, err := os.OpenFile(opts.logFile, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0664)
if err == nil {
defer logFile.Close()
log.SetOutput(logFile)
} else {
log.Print("Could not open log file: ", err)
}
}
log.Println("Loading config:", opts.config) log.Println("Loading config:", opts.config)
settings, err := loadSettings(opts.config) settings, err := loadSettings(opts.config)
if err != nil { if err != nil {

View File

@@ -1,17 +0,0 @@
{
"ApiToken": "<your token here>",
"GatewayInterface": "eth0:2",
"Servers": {
"minecraft": {
"Ports": [25565],
"UsePublicIP": false,
"InitialSize": "4gb",
"Size": "4gb",
"Region": "nyc1",
"SshKeys": [513314, 1667247],
"ImageSlug": "ubuntu-16-04-x64",
"Volumes": ["volume-nyc1-01"],
"UserData": "#!/bin/bash\napt-get update\napt-get install -y docker.io\nmkdir -p /mnt/volume-nyc1-01\nmount -o discard,defaults /dev/disk/by-id/scsi-0DO_Volume_volume-nyc1-01 /mnt/volume-nyc1-01\necho /dev/disk/by-id/scsi-0DO_Volume_volume-nyc1-01 /mnt/volume-nyc1-01 ext4 defaults,nofail,discard 0 0 | tee -a /etc/fstab\ndocker run -d --restart=always -p 25565:25565 -v /mnt/volume-nyc1-01/minecraft-server:/minecraft-server -w /minecraft-server -t java:8-alpine sh start.sh"
}
}
}

26
dospin.yaml Normal file
View File

@@ -0,0 +1,26 @@
---
api_token: <your token here>
servers:
minecraft:
ports:
- 25565
activity_timeout_min: 20m
use_public_ip: false
initial_size: 4gb
size: 4gb
region: nyc1
ssh_keys:
- Key1
- gtalent2@gmail.com
use_persistent_image: false
image_slug: ubuntu-16-04-x64
volumes:
- volume-nyc1-01
user_data: |-
#!/bin/bash
apt-get update
apt-get install -y docker.io
mkdir -p /mnt/volume-nyc1-01
mount -o discard,defaults /dev/disk/by-id/scsi-0DO_Volume_volume-nyc1-01 /mnt/volume-nyc1-01
echo /dev/disk/by-id/scsi-0DO_Volume_volume-nyc1-01 /mnt/volume-nyc1-01 ext4 defaults,nofail,discard 0 0 | tee -a /etc/fstab
docker run -d --restart=always -p 25565:25565 -v /mnt/volume-nyc1-01/minecraft-server:/minecraft-server -w /minecraft-server -t java:8-alpine sh start.sh

View File

@@ -1,5 +1,5 @@
/* /*
Copyright 2016 gtalent2@gmail.com Copyright 2016-2017 gtalent2@gmail.com
This Source Code Form is subject to the terms of the Mozilla Public This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this License, v. 2.0. If a copy of the MPL was not distributed with this
@@ -28,22 +28,6 @@ func (t *tokenSource) Token() (*oauth2.Token, error) {
return token, nil return token, nil
} }
func sshKeys(ids []int) []godo.DropletCreateSSHKey {
var out []godo.DropletCreateSSHKey
for _, id := range ids {
out = append(out, godo.DropletCreateSSHKey{ID: id})
}
return out
}
func volumes(names []string) []godo.DropletCreateVolume {
var out []godo.DropletCreateVolume
for _, name := range names {
out = append(out, godo.DropletCreateVolume{Name: name})
}
return out
}
type DropletHandler struct { type DropletHandler struct {
client *godo.Client client *godo.Client
settings Settings settings Settings
@@ -103,8 +87,8 @@ func (me *DropletHandler) Spinup(name string) (string, error) {
Region: vd.Region, Region: vd.Region,
Size: size, Size: size,
PrivateNetworking: true, PrivateNetworking: true,
SSHKeys: sshKeys(vd.SshKeys), SSHKeys: me.sshKeys(vd.SshKeys),
Volumes: volumes(vd.Volumes), Volumes: me.volumes(vd.Volumes),
UserData: vd.UserData, UserData: vd.UserData,
Image: image, Image: image,
} }
@@ -153,8 +137,8 @@ func (me *DropletHandler) Spinup(name string) (string, error) {
} }
// delete the image // delete the image
log.Println("Spinup: Deleting image " + name) if image.ID > 0 {
if image.ID > -1 { log.Println("Spinup: Deleting image " + name)
_, err = me.client.Images.Delete(image.ID) _, err = me.client.Images.Delete(image.ID)
if err != nil { if err != nil {
log.Println("Spinup: Could not delete image: ", err) log.Println("Spinup: Could not delete image: ", err)
@@ -182,7 +166,8 @@ func (me *DropletHandler) Spinup(name string) (string, error) {
func (me *DropletHandler) Spindown(name string) error { func (me *DropletHandler) Spindown(name string) error {
droplet, err := me.getDroplet(name) droplet, err := me.getDroplet(name)
if err != nil { if err != nil {
return err // droplet not existing is not an error
return nil
} }
// power off // power off
@@ -192,12 +177,14 @@ func (me *DropletHandler) Spindown(name string) error {
} }
// snapshot existing droplet // snapshot existing droplet
log.Println("Spindown: Creating image " + name) if me.settings.Servers[name].UsePersistentImage {
action, _, err := me.client.DropletActions.Snapshot(droplet.ID, DROPLET_NS+name) log.Println("Spindown: Creating image " + name)
if err != nil || !me.actionWait(action.ID) { action, _, err := me.client.DropletActions.Snapshot(droplet.ID, DROPLET_NS+name)
return err if err != nil || !me.actionWait(action.ID) {
return err
}
log.Println("Spindown: Created image " + name)
} }
log.Println("Spindown: Created image " + name)
// delete droplet // delete droplet
log.Println("Spindown: Deleting droplet " + name) log.Println("Spindown: Deleting droplet " + name)
@@ -318,3 +305,46 @@ func (me *DropletHandler) actionWait(actionId int) bool {
time.Sleep(1000 * time.Millisecond) time.Sleep(1000 * time.Millisecond)
} }
} }
func (me *DropletHandler) sshKeys(keyNames []string) []godo.DropletCreateSSHKey {
// build key map
page := 0
perPage := 200
keyMap := make(map[string]string)
for {
page++
opt := &godo.ListOptions{
Page: page,
PerPage: perPage,
}
keys, _, err := me.client.Keys.List(opt)
if err != nil {
break
}
for _, v := range keys {
keyMap[v.Name] = v.Fingerprint
}
// check next page?
if len(keys) < perPage {
break
}
}
// build output key list
var out []godo.DropletCreateSSHKey
for _, kn := range keyNames {
fp := keyMap[kn]
out = append(out, godo.DropletCreateSSHKey{Fingerprint: fp})
}
return out
}
func (me *DropletHandler) volumes(names []string) []godo.DropletCreateVolume {
var out []godo.DropletCreateVolume
for _, name := range names {
out = append(out, godo.DropletCreateVolume{Name: name})
}
return out
}

View File

@@ -1,2 +0,0 @@
#! /usr/bin/env sh
curl -X GET -H "Content-Type: application/json" -H "Authorization: Bearer `cat dospin.json | jq -r .ApiToken`" "https://api.digitalocean.com/v2/droplets?page=1&per_page=100&private=true" | jq .

View File

@@ -1,2 +0,0 @@
#! /usr/bin/env sh
curl -X GET -H "Content-Type: application/json" -H "Authorization: Bearer `cat dospin.json | jq -r .ApiToken`" "https://api.digitalocean.com/v2/images?page=1&per_page=100&private=true" | jq .

View File

@@ -1,4 +0,0 @@
#! /usr/bin/env sh
echo Keys
curl -X GET -H "Content-Type: application/json" -H "Authorization: Bearer `cat dospin.json | jq -r .ApiToken`" "https://api.digitalocean.com/v2/account/keys" | jq .

View File

@@ -1,4 +0,0 @@
#! /usr/bin/env sh
echo Volumes
curl -X GET -H "Content-Type: application/json" -H "Authorization: Bearer `cat dospin.json | jq -r .ApiToken`" "https://api.digitalocean.com/v2/volumes" | jq .

63
net.go Normal file
View File

@@ -0,0 +1,63 @@
/*
Copyright 2016-2017 gtalent2@gmail.com
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package main
import (
"io"
"log"
"net"
"time"
)
const (
CONN_ACTIVE = iota
CONN_DISCONNECTED
)
type ConnStatus struct {
Status int
Err error
}
func portForward(wanConn *net.TCPConn, lanIp, port string, connStatus chan ConnStatus) {
done := make(chan error)
log.Print("Proxy: Connecting to ", lanIp+":"+port)
lanConn, err := net.Dial("tcp", lanIp+":"+port)
if err != nil {
log.Print("Proxy: LAN dial error: ", err)
return
}
go forwardConn(wanConn, lanConn, done)
go forwardConn(lanConn, wanConn, done)
ticker := time.NewTicker(1 * time.Minute)
for i := 0; i < 2; {
select {
case err = <-done:
if err != nil {
log.Print("Proxy: ", err)
}
i++
case <-ticker.C:
connStatus <- ConnStatus{Status: CONN_ACTIVE}
}
}
log.Print("Proxy: ending connection: ", wanConn.LocalAddr().String())
ticker.Stop()
wanConn.Close()
lanConn.Close()
connStatus <- ConnStatus{Status: CONN_DISCONNECTED, Err: err}
}
func forwardConn(writer, reader net.Conn, done chan error) {
_, err := io.Copy(writer, reader)
done <- err
}

View File

@@ -1,51 +0,0 @@
/*
Copyright 2016 gtalent2@gmail.com
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package main
import (
"bytes"
"log"
"os/exec"
"strconv"
)
func addPortForward(ruleName, ip, port string) {
pfrule := "\"rdr pass on $dospin_ext_if proto { tcp, udp } from any to any port {" + port + "} -> " + ip + "\""
in, err := exec.Command("pfctl", "-a", "\"dospin_"+ruleName+"\"", "-f", "-").StdinPipe()
defer in.Close()
if err != nil {
log.Println("Port Forwarding:", err)
}
_, err = in.Write([]byte(pfrule))
if err != nil {
log.Println("Port Forwarding:", err)
}
}
func rmPortForward(ruleName string) {
_, err := exec.Command("pfctl", "-a", "\"dospin_"+ruleName+"\"", "-F", "rules").Output()
if err != nil {
log.Println("Port Forwarding:", err)
}
}
func portUsageCount(ports ...int) int {
cmd := "sockstat"
args := []string{"-4c"}
for _, v := range ports {
args = append(args, "-p")
args = append(args, strconv.Itoa(v))
}
out, err := exec.Command(cmd, args...).Output()
if err != nil {
log.Println("Port Usage Check: Could not run \""+cmd+"\":", err)
}
return bytes.Count(out, []byte{'\n'}) - 1
}

View File

@@ -1,32 +0,0 @@
/*
Copyright 2016 gtalent2@gmail.com
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package main
import (
"log"
"os/exec"
)
func addPortForward(ruleName, gatewayInt, localIp, targetIp, port string) {
log.Println("Setting up port", port, "to", targetIp)
cmdOut, err := exec.Command("iptables", "-A", "PREROUTING", "-t", "nat", "-i", "\""+gatewayInt+"\"", "-p", "tcp", "--dport", port, "-j", "DNAT", "--to", targetIp+":"+port).Output()
log.Println("iptables", "-A", "PREROUTING,", "-t", "nat", "-i", "\""+gatewayInt+"\"", "-p", "tcp", "--dport", port, "-j", "DNAT", "--to", targetIp+":"+port)
if err != nil {
log.Println("iptables error:", err)
}
cmdOut, err = exec.Command("iptables", "-A", "FORWARD", "-p", "tcp", "-d", targetIp, "--dport", port, "-j", "ACCEPT").Output()
log.Println("iptables", "-A", "FORWARD", "-p", "tcp", "-d", targetIp, "--dport", port, "-j", "ACCEPT")
if err != nil {
log.Println("iptables error:", err)
}
}
func rmPortForward(ruleName string) {
log.Print("Port forwarding not currently implemented for Linux/iptables")
}

View File

@@ -1,27 +0,0 @@
/*
Copyright 2016 gtalent2@gmail.com
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package main
import (
"net"
"strconv"
"testing"
)
func TestPortCount(t *testing.T) {
port := 49214
// listen on some port that nothing should be using for the sake of the test
go func() {
addr, _ := net.ResolveTCPAddr("tcp", "0.0.0.0:"+strconv.Itoa(port))
net.ListenTCP("tcp", addr)
}()
if portUsageCount(49214) != 1 {
t.Errorf("Port count usage reporting wrong number")
}
}

View File

@@ -1,5 +1,5 @@
/* /*
Copyright 2016 gtalent2@gmail.com Copyright 2016-2017 gtalent2@gmail.com
This Source Code Form is subject to the terms of the Mozilla Public This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this License, v. 2.0. If a copy of the MPL was not distributed with this
@@ -11,6 +11,7 @@ import (
"log" "log"
"net" "net"
"strconv" "strconv"
"time"
) )
const ( const (
@@ -31,13 +32,14 @@ type ServerHandler interface {
} }
type ServerManager struct { type ServerManager struct {
name string name string
ports []int ports []int
gatewayInt string in chan serverManagerEvent
in chan serverManagerEvent done chan int
done chan interface{} connStatus chan ConnStatus
usageScore int // spin down server when this reaches 0 lastKeepAliveTime time.Time
server ServerHandler server ServerHandler
activityTimeout time.Duration
} }
func NewServerManager(name string, server ServerHandler, settings Settings) *ServerManager { func NewServerManager(name string, server ServerHandler, settings Settings) *ServerManager {
@@ -45,11 +47,18 @@ func NewServerManager(name string, server ServerHandler, settings Settings) *Ser
sm.name = name sm.name = name
sm.ports = settings.Servers[name].Ports sm.ports = settings.Servers[name].Ports
sm.gatewayInt = settings.GatewayInterface
sm.in = make(chan serverManagerEvent) sm.in = make(chan serverManagerEvent)
sm.done = make(chan interface{}) sm.done = make(chan int)
sm.usageScore = 5 sm.connStatus = make(chan ConnStatus)
sm.server = server sm.server = server
sm.lastKeepAliveTime = time.Now()
activityTimeout, err := time.ParseDuration(settings.Servers[sm.name].ActivityTimeout)
if err != nil { // invalid timeout, default to 5 minutes
activityTimeout = time.Duration(5 * time.Minute)
}
sm.activityTimeout = activityTimeout
log.Println("ServerManager: ", name, " has activity timeout of ", sm.activityTimeout.String())
return sm return sm
} }
@@ -60,16 +69,28 @@ func NewServerManager(name string, server ServerHandler, settings Settings) *Ser
func (me *ServerManager) Serve() { func (me *ServerManager) Serve() {
// TODO: see if server is currently up, and setup port forwarding if so // TODO: see if server is currently up, and setup port forwarding if so
ticker := time.NewTicker(1 * time.Minute)
// event loop // event loop
for running := true; running; { for running := true; running; {
select { select {
case status := <-me.connStatus:
if status.Status == CONN_ACTIVE {
me.lastKeepAliveTime = time.Now()
}
case action := <-me.in: case action := <-me.in:
running = me.serveAction(action) running = me.serveAction(action)
case <-ticker.C:
if time.Since(me.lastKeepAliveTime) > me.activityTimeout {
running = me.serveAction(serverManagerEvent{eventType: SERVERMANAGER_SPINDOWN})
}
} }
} }
ticker.Stop()
// notify done // notify done
me.done <- 42 me.done <- 0
} }
/* /*
@@ -82,7 +103,7 @@ func (me *ServerManager) Spinup(c *net.TCPConn) {
/* /*
Sends the serve loop a spindown message. Sends the serve loop a spindown message.
*/ */
func (me *ServerManager) Spindown(c *net.TCPConn) { func (me *ServerManager) Spindown() {
me.in <- serverManagerEvent{eventType: SERVERMANAGER_SPINDOWN} me.in <- serverManagerEvent{eventType: SERVERMANAGER_SPINDOWN}
} }
@@ -97,18 +118,6 @@ func (me *ServerManager) Done() {
<-me.done <-me.done
} }
func (me *ServerManager) addPortForwards(localIp, remoteIp string) {
log.Println("Ports:", me.ports)
for _, p := range me.ports {
port := strconv.Itoa(p)
addPortForward(me.name, me.gatewayInt, localIp, remoteIp, port)
}
}
func (me *ServerManager) rmPortForwards() {
rmPortForward(me.name)
}
func (me *ServerManager) setupListener(port int) { func (me *ServerManager) setupListener(port int) {
portStr := strconv.Itoa(port) portStr := strconv.Itoa(port)
addr, err := net.ResolveTCPAddr("tcp", "0.0.0.0:"+portStr) addr, err := net.ResolveTCPAddr("tcp", "0.0.0.0:"+portStr)
@@ -127,14 +136,9 @@ func (me *ServerManager) setupListener(port int) {
conn, err := l.AcceptTCP() conn, err := l.AcceptTCP()
if err != nil { if err != nil {
log.Print("Could not accept TCP connection:", err) log.Print("Could not accept TCP connection:", err)
} else { } else { // connection accepted
// connection accepted
// spinup machine // spinup machine
me.Spinup(conn) me.Spinup(conn)
// close existing connection, not doing anything with it
conn.Close()
} }
} }
} }
@@ -146,18 +150,18 @@ func (me *ServerManager) serveAction(event serverManagerEvent) bool {
switch event.eventType { switch event.eventType {
case SERVERMANAGER_SPINUP: case SERVERMANAGER_SPINUP:
targetIp, err := me.server.Spinup(me.name) targetIp, err := me.server.Spinup(me.name)
me.lastKeepAliveTime = time.Now()
if err == nil { if err == nil {
log.Println("ServerManager: Got IP for", me.name+":", targetIp) log.Println("ServerManager: Got IP for", me.name+":", targetIp)
localIp := event.tcpConn.LocalAddr().String() wanAddr := event.tcpConn.LocalAddr().String()
me.addPortForwards(localIp, targetIp) _, port, _ := net.SplitHostPort(wanAddr)
go portForward(event.tcpConn, targetIp, port, me.connStatus)
} else { } else {
log.Println("ServerManager: Could not spin up "+me.name+":", err) log.Println("ServerManager: Could not spin up "+me.name+":", err)
} }
case SERVERMANAGER_SPINDOWN: case SERVERMANAGER_SPINDOWN:
err := me.server.Spindown(me.name) err := me.server.Spindown(me.name)
if err == nil { if err != nil {
me.rmPortForwards()
} else {
log.Println("ServerManager: Could not spin down "+me.name+":", err) log.Println("ServerManager: Could not spin down "+me.name+":", err)
} }
case SERVERMANAGER_STOP: case SERVERMANAGER_STOP:

View File

@@ -1,5 +1,5 @@
/* /*
Copyright 2016 gtalent2@gmail.com Copyright 2016-2017 gtalent2@gmail.com
This Source Code Form is subject to the terms of the Mozilla Public This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this License, v. 2.0. If a copy of the MPL was not distributed with this
@@ -8,26 +8,27 @@
package main package main
import ( import (
"encoding/json" "gopkg.in/yaml.v2"
"io/ioutil" "io/ioutil"
) )
type Settings struct { type Settings struct {
ApiToken string ApiToken string `yaml:"api_token"`
GatewayInterface string Servers map[string]Server `yaml:"servers"`
Servers map[string]Server
} }
type Server struct { type Server struct {
Ports []int Ports []int `yaml:"ports"`
UsePublicIP bool ActivityTimeout string `yaml:"activity_timeout"`
InitialSize string UsePublicIP bool `yaml:"use_public_ip"`
Size string InitialSize string `yaml:"initial_size"`
Region string Size string `yaml:"size"`
ImageSlug string Region string `yaml:"region"`
UserData string UsePersistentImage bool `yaml:"use_persistent_image"`
SshKeys []int ImageSlug string `yaml:"image_slug"`
Volumes []string UserData string `yaml:"user_data"`
SshKeys []string `yaml:"ssh_keys"`
Volumes []string `yaml:"volumes"`
} }
func loadSettings(path string) (Settings, error) { func loadSettings(path string) (Settings, error) {
@@ -37,7 +38,7 @@ func loadSettings(path string) (Settings, error) {
return s, err return s, err
} }
err = json.Unmarshal(data, &s) err = yaml.Unmarshal(data, &s)
if err != nil { if err != nil {
return s, err return s, err
} }

View File

@@ -1,5 +1,5 @@
/* /*
Copyright 2016 gtalent2@gmail.com Copyright 2016-2017 gtalent2@gmail.com
This Source Code Form is subject to the terms of the Mozilla Public This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this License, v. 2.0. If a copy of the MPL was not distributed with this