├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── demo.gif ├── main.go └── tron ├── board.go ├── bot.go ├── config.go ├── db.go ├── game.go ├── player.go ├── score.go └── server.go /.gitignore: -------------------------------------------------------------------------------- 1 | telnet-tron 2 | tron_* 3 | ssh-tron 4 | tron.db 5 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine 2 | MAINTAINER dev@jpillora.com 3 | 4 | #configure go path 5 | ENV GOPATH /root/go 6 | ENV PATH $PATH:/usr/local/go/bin:$GOPATH/bin 7 | 8 | #package 9 | ENV PACKAGE github.com/jpillora/ssh-tron 10 | 11 | #install go and deps, then package, 12 | #move build binaries out then wipe build tools 13 | RUN apk update && \ 14 | apk add git go gzip && \ 15 | go get -v $PACKAGE && \ 16 | mv $GOPATH/bin/* /usr/local/bin/ && \ 17 | rm -rf $GOPATH && \ 18 | apk del git go gzip && \ 19 | echo "Installed $PACKAGE" 20 | 21 | #alternatively, git clone into $GOPATH/src, 22 | #then go get -u $PACKAGE to update deps 23 | 24 | #run package 25 | ENTRYPOINT ["ssh-tron"] 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Jaime Pillora 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## ssh-tron 2 | 3 | Multiplayer [Tron](http://www.thepcmanwebsite.com/media/flash_tron/) (Light Cycles) over SSH, written in Go - ported from a telnet version I'd previously written in Node many years ago. 4 | 5 | ![tron](https://rawgit.com/jpillora/ssh-tron/master/demo.gif) 6 | 7 | ### Demo 8 | 9 | ``` 10 | ssh tron.jpillora.com 11 | ``` 12 | 13 | :warning: This server is in Australia 14 | 15 | ### Install 16 | 17 | #### Docker 18 | 19 | ```sh 20 | #run on the host's port 22 21 | docker run --name=tron -d -p 22:2200 jpillora/ssh-tron 22 | #run on the host's port 22 and save player scores 23 | touch /path/to/tron.db 24 | docker run --name=tron -d -v /path/to/tron.db:/tron.db -p 22:2200 jpillora/ssh-tron --db-location /tron.db 25 | ``` 26 | 27 | #### Binaries 28 | 29 | See [the latest release](https://github.com/jpillora/ssh-tron/releases/latest) or install it now with `curl i.jpillora.com/ssh-tron! | bash` 30 | 31 | #### From Source 32 | 33 | ``` 34 | $ go get -v github.com/jpillora/ssh-tron 35 | ``` 36 | 37 | ### Usage 38 | 39 | Server: 40 | 41 | ``` 42 | $ tron --help 43 | 44 | Usage: ssh-tron [options] 45 | 46 | Options: 47 | --port, -p Port to listen for TCP connections on (default 2200) 48 | --width, -w Width of the game world (default 60) 49 | --height, -h Height of the game world (default 60) 50 | --max-players, -m Maximum number of simultaneous players (default 6) 51 | --game-speed, -g Game tick interval, basically controls how fast each 52 | player moves (default 40ms) 53 | --respawn-delay, -r The time a player must wait before being able to 54 | respawn (default 2s) 55 | --db-location, -d Location of tron.db, stores game score and config (default /tmp/tron.db) 56 | --db-reset Reset all scores in the database 57 | --help 58 | --version, -v 59 | 60 | Author: 61 | jpillora 62 | 63 | Version: 64 | 3.0.1 65 | 66 | Read more: 67 | https://github.com/jpillora/ssh-tron 68 | 69 | $ tron 70 | tron: game started (#6 player slots) 71 | server: up - join at 72 | server: ○ ssh 127.0.0.1 -p 2200 73 | server: ○ ssh 172.27.1.78 -p 2200 74 | server: ○ ssh 192.168.136.1 -p 2200 75 | server: ○ ssh 172.16.4.1 -p 2200 76 | server: fingerprint - 5e:6b:8f:f5:39:af:57:84:3c:5a:a5:32:d7:41:04:b8 77 | ``` 78 | 79 | Players: 80 | 81 | ``` 82 | $ ssh 172.27.1.78 -p 2200 83 | ``` 84 | 85 | *Press `Enter` to spawn* 86 | 87 | ### Known Client Issues 88 | 89 | * Appears best with a dark terminal background 90 | * The refresh rate is quite high, so you'll need a low latency connection to the server to play effectively (in essense, you want your latency to be lower the game speed - which has a default of 40ms/tick). 91 | * Only works on operating systems with [braille unicode characters (e.g. "⠶" and "⠛")](http://en.wikipedia.org/wiki/Braille_Patterns#Chart) installed. Operating systems lacking this character set will cause the walls to render as the missing glyph (square or diamond). 92 | 93 | ### systemd 94 | 95 | Create a `/usr/lib/systemd/system/tron.service` file: 96 | 97 | ``` 98 | [Unit] 99 | Description=ssh-tron 100 | 101 | [Service] 102 | ExecStart=/path/to/my/ssh-tron -p 22 --join-address tron.company 103 | Restart=always 104 | RestartSec=3 105 | 106 | [Install] 107 | WantedBy=multi-user.target 108 | ``` 109 | 110 | Then: 111 | 112 | ``` 113 | systemctl enable tron 114 | systemctl start tron 115 | ``` 116 | 117 | ### Todo 118 | 119 | * Support multi-core (Fix race conditions) 120 | * Optimise game calculations 121 | * Optimise network 122 | * `SPACE` to invert colours 123 | * Add "kills" option (end the game once someone reaches `kills`) 124 | * Add "all players reset on any death" option. 125 | * Extract code to produce a generic 2D multi-player game engine 126 | * Bomber man 127 | * Dungeon explorer 128 | 129 | #### MIT License 130 | 131 | Copyright © 2014 <dev@jpillora.com> 132 | 133 | Permission is hereby granted, free of charge, to any person obtaining 134 | a copy of this software and associated documentation files (the 135 | 'Software'), to deal in the Software without restriction, including 136 | without limitation the rights to use, copy, modify, merge, publish, 137 | distribute, sublicense, and/or sell copies of the Software, and to 138 | permit persons to whom the Software is furnished to do so, subject to 139 | the following conditions: 140 | 141 | The above copyright notice and this permission notice shall be 142 | included in all copies or substantial portions of the Software. 143 | 144 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 145 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 146 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 147 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 148 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 149 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 150 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 151 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jpillora/ssh-tron/c8d16f428056b6fb930b7767c5a39cb128b3cc42/demo.gif -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "math/rand" 6 | "os" 7 | "path/filepath" 8 | "time" 9 | 10 | "github.com/jpillora/opts" 11 | "github.com/jpillora/ssh-tron/tron" 12 | ) 13 | 14 | var VERSION = "0.0.0-src" 15 | 16 | func main() { 17 | 18 | c := tron.Config{ 19 | Port: 2200, 20 | Width: 60, 21 | Height: 60, 22 | MaxPlayers: 6, 23 | // Mode: "kd", 24 | // KickDeaths: 5, 25 | GameSpeed: 40 * time.Millisecond, 26 | RespawnDelay: 2 * time.Second, 27 | DBLocation: filepath.Join(os.TempDir(), "tron.db"), 28 | } 29 | 30 | opts.New(&c). 31 | PkgRepo(). 32 | Version(VERSION). 33 | Parse() 34 | 35 | rand.Seed(time.Now().UnixNano()) 36 | 37 | g, err := tron.NewGame(c) 38 | if err != nil { 39 | log.Fatal(err) 40 | } 41 | g.Play() 42 | } 43 | -------------------------------------------------------------------------------- /tron/board.go: -------------------------------------------------------------------------------- 1 | package tron 2 | 3 | import "errors" 4 | 5 | const ( 6 | wall = ID(0xffff) 7 | blank = ID(0x0000) 8 | ) 9 | 10 | // Board represents a board in a game. 11 | // Each player has a board, which are used to send board deltas. 12 | type Board [][]ID 13 | 14 | // NewBoard returns an initialized Board. 15 | func NewBoard(width, height uint8) (Board, error) { 16 | if height%2 != 0 { 17 | return nil, errors.New("height must be even") 18 | } 19 | if width%2 != 0 { 20 | return nil, errors.New("width must be even") 21 | } 22 | board := make([][]ID, width) 23 | for w := uint8(0); w < width; w++ { 24 | board[w] = make([]ID, height) 25 | for h := uint8(0); h < height; h++ { 26 | board[w][h] = blank 27 | } 28 | } 29 | return board, nil 30 | } 31 | -------------------------------------------------------------------------------- /tron/bot.go: -------------------------------------------------------------------------------- 1 | package tron 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "regexp" 7 | 8 | "github.com/nlopes/slack" 9 | ) 10 | 11 | const topNumPlayers = 10 12 | 13 | type Bot struct { 14 | connected bool 15 | api *slack.Client 16 | channel string 17 | top *Player 18 | scores string 19 | } 20 | 21 | func (b *Bot) init(token, channel string) error { 22 | b.api = slack.New(token) 23 | b.channel = channel 24 | resp, err := b.api.AuthTest() 25 | if err != nil { 26 | return err 27 | } 28 | fmt.Printf("authenticated on slack as: %s\n", resp.User) 29 | b.connected = true 30 | return nil 31 | } 32 | 33 | func (b *Bot) message(msg string) error { 34 | return b.messageTo("#"+b.channel, msg) 35 | } 36 | 37 | func (b *Bot) messageTo(to, msg string) error { 38 | if _, _, err := b.api.PostMessage(to, msg, slack.PostMessageParameters{AsUser: true}); err != nil { 39 | log.Printf("failed to send slack message to: %s: %s", to, err) 40 | return err 41 | } 42 | return nil 43 | } 44 | 45 | var scoresRe = regexp.MustCompile(`(?i)tron\s*scores?\b`) 46 | 47 | func (b *Bot) scoreChange(ps []*Player) { 48 | if len(ps) > topNumPlayers { 49 | ps = ps[:topNumPlayers] 50 | } 51 | var top *Player 52 | b.scores = "" 53 | //keep rendered string of scores 54 | for i, p := range ps { 55 | if i == 0 && p.Kills > 0 { 56 | top = p 57 | } 58 | b.scores += fmt.Sprintf("#%d *%s* `%d` kills\n", p.rank, p.SSHName, p.Kills) 59 | } 60 | //if leader changed, send message 61 | if top != nil && b.top != top && (b.top == nil || top.rank > b.top.rank) { 62 | b.message("*" + top.SSHName + "* has taken the lead!\n\n" + b.scores) 63 | b.top = top 64 | } 65 | } 66 | 67 | func (b *Bot) start() { 68 | rtm := b.api.NewRTM() 69 | go rtm.ManageConnection() 70 | for { 71 | select { 72 | case msg := <-rtm.IncomingEvents: 73 | switch ev := msg.Data.(type) { 74 | case *slack.MessageEvent: 75 | // log.Printf("%s, %s, %s", ev.Channel, ev.Text, ev.User) 76 | if scoresRe.MatchString(ev.Text) { 77 | if ch, err := b.api.GetChannelInfo(ev.Channel); err == nil { 78 | b.messageTo("#"+ch.Name, b.scores) 79 | } else if us, err := b.api.GetUserInfo(ev.User); err == nil { 80 | b.messageTo("@"+us.Name, b.scores) 81 | } else { 82 | b.message(b.scores) 83 | } 84 | } 85 | case *slack.RTMError: 86 | log.Printf("Error: %s\n", ev.Error()) 87 | case *slack.InvalidAuthEvent: 88 | log.Printf("Invalid slack credentials") 89 | return 90 | } 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /tron/config.go: -------------------------------------------------------------------------------- 1 | package tron 2 | 3 | import "time" 4 | 5 | type Config struct { 6 | Port int `help:"Port to listen for TCP connections on" env:"PORT"` 7 | Width int `help:"Width of the game world" min:"32" max:"256"` 8 | Height int `help:"Height of the game world" min:"32" max:"256"` 9 | MaxPlayers int `help:"Maximum number of simultaneous players"` 10 | GameSpeed time.Duration `help:"Game tick interval, basically controls how fast each player moves"` 11 | RespawnDelay time.Duration `help:"The time a player must wait before being able to respawn"` 12 | DBLocation string `help:"Location of tron.db, stores game score and config"` 13 | DBReset bool `help:"Reset all scores in the database"` 14 | JoinAddress string `help:"A friendly DNS address to present to users"` 15 | SlackToken string `help:"Slack chatroom API token" env:"SLACK_TOKEN"` 16 | SlackChannel string `help:"Slack chatroom channel" env:"SLACK_CHANNEL"` 17 | } 18 | 19 | // TODO 20 | // KickDeaths int `help:"Punish bad players by kicking them out after N deaths in a row"` 21 | // Mode string `help:"Score by players running into your trail, or score by creating the longest trail"` 22 | -------------------------------------------------------------------------------- /tron/db.go: -------------------------------------------------------------------------------- 1 | package tron 2 | 3 | import ( 4 | "crypto/rand" 5 | "crypto/rsa" 6 | "crypto/x509" 7 | "encoding/json" 8 | "encoding/pem" 9 | "fmt" 10 | "strings" 11 | 12 | "golang.org/x/crypto/ssh" 13 | 14 | "github.com/boltdb/bolt" 15 | ) 16 | 17 | var ( 18 | playerBucket = []byte("players") 19 | configBucket = []byte("config") 20 | configSSHKey = []byte("ssh-private-key") 21 | ) 22 | 23 | //store is a storage mechanism for 24 | //various game structs. disk or memory. 25 | type Database struct { 26 | *bolt.DB 27 | } 28 | 29 | func NewDatabase(loc string, reset bool) (*Database, error) { 30 | b, err := bolt.Open(loc, 0600, nil) 31 | if err != nil { 32 | return nil, fmt.Errorf("Database error (%s)", err) 33 | } 34 | db := &Database{ 35 | DB: b, 36 | } 37 | if reset { 38 | db.Update(func(tx *bolt.Tx) error { 39 | return tx.DeleteBucket(playerBucket) 40 | }) 41 | } 42 | return db, nil 43 | } 44 | 45 | func (db *Database) save(p *Player) error { 46 | err := db.Update(func(tx *bolt.Tx) error { 47 | ps, err := tx.CreateBucketIfNotExists(playerBucket) 48 | if err != nil { 49 | return err 50 | } 51 | val, err := json.Marshal(p) 52 | if err != nil { 53 | return err 54 | } 55 | if err := ps.Put([]byte(p.hash), val); err != nil { 56 | return err 57 | } 58 | return nil 59 | }) 60 | if err != nil { 61 | // log.Printf("failed to save player scores: %s", p.dbkey) 62 | return err 63 | } 64 | return nil 65 | } 66 | 67 | func (db *Database) load(p *Player) error { 68 | err := db.View(func(tx *bolt.Tx) error { 69 | ps := tx.Bucket(playerBucket) 70 | if ps == nil { 71 | return nil 72 | } 73 | val := ps.Get([]byte(p.hash)) 74 | if val == nil { 75 | return nil 76 | } 77 | tmp := Player{} 78 | if err := json.Unmarshal(val, &tmp); err != nil { 79 | return err 80 | } 81 | //only load KDs 82 | p.Kills = tmp.Kills 83 | p.Deaths = tmp.Deaths 84 | return nil 85 | }) 86 | if err != nil { 87 | // log.Printf("failed to load player scores: %s", p.dbkey) 88 | return err 89 | } 90 | return nil 91 | } 92 | 93 | func (db *Database) loadAll() ([]*Player, error) { 94 | players := []*Player{} 95 | err := db.View(func(tx *bolt.Tx) error { 96 | ps := tx.Bucket(playerBucket) 97 | if ps == nil { 98 | return nil 99 | } 100 | return ps.ForEach(func(key []byte, val []byte) error { 101 | p := &Player{} 102 | if err := json.Unmarshal(val, p); err != nil { 103 | return err 104 | } 105 | p.hash = string(key) 106 | players = append(players, p) 107 | return nil 108 | }) 109 | }) 110 | if err != nil { 111 | return nil, err 112 | } 113 | return players, nil 114 | } 115 | 116 | func (db *Database) GetPrivateKey(s *Server) error { 117 | err := db.View(func(tx *bolt.Tx) error { 118 | b := tx.Bucket(configBucket) 119 | if b == nil { 120 | return nil 121 | } 122 | key := b.Get(configSSHKey) 123 | if key != nil { 124 | //only load RSA keys 125 | if strings.Contains(string(key), "RSA PRIVATE KEY") { 126 | if p, err := ssh.ParsePrivateKey(key); err == nil { 127 | s.privateKey = p 128 | return nil 129 | } 130 | } 131 | } 132 | return nil 133 | }) 134 | if err != nil { 135 | return err 136 | } 137 | if s.privateKey != nil { 138 | return nil 139 | } 140 | val, err := genPrivateKey() 141 | if err != nil { 142 | return err 143 | } 144 | if p, keyerr := ssh.ParsePrivateKey(val); err == nil { 145 | s.privateKey = p 146 | } else { 147 | return keyerr 148 | } 149 | err = db.Update(func(tx *bolt.Tx) error { 150 | if b, err := tx.CreateBucketIfNotExists(configBucket); err != nil { 151 | return err 152 | } else if err := b.Put(configSSHKey, val); err == nil { 153 | return err 154 | } 155 | return nil 156 | }) 157 | if err != nil { 158 | return err 159 | } 160 | return nil 161 | } 162 | 163 | func genPrivateKey() ([]byte, error) { 164 | priv, err := rsa.GenerateKey(rand.Reader, 2048) 165 | if err != nil { 166 | return nil, err 167 | } 168 | key := x509.MarshalPKCS1PrivateKey(priv) 169 | return pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: key}), nil 170 | } 171 | -------------------------------------------------------------------------------- /tron/game.go: -------------------------------------------------------------------------------- 1 | package tron 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "log" 7 | "os" 8 | "os/signal" 9 | "time" 10 | ) 11 | 12 | type ID uint16 13 | 14 | type Game struct { 15 | Config 16 | w, h, bw, bh int // total score+board size 17 | db *Database // database 18 | server *Server // ssh server 19 | score *scoreboard // state 20 | bot *Bot // chat bot 21 | board Board 22 | idPool chan ID 23 | allPlayers map[string]*Player 24 | allPlayersSorted []*Player 25 | currPlayers map[ID]*Player 26 | logf func(format string, args ...interface{}) 27 | } 28 | 29 | // NewGame returns an initialized Game according to the input arguments. 30 | // The main() function should call the Play() method on this Game. 31 | func NewGame(c Config) (*Game, error) { 32 | if c.Height < 32 || c.Height > 255 { 33 | return nil, errors.New("height must be between 32-256") 34 | } 35 | if c.Width < 32 || c.Width > 255 { 36 | return nil, errors.New("width must be between 32-256") 37 | } 38 | db, err := NewDatabase(c.DBLocation, c.DBReset) 39 | if err != nil { 40 | return nil, err 41 | } 42 | board, err := NewBoard(uint8(c.Width), uint8(c.Height)) 43 | if err != nil { 44 | return nil, err 45 | } 46 | // create an id pool 47 | idPool := make(chan ID, c.MaxPlayers) 48 | for id := 1; id <= c.MaxPlayers; id++ { 49 | idPool <- ID(id) 50 | } 51 | server, err := NewServer(db, c.Port, idPool) 52 | if err != nil { 53 | return nil, err 54 | } 55 | g := &Game{ 56 | Config: c, 57 | w: c.Width + sidebarWidth, 58 | h: c.Height / 2, 59 | bw: c.Height, 60 | bh: c.Width, 61 | db: db, 62 | server: server, 63 | bot: &Bot{}, 64 | board: board, 65 | idPool: idPool, 66 | allPlayers: make(map[string]*Player), 67 | currPlayers: make(map[ID]*Player), 68 | logf: log.New(os.Stdout, "tron: ", 0).Printf, 69 | } 70 | g.score = &scoreboard{g: g} 71 | //load initial player list 72 | prevPlayers, err := g.db.loadAll() 73 | if err != nil { 74 | return nil, errors.New("Failed to restore player list") 75 | } 76 | for _, p := range prevPlayers { 77 | g.allPlayers[p.hash] = p 78 | } 79 | // initialise slack if provided 80 | if t := c.SlackToken; t != "" { 81 | ch := c.SlackChannel 82 | if ch == "" { 83 | return nil, errors.New("Slack channel must also be specified (--slack-channel)") 84 | } 85 | if err := g.bot.init(t, ch); err != nil { 86 | return nil, err 87 | } 88 | motd := "tron server started\n" 89 | if g.Config.JoinAddress != "" { 90 | motd += fmt.Sprintf("join using: `ssh %s`", g.Config.JoinAddress) 91 | } else { 92 | motd += fmt.Sprintf("join using:\n```\n%s\n```\n", g.server.addresses) 93 | } 94 | if err := g.bot.message(motd); err != nil { 95 | return nil, err 96 | } 97 | go g.bot.start() 98 | } 99 | //compute initial score, load into slackbot 100 | g.score.compute() 101 | //game ready 102 | return g, nil 103 | } 104 | 105 | func (g *Game) Play() { 106 | // build walls 107 | for w := 0; w < g.bw; w++ { 108 | g.board[w][0] = wall 109 | g.board[w][g.bh-1] = wall 110 | } 111 | for h := 0; h < g.bh; h++ { 112 | g.board[0][h] = wall 113 | g.board[g.bw-1][h] = wall 114 | } 115 | 116 | // start the game ticker! 117 | go g.tick() 118 | 119 | // ready for players! 120 | g.logf("game started (#%d player slots, %s/tick)", len(g.idPool), g.Config.GameSpeed) 121 | 122 | // watch signals (catch Ctrl+C and gracefully shutdown) 123 | c := make(chan os.Signal, 1) 124 | signal.Notify(c, os.Interrupt, os.Kill) 125 | go g.watch(c) 126 | addr := g.Config.JoinAddress 127 | if addr == "" { 128 | addr = "\n" + g.server.addresses 129 | } 130 | // start the ssh server 131 | go g.server.start() 132 | g.logf("server up (fingerprint %s)\njoin at: %s\n", fingerprintKey(g.server.privateKey.PublicKey()), addr) 133 | // handle incoming players forever (channel never closed) 134 | for p := range g.server.newPlayers { 135 | go g.handle(p) 136 | } 137 | } 138 | 139 | func (g *Game) watch(c chan os.Signal) { 140 | <-c 141 | g.logf("game ending...") 142 | for _, p := range g.currPlayers { 143 | p.teardown() 144 | } 145 | g.db.Close() 146 | time.Sleep(300 * time.Millisecond) 147 | os.Exit(0) 148 | } 149 | 150 | func (g *Game) handle(p *Player) { 151 | // check not already connected 152 | if existing, ok := g.allPlayers[p.hash]; ok && existing.id != blank { 153 | p.teardown() 154 | p.logf("rejected - already connected as %s", existing.cname) 155 | g.idPool <- p.id //put back 156 | return 157 | } 158 | // attempt to load previous scores 159 | if err := g.db.load(p); err != nil { 160 | //otherwise new player 161 | g.db.save(p) 162 | } 163 | // connected with a valid id 164 | p.g = g 165 | g.allPlayers[p.hash] = p 166 | g.currPlayers[p.id] = p 167 | g.score.compute() 168 | // connected 169 | p.play() //block while playing 170 | // disconnected 171 | g.remove(p) 172 | delete(g.currPlayers, p.id) 173 | // reinsert back into pool 174 | g.idPool <- p.id 175 | p.id = blank 176 | p.teardown() 177 | } 178 | 179 | func (g *Game) death(p *Player) { 180 | p.Deaths++ 181 | g.score.compute() 182 | go g.db.save(p) //save new death count 183 | p.tdeath = time.Now() 184 | g.remove(p) 185 | } 186 | 187 | //time to keep players trail around after death 188 | var deathTrail = 1 * time.Second 189 | 190 | func (g *Game) remove(p *Player) { 191 | p.waiting = true 192 | //respawn/deathtrail time 193 | if g.RespawnDelay > deathTrail { 194 | time.Sleep(deathTrail) 195 | } else { 196 | time.Sleep(g.RespawnDelay) 197 | } 198 | // clear this player off the board! 199 | for w := 0; w < g.bw; w++ { 200 | for h := 0; h < g.bh; h++ { 201 | if g.board[w][h] == p.id { 202 | g.board[w][h] = blank 203 | } 204 | } 205 | } 206 | //respawn extra 207 | if g.RespawnDelay > deathTrail { 208 | time.Sleep(g.RespawnDelay - deathTrail) 209 | } 210 | p.waiting = false 211 | } 212 | 213 | func (g *Game) tick() { 214 | // loop forever 215 | for { 216 | t0 := time.Now() 217 | // move each player 1 square 218 | for _, p := range g.currPlayers { 219 | // skip this player 220 | if p.dead { 221 | continue 222 | } 223 | // move player in [d]irection 224 | p.d = p.nextd 225 | switch p.d { 226 | case dup: 227 | p.y-- 228 | case ddown: 229 | p.y++ 230 | case dleft: 231 | p.x-- 232 | case dright: 233 | p.x++ 234 | } 235 | // player is in a wall 236 | if g.board[p.x][p.y] != blank { 237 | // is it another player's wall? kills++ 238 | id := g.board[p.x][p.y] 239 | if other, ok := g.currPlayers[id]; ok && other != p { 240 | other.Kills++ 241 | g.score.compute() 242 | go g.db.save(other) //save new kill count 243 | other.logf("killed %s", p.cname) 244 | } 245 | // this player dies... 246 | p.dead = true 247 | go g.death(p) 248 | continue 249 | } 250 | // place a player square 251 | g.board[p.x][p.y] = p.id 252 | } 253 | // update bot score list 254 | if g.score.changed && g.bot.connected { 255 | g.bot.scoreChange(g.score.allPlayersSorted) 256 | } 257 | // send delta updates to each player 258 | for _, p := range g.currPlayers { 259 | if p.ready { 260 | p.update() 261 | } 262 | } 263 | // mark score as used 264 | g.score.changed = false 265 | // game sleep! (attempt to stablize game speed) 266 | cpu := time.Now().Sub(t0) 267 | time.Sleep(g.GameSpeed - cpu*2) 268 | } 269 | } 270 | -------------------------------------------------------------------------------- /tron/player.go: -------------------------------------------------------------------------------- 1 | package tron 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "math" 7 | "math/rand" 8 | "os" 9 | "sync" 10 | "time" 11 | 12 | "github.com/jpillora/ansi" 13 | "golang.org/x/crypto/ssh" 14 | ) 15 | 16 | const slotHeight = 4 17 | 18 | var ( 19 | filled = '⣿' 20 | top = '⠛' 21 | bottom = '⣤' 22 | empty = ' ' 23 | ) 24 | 25 | type Direction byte 26 | 27 | const ( 28 | dup Direction = iota + 65 29 | ddown 30 | dright 31 | dleft 32 | ) 33 | 34 | func (d Direction) String() string { 35 | switch d { 36 | case dup: 37 | return "up" 38 | case ddown: 39 | return "down" 40 | case dleft: 41 | return "left" 42 | case dright: 43 | return "right" 44 | default: 45 | return fmt.Sprintf("%d", d) 46 | } 47 | } 48 | 49 | var colours = map[ID][]byte{ 50 | blank: ansi.Set(ansi.White), 51 | wall: ansi.Set(ansi.White), 52 | ID(1): ansi.Set(ansi.Blue), 53 | ID(2): ansi.Set(ansi.Green), 54 | ID(3): ansi.Set(ansi.Magenta), 55 | ID(4): ansi.Set(ansi.Cyan), 56 | ID(5): ansi.Set(ansi.Yellow), 57 | ID(6): ansi.Set(ansi.Red), 58 | } 59 | 60 | type resize struct { 61 | width, height uint32 62 | } 63 | 64 | // A Player represents a live TCP connection from a client 65 | type Player struct { 66 | id ID // identification 67 | hash string //hash of public key 68 | SSHName, Name, cname string 69 | rank, index int 70 | x, y uint8 // position 71 | d Direction // curr direction 72 | nextd Direction // next direction 73 | w, h int // terminal size 74 | screenRunes [][]rune // the player's view of the screen 75 | screenColors [][]ID // the player's view of the screen 76 | score [slotHeight]string 77 | scoreDrawn, redraw bool 78 | dead, ready, waiting bool 79 | tdeath time.Time // time of death 80 | Kills, Deaths int // score 81 | playing chan bool // is playing signal 82 | g *Game 83 | resizes chan resize 84 | conn *ansi.Ansi 85 | logf func(format string, args ...interface{}) 86 | once *sync.Once 87 | } 88 | 89 | // NewPlayer returns an initialized Player. 90 | func NewPlayer(id ID, sshName, name, hash string, conn ssh.Channel) *Player { 91 | if hash == "" { 92 | hash = name //finally, hash fallsback to name 93 | } 94 | colouredName := fmt.Sprintf("%s%s%s", colours[id], name, ansi.Set(ansi.Reset)) 95 | p := &Player{ 96 | id: id, 97 | hash: hash, 98 | SSHName: sshName, 99 | Name: name, 100 | cname: colouredName, 101 | d: dup, 102 | dead: true, 103 | ready: false, 104 | playing: make(chan bool, 1), 105 | resizes: make(chan resize), 106 | conn: ansi.Wrap(conn), 107 | logf: log.New(os.Stdout, colouredName+" ", 0).Printf, 108 | once: &sync.Once{}, 109 | } 110 | return p 111 | } 112 | 113 | func (p *Player) resetScreen() { 114 | p.screenRunes = make([][]rune, p.g.w) 115 | p.screenColors = make([][]ID, p.g.w) 116 | for w := 0; w < p.g.w; w++ { 117 | p.screenRunes[w] = make([]rune, p.g.h) 118 | p.screenColors[w] = make([]ID, p.g.h) 119 | for h := 0; h < p.g.h; h++ { 120 | p.screenRunes[w][h] = empty 121 | p.screenColors[w][h] = ID(255) 122 | } 123 | } 124 | p.redraw = true 125 | } 126 | 127 | const ( 128 | respawnAttempts = 100 129 | respawnLookahead = 15 130 | ) 131 | 132 | func (p *Player) respawn() { 133 | if !p.dead || !p.ready || p.waiting { 134 | return 135 | } 136 | for i := 0; i < respawnAttempts; i++ { 137 | // randomly spawn player 138 | p.x = uint8(rand.Intn(int(p.g.bw-2))) + 1 139 | p.y = uint8(rand.Intn(int(p.g.bh-2))) + 1 140 | p.d = Direction(uint8(rand.Intn(4) + 65)) 141 | p.nextd = p.d 142 | // look ahead 143 | clear := true 144 | x, y := p.x, p.y 145 | for j := 0; j < respawnLookahead; j++ { 146 | switch p.d { 147 | case dup: 148 | y-- 149 | case ddown: 150 | y++ 151 | case dleft: 152 | x-- 153 | case dright: 154 | x++ 155 | } 156 | if p.g.board[x][y] != blank { 157 | clear = false 158 | break 159 | } 160 | } 161 | // when clear, mark player as alive 162 | if clear { 163 | p.dead = false 164 | break 165 | } 166 | } 167 | } 168 | 169 | func (p *Player) play() { 170 | p.logf("connected") 171 | p.conn.Set(ansi.Reset) 172 | p.conn.CursorHide() 173 | go p.resizeWatch() 174 | go p.recieveActions() 175 | // block until player disconnects 176 | <-p.playing 177 | p.logf("disconnected") 178 | } 179 | 180 | func (p *Player) teardown() { 181 | // guard teardown to execute only once per player 182 | p.once.Do(p.teardownMeta) 183 | } 184 | 185 | func (p *Player) teardownMeta() { 186 | p.conn.CursorShow() 187 | p.conn.EraseScreen() 188 | p.conn.Goto(1, 1) 189 | p.conn.Set(ansi.Reset) 190 | p.conn.Close() 191 | close(p.playing) 192 | } 193 | 194 | func (p *Player) status() string { 195 | if !p.ready { 196 | return "not ready" 197 | } else if p.dead && p.waiting { 198 | return fmt.Sprintf("dead %1.1f", (p.g.RespawnDelay - time.Since(p.tdeath)).Seconds()) 199 | } else if p.dead { 200 | return "ready" 201 | } 202 | return "playing" 203 | } 204 | 205 | func (p *Player) recieveActions() { 206 | buff := make([]byte, 0xffff) 207 | for { 208 | n, err := p.conn.Read(buff) 209 | if err != nil { 210 | break 211 | } 212 | b := buff[:n] 213 | if b[0] == 3 { 214 | break 215 | } 216 | // ignore actions until ready 217 | if !p.ready { 218 | continue 219 | } 220 | // parse up,down,left,right 221 | d := byte(p.d) 222 | if len(b) == 3 && b[0] == ansi.Esc && b[1] == 91 && 223 | b[2] >= byte(dup) && b[2] <= byte(dleft) && 224 | // while preventing player from moving into itself (odd<->even) 225 | ((d%2 == 0 && d-1 != b[2]) || ((d+1)%2 == 0 && d+1 != b[2])) { 226 | p.nextd = Direction(b[2]) 227 | continue 228 | } 229 | // respawn! 230 | if b[0] == 13 { 231 | p.respawn() 232 | continue 233 | } 234 | // p.logf("sent action %+v", b) 235 | } 236 | p.teardown() 237 | } 238 | 239 | var resizeTmpl = string(ansi.Goto(2, 5)) + 240 | string(ansi.Set(ansi.White)) + 241 | "Please resize your terminal to %dx%d (+%dx+%d)" 242 | 243 | func (p *Player) resizeWatch() { 244 | for r := range p.resizes { 245 | p.w = int(r.width) 246 | p.h = int(r.height) 247 | // fits? 248 | if p.w >= p.g.w && p.h >= p.g.h { 249 | p.conn.EraseScreen() 250 | p.resetScreen() 251 | // send updates! 252 | p.ready = true 253 | } else { 254 | // doesnt fit 255 | p.conn.EraseScreen() 256 | p.conn.Write([]byte(fmt.Sprintf(resizeTmpl, p.g.w, p.g.h, 257 | int(math.Max(float64(p.g.w-p.w), 0)), 258 | int(math.Max(float64(p.g.h-p.h), 0))))) 259 | p.screenRunes = nil 260 | p.ready = false 261 | } 262 | } 263 | } 264 | 265 | // every tick, based on player screen size - calculate, store and send screen deltas. 266 | func (p *Player) update() { 267 | if !p.ready { 268 | return 269 | } 270 | g := p.g 271 | gb := g.board 272 | // score state 273 | totalPlayers := len(g.score.allPlayersSorted) 274 | maxLines := (g.h - 1) - 2 //height units - borders 275 | maxSlots := maxLines / slotHeight //each player needs 3 lines 276 | halfSlots := maxSlots / 2 277 | startIndex := p.index - halfSlots 278 | if startIndex < 0 { 279 | startIndex = 0 280 | } 281 | // center board (origin) with offset width and height 282 | ow := (p.w - g.w) / 2 283 | oh := (p.h - g.h) / 2 284 | // store the last rendered for network optimisation 285 | var lastw, lasth uint16 286 | var r rune 287 | var c ID 288 | // screen loop 289 | var u []byte 290 | for h := 0; h < g.h; h++ { 291 | for tw := 0; tw < g.w; tw++ { 292 | // each iteration draws rune (r) and color (c) 293 | // at terminal location: w x h 294 | r = empty 295 | c = blank 296 | // choose a rune to draw, either from 297 | // sidebar or from game board 298 | if tw < sidebarWidth { 299 | // pick rune from sidebar 300 | if tw == 0 { 301 | r = filled 302 | } else if h == 0 { 303 | r = top 304 | } else if h == g.h-1 { 305 | r = bottom 306 | } else { 307 | bh := h - 1 //borderless height 308 | playerSlot := bh / slotHeight 309 | playerIndex := startIndex + playerSlot 310 | if playerIndex < totalPlayers { 311 | sp := g.score.allPlayersSorted[playerIndex] 312 | line := bh % slotHeight 313 | if tw == 1 { 314 | switch line { 315 | case 0: 316 | sp.score[0] = fmt.Sprintf("%s ", sp.Name) 317 | case 1: 318 | sp.score[1] = fmt.Sprintf(" rank #%03d ", sp.rank) 319 | case 2: 320 | sp.score[2] = fmt.Sprintf(" %s ", sp.status()) 321 | case 3: 322 | sp.score[3] = fmt.Sprintf(" kills %4d ", sp.Kills) 323 | } 324 | } 325 | if tw-1 < len(sp.score[line]) { 326 | r = rune(sp.score[line][tw-1]) 327 | c = sp.id 328 | } 329 | } 330 | } 331 | } else { 332 | // pick rune from game board, one rune is two game tiles 333 | gw := tw - sidebarWidth 334 | h1 := h * 2 335 | h2 := h1 + 1 336 | // choose rune 337 | if gb[gw][h1] != blank && gb[gw][h2] != blank { 338 | r = filled 339 | } else if gb[gw][h1] != blank { 340 | r = top 341 | } else if gb[gw][h2] != blank { 342 | r = bottom 343 | } 344 | // choose color (use color of h1, otherwise h2) 345 | if gb[gw][h2] == blank { 346 | c = gb[gw][h1] 347 | } else { 348 | c = gb[gw][h2] 349 | } 350 | } 351 | // player board is different? draw it 352 | if p.screenRunes[tw][h] != r || 353 | (p.screenRunes[tw][h] != empty && p.screenColors[tw][h] != c) { 354 | // skip if we only moved one space right 355 | nexth := uint16(h + 1 + oh) 356 | nextw := uint16(tw + 1 + ow) 357 | if nexth != lasth || nextw != lastw+1 { 358 | u = append(u, ansi.Goto(nexth, nextw)...) 359 | lasth = nexth 360 | lastw = nextw 361 | } 362 | // p.logf("draw [%d,%d] '%s' (%d)", nexth, nextw, string(r), c) 363 | // write color 364 | u = append(u, colours[c]...) 365 | p.screenColors[tw][h] = c 366 | // write rune 367 | u = append(u, []byte(string(r))...) 368 | p.screenRunes[tw][h] = r 369 | } 370 | } 371 | } 372 | if len(u) == 0 { 373 | return 374 | } 375 | p.conn.Write(u) 376 | // p.logf("send %d", len(u)) 377 | } 378 | -------------------------------------------------------------------------------- /tron/score.go: -------------------------------------------------------------------------------- 1 | package tron 2 | 3 | import "sort" 4 | 5 | const ( 6 | sidebarWidth = 14 7 | sidebarEntryHeight = 4 8 | ) 9 | 10 | //scoreboard 11 | type scoreboard struct { 12 | g *Game 13 | changed bool 14 | allPlayersSorted []*Player 15 | } 16 | 17 | //compute the score 18 | func (s *scoreboard) compute() { 19 | // pull in player list and sort 20 | sorted := make([]*Player, len(s.g.allPlayers)) 21 | i := 0 22 | for _, p := range s.g.allPlayers { 23 | sorted[i] = p 24 | i++ 25 | } 26 | sort.Sort(byScore(sorted)) 27 | if s.allPlayersSorted == nil { 28 | s.changed = true 29 | } 30 | if len(sorted) > 0 { 31 | //place a rank on all players 32 | last := sorted[0] 33 | if last.index != 0 { 34 | s.changed = true 35 | } 36 | last.rank = 1 37 | for i = 1; i < len(sorted); i++ { 38 | p := sorted[i] 39 | if p.Kills == last.Kills && 40 | p.Deaths == last.Deaths { 41 | p.rank = last.rank 42 | } else { 43 | p.rank = last.rank + 1 44 | } 45 | if p.index != i { 46 | s.changed = true 47 | } 48 | p.index = i 49 | last = p 50 | } 51 | } 52 | if s.g.bot.connected && s.changed { 53 | s.g.bot.scoreChange(sorted) 54 | } 55 | s.allPlayersSorted = sorted 56 | } 57 | 58 | // byScore implements the sort.Interface to sort the players by score. 59 | // The scores are first influenced by kills, then deaths and then names. 60 | type byScore []*Player 61 | 62 | func (ps byScore) Len() int { return len(ps) } 63 | func (ps byScore) Swap(i, j int) { ps[i], ps[j] = ps[j], ps[i] } 64 | func (ps byScore) Less(i, j int) bool { 65 | if ps[i].Kills > ps[j].Kills { 66 | return true 67 | } else if ps[i].Kills < ps[j].Kills { 68 | return false 69 | } else if ps[i].Deaths < ps[j].Deaths { 70 | return true 71 | } else if ps[i].Deaths > ps[j].Deaths { 72 | return false 73 | } 74 | return ps[i].hash < ps[j].hash 75 | } 76 | -------------------------------------------------------------------------------- /tron/server.go: -------------------------------------------------------------------------------- 1 | package tron 2 | 3 | import ( 4 | "crypto/md5" 5 | "encoding/binary" 6 | "encoding/hex" 7 | "fmt" 8 | "log" 9 | "net" 10 | "os" 11 | "regexp" 12 | "strings" 13 | 14 | "golang.org/x/crypto/ssh" 15 | ) 16 | 17 | var ( 18 | matchip = regexp.MustCompile(`^\d+\.\d+\.\d+\.\d+`) // TODO: make correct 19 | filtername = regexp.MustCompile(`\W`) // non-words 20 | ) 21 | 22 | type Server struct { 23 | port int 24 | addresses string 25 | idPool <-chan ID 26 | logf func(format string, args ...interface{}) 27 | privateKey ssh.Signer 28 | newPlayers chan *Player 29 | } 30 | 31 | func NewServer(db *Database, port int, idPool <-chan ID) (*Server, error) { 32 | s := &Server{ 33 | port: port, 34 | idPool: idPool, 35 | logf: log.New(os.Stdout, "server: ", 0).Printf, 36 | newPlayers: make(chan *Player), 37 | } 38 | if err := db.GetPrivateKey(s); err != nil { 39 | return nil, err 40 | } 41 | if addrs, err := net.InterfaceAddrs(); err == nil { 42 | joins := []string{} 43 | for _, a := range addrs { 44 | ipv4 := matchip.FindString(a.String()) 45 | if ipv4 != "" { 46 | joins = append(joins, fmt.Sprintf(" ssh %s -p %d", ipv4, s.port)) 47 | } 48 | } 49 | s.addresses = strings.Join(joins, "\n") 50 | } 51 | return s, nil 52 | } 53 | 54 | func (s *Server) start() { 55 | // bind to provided port 56 | server, err := net.ListenTCP("tcp4", &net.TCPAddr{Port: s.port}) 57 | if err != nil { 58 | log.Fatal(err) 59 | } 60 | // accept all tcp 61 | for { 62 | tcpConn, err := server.AcceptTCP() 63 | if err != nil { 64 | s.logf("accept error (%s)", err) 65 | continue 66 | } 67 | go s.handle(tcpConn) 68 | } 69 | } 70 | 71 | func (s *Server) handle(tcpConn *net.TCPConn) { 72 | //extract these from connection 73 | var sshName string 74 | var hash string 75 | // perform handshake 76 | config := &ssh.ServerConfig{ 77 | PublicKeyCallback: func(conn ssh.ConnMetadata, publicKey ssh.PublicKey) (*ssh.Permissions, error) { 78 | sshName = conn.User() 79 | if publicKey != nil { 80 | m := md5.Sum(publicKey.Marshal()) 81 | hash = hex.EncodeToString(m[:]) 82 | } 83 | return nil, nil 84 | }, 85 | } 86 | config.AddHostKey(s.privateKey) 87 | sshConn, chans, globalReqs, err := ssh.NewServerConn(tcpConn, config) 88 | if err != nil { 89 | s.logf("new connection handshake failed (%s)", err) 90 | return 91 | } 92 | // global requests must be serviced - discard 93 | go ssh.DiscardRequests(globalReqs) 94 | // protect against XTR (cross terminal renderering) attacks 95 | name := filtername.ReplaceAllString(sshName, "") 96 | // trim name 97 | maxlen := sidebarWidth - 1 98 | if len(name) > maxlen { 99 | name = string([]rune(name)[:maxlen]) 100 | } 101 | // get the first channel 102 | c := <-chans 103 | // channel requests must be serviced - reject rest 104 | go func() { 105 | for c := range chans { 106 | c.Reject(ssh.Prohibited, "only 1 channel allowed") 107 | } 108 | }() 109 | // must be a 'session' 110 | if t := c.ChannelType(); t != "session" { 111 | c.Reject(ssh.UnknownChannelType, fmt.Sprintf("unknown channel type: %s", t)) 112 | sshConn.Close() 113 | return 114 | } 115 | conn, chanReqs, err := c.Accept() 116 | if err != nil { 117 | s.logf("could not accept channel (%s)", err) 118 | sshConn.Close() 119 | return 120 | } 121 | // non-blocking pull off the id pool 122 | id := ID(0) 123 | select { 124 | case id, _ = <-s.idPool: 125 | default: 126 | } 127 | // show fullgame error 128 | if id == 0 { 129 | conn.Write([]byte("This game is full.\r\n")) 130 | sshConn.Close() 131 | return 132 | } 133 | // default name using id 134 | if name == "" { 135 | name = fmt.Sprintf("player-%d", id) 136 | } 137 | // if user has no public key for some strange reason, use their ip as their unique id 138 | if hash == "" { 139 | if ip, _, err := net.SplitHostPort(tcpConn.RemoteAddr().String()); err == nil { 140 | hash = ip 141 | } 142 | } 143 | p := NewPlayer(id, sshName, name, hash, conn) 144 | go func() { 145 | for r := range chanReqs { 146 | ok := false 147 | switch r.Type { 148 | case "shell": 149 | // We don't accept any commands (Payload), 150 | // only the default shell. 151 | if len(r.Payload) == 0 { 152 | ok = true 153 | } 154 | case "pty-req": 155 | // Responding 'ok' here will let the client 156 | // know we have a pty ready for input 157 | ok = true 158 | strlen := r.Payload[3] 159 | p.resizes <- parseDims(r.Payload[strlen+4:]) 160 | case "window-change": 161 | p.resizes <- parseDims(r.Payload) 162 | continue // no response 163 | } 164 | r.Reply(ok, nil) 165 | } 166 | }() 167 | s.newPlayers <- p 168 | } 169 | 170 | // parseDims extracts two uint32s from the provided buffer. 171 | func parseDims(b []byte) resize { 172 | if len(b) < 8 { 173 | return resize{ 174 | width: 0, 175 | height: 0, 176 | } 177 | } 178 | w := binary.BigEndian.Uint32(b) 179 | h := binary.BigEndian.Uint32(b[4:]) 180 | return resize{ 181 | width: w, 182 | height: h, 183 | } 184 | } 185 | 186 | func fingerprintKey(k ssh.PublicKey) string { 187 | bytes := md5.Sum(k.Marshal()) 188 | strbytes := make([]string, len(bytes)) 189 | for i, b := range bytes { 190 | strbytes[i] = fmt.Sprintf("%02x", b) 191 | } 192 | return strings.Join(strbytes, ":") 193 | } 194 | --------------------------------------------------------------------------------