├── .gitignore ├── Godeps ├── LICENSE ├── README.md ├── config.go ├── config_test.go ├── grohl.go ├── haystack.go ├── opstocat.go ├── script ├── bootstrap ├── cibuild ├── fmt ├── gpm └── test ├── statsd_signed_writer.go └── statsd_signed_writer_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | .gopack 2 | .vendor -------------------------------------------------------------------------------- /Godeps: -------------------------------------------------------------------------------- 1 | github.com/peterbourgon/g2s 44d08dabf4b5a94ed9c24e47e7f4badcf0d3b034 2 | github.com/technoweenie/grohl efc223565d8a0af2bc5f361267dd9a846f50dd13 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 GitHub, Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 17 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 18 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 19 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 20 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Opstocat 2 | 3 | Collection of Ops related patterns for Go apps at GitHub. 4 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package opstocat 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "os" 8 | "os/exec" 9 | "path/filepath" 10 | "reflect" 11 | "strings" 12 | ) 13 | 14 | var environments = []string{"test", "development", "staging", "production", "enterprise"} 15 | 16 | type Configuration struct { 17 | App string 18 | Env string `json:"APP_ENV"` 19 | AppConfigPath string `json:"APP_CONFIG_PATH"` 20 | PidPath string `json:"APP_PIDPATH"` 21 | LogFile string `json:"APP_LOG_FILE"` 22 | StatsDAddress string `json:"STATSD"` 23 | ForceStats string `json:"FORCE_STATS"` 24 | HaystackUser string `json:"FAILBOT_USERNAME"` 25 | HaystackPassword string `json:"FAILBOT_PASSWORD"` 26 | HaystackEndpoint string `json:"FAILBOT_URL"` 27 | SyslogAddr string `json:"SYSLOG_ADDR"` 28 | Sha string 29 | Hostname string 30 | } 31 | 32 | type ConfigWrapper interface { 33 | OpstocatConfiguration() *Configuration 34 | SetupLogger() 35 | } 36 | 37 | func NewConfiguration(workingdir string) *Configuration { 38 | return &Configuration{ 39 | AppConfigPath: filepath.Join(workingdir, ".app-config"), 40 | Sha: currentSha(workingdir), 41 | PidPath: filepath.Join(workingdir, "tmp", "pids"), 42 | Hostname: simpleExec("hostname", "-s"), 43 | } 44 | } 45 | 46 | func (c *Configuration) ShowPeriodicStats() bool { 47 | return (len(c.StatsDAddress) > 0 && c.StatsDAddress != "noop") || c.ForceStats == "1" 48 | } 49 | 50 | // Fill out the app config from values in the environment. The env keys are 51 | // the same as the json marshal keys in the config struct. 52 | func ReadEnv(config ConfigWrapper) { 53 | innerconfig := config.OpstocatConfiguration() 54 | 55 | readEnvConfig(config) 56 | readEnvConfig(innerconfig) 57 | 58 | if innerconfig.Env == "" { 59 | ReadAppConfig(config, innerconfig.AppConfigPath) 60 | } else { 61 | ReadEnvConfig(config, innerconfig.AppConfigPath, innerconfig.Env) 62 | } 63 | } 64 | 65 | // Decode the environment's JSON file inside the appconfig path into the given 66 | // config struct. 67 | func ReadEnvConfig(config ConfigWrapper, appconfigPath, env string) error { 68 | return readAppConfig(config, filepath.Join(appconfigPath, env+".json")) 69 | } 70 | 71 | // Look for an available appconfig environment json file. 72 | func ReadAppConfig(config ConfigWrapper, appconfigPath string) { 73 | var err error 74 | for _, env := range environments { 75 | err = ReadEnvConfig(config, appconfigPath, env) 76 | if err == nil { 77 | return 78 | } 79 | } 80 | } 81 | 82 | func readEnvConfig(config interface{}) { 83 | val := reflect.ValueOf(config).Elem() 84 | t := val.Type() 85 | fieldSize := val.NumField() 86 | for i := 0; i < fieldSize; i++ { 87 | valueField := val.Field(i) 88 | typeField := t.Field(i) 89 | envKey := typeField.Tag.Get("json") 90 | 91 | if envValue := os.Getenv(envKey); 0 < len(envValue) { 92 | valueField.SetString(envValue) 93 | } 94 | } 95 | } 96 | 97 | func readAppConfig(config ConfigWrapper, appconfig string) error { 98 | file, err := os.Open(appconfig) 99 | if err != nil { 100 | return err 101 | } 102 | 103 | defer file.Close() 104 | err = readAppConfigs(file, []interface{}{config, config.OpstocatConfiguration()}) 105 | if err != nil { 106 | fmt.Fprintf(os.Stderr, "Error reading app config %s\n", appconfig) 107 | fmt.Fprintln(os.Stderr, err.Error()) 108 | os.Exit(1) 109 | } 110 | 111 | return err 112 | } 113 | 114 | func readAppConfigs(file *os.File, configs []interface{}) error { 115 | var err error 116 | dec := json.NewDecoder(file) 117 | for _, config := range configs { 118 | file.Seek(0, 0) 119 | err = dec.Decode(config) 120 | if err != nil { 121 | return err 122 | } 123 | } 124 | return nil 125 | } 126 | 127 | func simpleExec(name string, arg ...string) string { 128 | output, err := exec.Command(name, arg...).Output() 129 | if err != nil { 130 | panic(err) 131 | } 132 | 133 | return trim(output) 134 | } 135 | 136 | func currentSha(wd string) (sha string) { 137 | sha = os.Getenv("GIT_SHA") 138 | if len(sha) != 0 { 139 | return 140 | } 141 | 142 | output, err := ioutil.ReadFile(filepath.Join(wd, "SHA1")) 143 | 144 | if err != nil { 145 | sha = simpleExec("git", "rev-parse", "HEAD") 146 | } else { 147 | sha = trim(output) 148 | } 149 | 150 | return 151 | } 152 | 153 | func trim(output []byte) string { 154 | return strings.Trim(string(output), " \n") 155 | } 156 | -------------------------------------------------------------------------------- /config_test.go: -------------------------------------------------------------------------------- 1 | package opstocat 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "path" 7 | "testing" 8 | ) 9 | 10 | func TestCurrentShaFromFile(t *testing.T) { 11 | dir, err := ioutil.TempDir("", "opstocat-test-") 12 | if err != nil { 13 | t.Fatal("Expected to be able to create a temporal directory") 14 | } 15 | sha := "4628f8f626af95168d7139dde4c6e503bd0acf53" 16 | ioutil.WriteFile(path.Join(dir, "SHA1"), []byte(sha), 0755) 17 | 18 | newSha := currentSha(dir) 19 | if newSha != sha { 20 | t.Errorf("Expected current sha to be %s, but it was %s", sha, newSha) 21 | } 22 | } 23 | 24 | func TestCurrentShaFromEnv(t *testing.T) { 25 | defer os.Setenv("GIT_SHA", "") 26 | os.Setenv("GIT_SHA", "foobar") 27 | 28 | newSha := currentSha("") 29 | if newSha != "foobar" { 30 | t.Errorf("Expected current sha to be foobar, but it was %s", newSha) 31 | } 32 | } 33 | 34 | func TestCurrentShaFromGit(t *testing.T) { 35 | sha := simpleExec("git", "rev-parse", "HEAD") 36 | 37 | newSha := currentSha("") 38 | if newSha != sha { 39 | t.Errorf("Expected current sha to be %s, but it was %s", sha, newSha) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /grohl.go: -------------------------------------------------------------------------------- 1 | package opstocat 2 | 3 | import ( 4 | "fmt" 5 | "github.com/peterbourgon/g2s" 6 | "github.com/technoweenie/grohl" 7 | "log/syslog" 8 | "net/url" 9 | "os" 10 | "runtime" 11 | "time" 12 | ) 13 | 14 | func SetupLogger(config ConfigWrapper) { 15 | innerconfig := config.OpstocatConfiguration() 16 | logch := make(chan grohl.Data, 100) 17 | chlogger, _ := grohl.NewChannelLogger(logch) 18 | grohl.SetLogger(chlogger) 19 | 20 | if len(innerconfig.StatsDAddress) > 0 { 21 | if innerconfig.StatsDAddress == "noop" { 22 | grohl.CurrentStatter = &NoOpStatter{} 23 | } else { 24 | statter, err := g2s.Dial("udp", innerconfig.StatsDAddress) 25 | if err != nil { 26 | grohl.Report(err, grohl.Data{"statsd_address": innerconfig.StatsDAddress}) 27 | grohl.CurrentStatter = &NoOpStatter{} 28 | } else { 29 | grohl.CurrentStatter = statter 30 | } 31 | } 32 | } 33 | 34 | grohl.CurrentStatter = PrefixedStatter(innerconfig.App, grohl.CurrentStatter) 35 | 36 | if len(innerconfig.HaystackEndpoint) > 0 { 37 | reporter, err := NewHaystackReporter(innerconfig) 38 | if err != nil { 39 | grohl.Report(err, grohl.Data{"haystack_enpdoint": innerconfig.HaystackEndpoint}) 40 | } else { 41 | grohl.SetErrorReporter(reporter) 42 | } 43 | } 44 | 45 | grohl.AddContext("app", innerconfig.App) 46 | grohl.AddContext("deploy", innerconfig.Env) 47 | grohl.AddContext("sha", innerconfig.Sha) 48 | 49 | var logger grohl.Logger 50 | if len(innerconfig.SyslogAddr) > 0 { 51 | writer, err := newSyslogWriter(innerconfig.SyslogAddr, innerconfig.App) 52 | if err != nil { 53 | grohl.Report(err, grohl.Data{"syslog": innerconfig.SyslogAddr}) 54 | } else { 55 | logger = grohl.NewIoLogger(writer) 56 | } 57 | } else if len(innerconfig.LogFile) > 0 { 58 | file, err := os.OpenFile(innerconfig.LogFile, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0755) 59 | if err != nil { 60 | grohl.Report(err, grohl.Data{"log_file": innerconfig.LogFile}) 61 | } else { 62 | logger = grohl.NewIoLogger(file) 63 | } 64 | } 65 | 66 | if logger == nil { 67 | logger = grohl.NewIoLogger(nil) 68 | } 69 | 70 | go grohl.Watch(logger, logch) 71 | } 72 | 73 | func newSyslogWriter(configAddr, tag string) (*syslog.Writer, error) { 74 | net, addr, err := parseAddr(configAddr) 75 | if err != nil { 76 | return nil, err 77 | } 78 | writer, err := syslog.Dial(net, addr, syslog.LOG_INFO|syslog.LOG_LOCAL7, tag) 79 | if err != nil { 80 | grohl.Report(err, grohl.Data{"syslog_network": net, "syslog_addr": addr}) 81 | fmt.Fprintf(os.Stderr, "Error opening syslog connection: %s\n", err) 82 | } 83 | return writer, err 84 | } 85 | 86 | func parseAddr(s string) (string, string, error) { 87 | u, err := url.Parse(s) 88 | if err != nil { 89 | return "", "", err 90 | } 91 | 92 | if u.Host == "" { 93 | return u.Scheme, u.Path, nil 94 | } 95 | return u.Scheme, u.Host, nil 96 | } 97 | 98 | func SendPeriodicStats(duration string, config ConfigWrapper, callback func(keyprefix string)) error { 99 | innerconfig := config.OpstocatConfiguration() 100 | if !innerconfig.ShowPeriodicStats() { 101 | return nil 102 | } 103 | 104 | dur, err := time.ParseDuration(duration) 105 | if err != nil { 106 | return err 107 | } 108 | 109 | keyprefix := fmt.Sprintf("sys.%s.", innerconfig.Hostname) 110 | if callback == nil { 111 | callback = nopPeriodicCallback 112 | } 113 | 114 | go sendPeriodicStats(dur, keyprefix, callback) 115 | return nil 116 | } 117 | 118 | func sendPeriodicStats(dur time.Duration, keyprefix string, callback func(keyprefix string)) { 119 | var memStats runtime.MemStats 120 | var lastGCCount uint32 121 | 122 | for { 123 | time.Sleep(dur) 124 | grohl.Gauge(1.0, keyprefix+"goroutines", grohl.Format(runtime.NumGoroutine())) 125 | 126 | runtime.ReadMemStats(&memStats) 127 | grohl.Gauge(1.0, keyprefix+"memory.alloc", grohl.Format(memStats.Alloc)) 128 | grohl.Gauge(1.0, keyprefix+"memory.heap", grohl.Format(memStats.HeapAlloc)) 129 | grohl.Gauge(1.0, keyprefix+"memory.heap_in_use", grohl.Format(memStats.HeapInuse)) 130 | grohl.Gauge(1.0, keyprefix+"memory.heap_idle", grohl.Format(memStats.HeapIdle)) 131 | grohl.Gauge(1.0, keyprefix+"memory.heap_released", grohl.Format(memStats.HeapReleased)) 132 | grohl.Gauge(1.0, keyprefix+"memory.heap_objects", grohl.Format(memStats.HeapObjects)) 133 | grohl.Gauge(1.0, keyprefix+"memory.stack", grohl.Format(memStats.StackInuse)) 134 | grohl.Gauge(1.0, keyprefix+"memory.sys", grohl.Format(memStats.Sys)) 135 | 136 | // Number of GCs since the last sample 137 | countGC := memStats.NumGC - lastGCCount 138 | grohl.Gauge(1.0, keyprefix+"memory.gc", grohl.Format(countGC)) 139 | 140 | if countGC > 0 { 141 | if countGC > 256 { 142 | countGC = 256 143 | } 144 | 145 | for i := uint32(0); i < countGC; i++ { 146 | idx := ((memStats.NumGC - i) + 255) % 256 147 | pause := time.Duration(memStats.PauseNs[idx]) 148 | grohl.Timing(1.0, keyprefix+"memory.gc_pause", pause) 149 | } 150 | } 151 | 152 | lastGCCount = memStats.NumGC 153 | 154 | callback(keyprefix) 155 | } 156 | } 157 | 158 | func nopPeriodicCallback(keyprefix string) {} 159 | 160 | func PrefixedStatter(prefix string, statter g2s.Statter) g2s.Statter { 161 | if prefix == "" { 162 | return statter 163 | } 164 | 165 | return &PrefixStatter{prefix, statter} 166 | } 167 | 168 | type PrefixStatter struct { 169 | Prefix string 170 | Statter g2s.Statter 171 | } 172 | 173 | func (s *PrefixStatter) Counter(sampleRate float32, bucket string, n ...int) { 174 | s.Statter.Counter(sampleRate, fmt.Sprintf("%s.%s", s.Prefix, bucket), n...) 175 | } 176 | 177 | func (s *PrefixStatter) Timing(sampleRate float32, bucket string, d ...time.Duration) { 178 | s.Statter.Timing(sampleRate, fmt.Sprintf("%s.%s", s.Prefix, bucket), d...) 179 | } 180 | 181 | func (s *PrefixStatter) Gauge(sampleRate float32, bucket string, value ...string) { 182 | s.Statter.Gauge(sampleRate, fmt.Sprintf("%s.%s", s.Prefix, bucket), value...) 183 | } 184 | 185 | type NoOpStatter struct{} 186 | 187 | func (s *NoOpStatter) Counter(sampleRate float32, bucket string, n ...int) {} 188 | func (s *NoOpStatter) Timing(sampleRate float32, bucket string, d ...time.Duration) {} 189 | func (s *NoOpStatter) Gauge(sampleRate float32, bucket string, value ...string) {} 190 | -------------------------------------------------------------------------------- /haystack.go: -------------------------------------------------------------------------------- 1 | package opstocat 2 | 3 | import ( 4 | "bytes" 5 | "crypto/md5" 6 | "encoding/json" 7 | "fmt" 8 | "github.com/technoweenie/grohl" 9 | "io" 10 | "net/http" 11 | "net/url" 12 | "strings" 13 | ) 14 | 15 | type HaystackReporter struct { 16 | Endpoint string 17 | Hostname string 18 | } 19 | 20 | func NewHaystackReporter(config *Configuration) (*HaystackReporter, error) { 21 | endpoint, err := url.Parse(config.HaystackEndpoint) 22 | if err != nil { 23 | return nil, err 24 | } 25 | 26 | endpoint.User = url.UserPassword(config.HaystackUser, config.HaystackPassword) 27 | return &HaystackReporter{Endpoint: endpoint.String(), Hostname: config.Hostname}, nil 28 | } 29 | 30 | func (r *HaystackReporter) Report(err error, data grohl.Data) error { 31 | backtrace := grohl.ErrorBacktraceLines(err) 32 | data["backtrace"] = strings.Join(backtrace, "\n") 33 | data["host"] = r.Hostname 34 | data["rollup"] = r.rollup(data, backtrace[0]) 35 | 36 | marshal, _ := json.Marshal(data) 37 | res, reporterr := http.Post(r.Endpoint, "application/json", bytes.NewBuffer(marshal)) 38 | if res != nil { 39 | defer res.Body.Close() 40 | } 41 | 42 | if reporterr != nil || res.StatusCode != 201 { 43 | delete(data, "backtrace") 44 | delete(data, "host") 45 | if res != nil { 46 | data["haystackstatus"] = res.Status 47 | } 48 | grohl.Log(data) 49 | return reporterr 50 | } 51 | 52 | return nil 53 | } 54 | 55 | func (r *HaystackReporter) rollup(data grohl.Data, firstline string) string { 56 | hash := md5.New() 57 | io.WriteString(hash, fmt.Sprintf("%s:%s:%s", data["ns"], data["fn"], firstline)) 58 | return fmt.Sprintf("%x", hash.Sum(nil)) 59 | } 60 | -------------------------------------------------------------------------------- /opstocat.go: -------------------------------------------------------------------------------- 1 | package opstocat 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "strconv" 8 | ) 9 | 10 | func WritePid(config ConfigWrapper) { 11 | innerconfig := config.OpstocatConfiguration() 12 | pidfile := filepath.Join(innerconfig.PidPath, fmt.Sprintf("%s.pid", innerconfig.App)) 13 | 14 | err := os.MkdirAll(innerconfig.PidPath, 0750) 15 | if err != nil { 16 | panic(err) 17 | } 18 | 19 | file, err := os.Create(pidfile) 20 | defer file.Close() 21 | 22 | if err != nil { 23 | panic(err) 24 | } 25 | 26 | pid := os.Getpid() 27 | file.WriteString(strconv.FormatInt(int64(pid), 10)) 28 | } 29 | -------------------------------------------------------------------------------- /script/bootstrap: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | GOPATH="`pwd`/.vendor" script/gpm install 3 | -------------------------------------------------------------------------------- /script/cibuild: -------------------------------------------------------------------------------- 1 | script/bootstrap 2 | script/fmt 3 | GOPATH="`pwd`/.vendor" go test ./... 4 | -------------------------------------------------------------------------------- /script/fmt: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | gofmt -w -l *.go 4 | -------------------------------------------------------------------------------- /script/gpm: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eu 4 | 5 | ## Functions/ 6 | usage() { 7 | cat << EOF 8 | SYNOPSIS 9 | 10 | gpm leverages the power of the go get command and the underlying version 11 | control systems used by it to set your Go dependencies to desired versions, 12 | thus allowing easily reproducible builds in your Go projects. 13 | 14 | A Godeps file in the root of your Go application is expected containing 15 | the import paths of your packages and a specific tag or commit hash 16 | from its version control system, an example Godeps file looks like this: 17 | 18 | $ cat Godeps 19 | # This is a comment 20 | github.com/nu7hatch/gotrail v0.0.2 21 | github.com/replicon/fast-archiver v1.02 #This is another comment! 22 | github.com/nu7hatch/gotrail 2eb79d1f03ab24bacbc32b15b75769880629a865 23 | 24 | gpm has a companion tool, called [gvp](https://github.com/pote/gvp) which 25 | provides vendoring functionalities, it alters your GOPATH so every project 26 | has its own isolated dependency directory, it's usage is recommended. 27 | 28 | USAGE 29 | $ gpm # Same as 'install'. 30 | $ gpm install # Parses the Godeps file, installs dependencies and sets 31 | # them to the appropriate version. 32 | $ gpm version # Outputs version information 33 | $ gpm help # Prints this message 34 | EOF 35 | } 36 | 37 | # Iterates over Godep file dependencies and sets 38 | # the specified version on each of them. 39 | set_dependencies() { 40 | deps=$(sed 's/#.*//;/^\s*$/d' < $1) || echo "" 41 | 42 | while read package version; do 43 | ( 44 | install_path="${GOPATH%%:*}/src/${package%%/...}" 45 | [[ -e "$install_path/.git/index.lock" || 46 | -e "$install_path/.hg/store/lock" || 47 | -e "$install_path/.bzr/checkout/lock" ]] && wait 48 | echo ">> Getting package "$package"" 49 | go get -u -d "$package" 50 | echo ">> Setting $package to version $version" 51 | cd $install_path 52 | [ -d .hg ] && hg update -q "$version" 53 | [ -d .git ] && git checkout -q "$version" 54 | [ -d .bzr ] && bzr revert -q -r "$version" 55 | [ -d .svn ] && svn update -r "$version" 56 | ) & 57 | done < <(echo "$deps") 58 | wait 59 | echo ">> All Done" 60 | } 61 | 62 | ## /Functions 63 | case "${1:-"install"}" in 64 | "version") 65 | echo ">> gpm v1.2.0" 66 | ;; 67 | "install") 68 | deps_file="Godeps" 69 | [[ "$#" -eq 2 ]] && [[ -n "$2" ]] && deps_file="$2" 70 | [[ -f "$deps_file" ]] || (echo ">> $deps_file file does not exist." && exit 1) 71 | (which go > /dev/null) || 72 | ( echo ">> Go is currently not installed or in your PATH" && exit 1) 73 | set_dependencies $deps_file 74 | ;; 75 | "help") 76 | usage 77 | ;; 78 | *) 79 | ## Support for Plugins: if command is unknown search for a gpm-command executable. 80 | if command -v "gpm-$1" > /dev/null 81 | then 82 | plugin=$1 && 83 | shift && 84 | gpm-$plugin $@ && 85 | exit 86 | else 87 | usage && exit 1 88 | fi 89 | ;; 90 | esac 91 | -------------------------------------------------------------------------------- /script/test: -------------------------------------------------------------------------------- 1 | script/bootstrap 2 | script/fmt 3 | GOPATH="`pwd`/.vendor" go test ./... 4 | -------------------------------------------------------------------------------- /statsd_signed_writer.go: -------------------------------------------------------------------------------- 1 | package opstocat 2 | 3 | import ( 4 | "bytes" 5 | "crypto/hmac" 6 | "crypto/rand" 7 | "crypto/sha256" 8 | "encoding/binary" 9 | "io" 10 | "time" 11 | ) 12 | 13 | // `StatsdSignedWriter` wraps an `io.Writer`, prefixing writes with an HMAC, 14 | // nonce and timestamp as described in 15 | // . 16 | // 17 | // As an example: 18 | // conn, err := net.DialTimeout("udp", "endpoint.example.com:8126", 2*time.Second) 19 | // if err != nil { 20 | // // handle err 21 | // } 22 | // 23 | // writer := &opstocat.StatsdSignedWriter{Writer: conn, Key: []byte("supersecret")} 24 | // statter := g2s.New(writer) 25 | // statter.Counter(1.0, "foo", 1) // :banana: out 26 | type StatsdSignedWriter struct { 27 | io.Writer 28 | Key []byte 29 | } 30 | 31 | func (s *StatsdSignedWriter) Write(p []byte) (int, error) { 32 | payload, err := s.signedPayload(p) 33 | if err != nil { 34 | return 0, err 35 | } 36 | 37 | _, err = s.Writer.Write(payload) 38 | if err != nil { 39 | return 0, err 40 | } else { 41 | return len(p), err 42 | } 43 | } 44 | 45 | func (s *StatsdSignedWriter) signedPayload(p []byte) ([]byte, error) { 46 | payload := new(bytes.Buffer) 47 | 48 | ts := time.Now() 49 | binary.Write(payload, binary.LittleEndian, ts.Unix()) 50 | 51 | randomBytes := make([]byte, 4) 52 | if _, err := rand.Read(randomBytes); err != nil { 53 | return nil, err 54 | } 55 | payload.Write(randomBytes) 56 | payload.Write(p) 57 | 58 | payloadBytes := payload.Bytes() 59 | mac := hmac.New(sha256.New, s.Key) 60 | mac.Write(payloadBytes) 61 | 62 | fullMessage := mac.Sum(nil) 63 | fullMessage = append(fullMessage, payloadBytes...) 64 | return fullMessage, nil 65 | } 66 | -------------------------------------------------------------------------------- /statsd_signed_writer_test.go: -------------------------------------------------------------------------------- 1 | package opstocat 2 | 3 | import ( 4 | "bytes" 5 | "crypto/hmac" 6 | "crypto/sha256" 7 | "testing" 8 | ) 9 | 10 | func TestSignedWriterSignsPayloads(t *testing.T) { 11 | var buf bytes.Buffer 12 | signedBuf := &StatsdSignedWriter{Writer: &buf, Key: []byte("secret")} 13 | 14 | n, err := signedBuf.Write([]byte("abc")) 15 | if n != 3 { 16 | t.Fatalf("Expected 3 bytes to be written, but was %v", n) 17 | } else if err != nil { 18 | t.Fatal(err) 19 | } 20 | 21 | signedBytes := buf.Bytes() 22 | if len(signedBytes) != 32+8+4+3 { 23 | // signature (32) + timestamp(8) + nonce(4) + message(3) 24 | t.Fatalf("Expected 47 bytes to be written to the underlying, but %v were written", len(signedBytes)) 25 | } 26 | hmacBytes := signedBytes[0:32] 27 | payload := signedBytes[32:] 28 | 29 | mac := hmac.New(sha256.New, signedBuf.Key) 30 | mac.Write(payload) 31 | if bytes.Compare(mac.Sum(nil), hmacBytes) != 0 { 32 | t.Fatalf("HMAC did not match up") 33 | } 34 | } 35 | --------------------------------------------------------------------------------