├── LICENSE ├── README.md ├── buffer └── buffer.go ├── example.config.json ├── input ├── input.go └── lumberjack │ └── lumberjack.go ├── main.go ├── output ├── elasticsearch │ └── elasticsearch.go ├── output.go ├── tcp │ └── tcp.go └── websocket │ ├── template.go │ └── websocket.go ├── parser └── parser.go └── server ├── config.go └── server.go /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Hailo 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Logslam - A lumberjack => logstash indexer in Go 2 | 3 | Logslam is a lightweight lumberjack compliant log indexer. It accepts the lumberjack v1 protocol and indexes logs in elasticsearch. It was written with the intention of being a small and efficient replacement for logstash on AWS EC2. It does not attempt to replicate all of logstash's features, the goal was to simply replace it for Hailo's specific use case in the ELK stack. 4 | 5 | ## Supported IO 6 | 7 | ### Inputs 8 | 9 | - Lumberjack V1 Protocol 10 | 11 | ### Outputs 12 | 13 | - TCP Streaming 14 | - WebSocket Streaming 15 | - Elasticsearch 16 | 17 | ## Getting Started 18 | 19 | ### 1. Create config 20 | 21 | Create a config file specifying location of ssl crt/key, bind addresses for inputs/outputs and elasticsearch hosts. 22 | 23 | An example config can be found in example.config.json: 24 | ``` 25 | { 26 | "inputs": { 27 | "lumberjack": { 28 | "host": ":7200", 29 | "ssl_key": "lumberjack.key", 30 | "ssl_crt": "lumberjack.crt" 31 | } 32 | }, 33 | "outputs": { 34 | "tcp": { 35 | "host": ":7201" 36 | }, 37 | "websocket": { 38 | "host": ":7202" 39 | }, 40 | "elasticsearch": { 41 | "hosts": [ 42 | "localhost:9200" 43 | ] 44 | } 45 | } 46 | } 47 | ``` 48 | 49 | ### 2. Run the server 50 | 51 | ``` 52 | $ go get 53 | $ $GOPATH/bin/logslam -config=example.config.json 54 | 2015/01/20 20:59:03 Starting server 55 | 2015/01/20 20:59:03 Starting buffer 56 | 2015/01/20 20:59:03 Starting input lumberjack 57 | 2015/01/20 20:59:03 Starting output tcp 58 | 2015/01/20 20:59:03 Starting output websocket 59 | 2015/01/20 20:59:03 Starting output elasticsearch 60 | ``` 61 | 62 | ### Streaming logs via TCP 63 | 64 | ``` 65 | nc localhost 7201 66 | ``` 67 | 68 | ### Streaming logs via WebSocket 69 | 70 | ``` 71 | Connect to http://localhost:7202 in a browser. 72 | A list of known sources will be displayed. 73 | ``` 74 | -------------------------------------------------------------------------------- /buffer/buffer.go: -------------------------------------------------------------------------------- 1 | package buffer 2 | 3 | import ( 4 | "log" 5 | "time" 6 | ) 7 | 8 | const ( 9 | bufSize = 100 10 | ) 11 | 12 | type Sender interface { 13 | AddSubscriber(string, chan *Event) error 14 | DelSubscriber(string) error 15 | } 16 | 17 | // Taken from https://github.com/elasticsearch/logstash-forwarder/blob/master/event.go 18 | type Event struct { 19 | Source string `json:"source,omitempty"` 20 | Offset int64 `json:"offset,omitempty"` 21 | Line uint64 `json:"line,omitempty"` 22 | Text *string `json:"text,omitempty"` 23 | Fields *map[string]string 24 | } 25 | 26 | // subscriber is some host that wants to receive events 27 | type subscriber struct { 28 | Host string 29 | Send chan *Event 30 | } 31 | 32 | type Buffer struct { 33 | send chan *Event 34 | subscribers map[string]*subscriber 35 | add chan *subscriber 36 | del chan string 37 | term chan bool 38 | ticker *time.Ticker 39 | } 40 | 41 | func New() *Buffer { 42 | return &Buffer{ 43 | ticker: time.NewTicker(time.Duration(10) * time.Millisecond), 44 | send: make(chan *Event, bufSize), 45 | subscribers: make(map[string]*subscriber), 46 | add: make(chan *subscriber, 1), 47 | del: make(chan string, 1), 48 | term: make(chan bool, 1), 49 | } 50 | } 51 | 52 | func (b *Buffer) AddSubscriber(host string, ch chan *Event) error { 53 | b.add <- &subscriber{host, ch} 54 | return nil 55 | } 56 | 57 | func (b *Buffer) DelSubscriber(host string) error { 58 | b.del <- host 59 | return nil 60 | } 61 | 62 | func (b *Buffer) Publish(event *Event) { 63 | for _, sub := range b.subscribers { 64 | select { 65 | case sub.Send <- event: 66 | case <-b.ticker.C: 67 | } 68 | } 69 | } 70 | 71 | func (b *Buffer) Send(event *Event) { 72 | b.send <- event 73 | } 74 | 75 | func (b *Buffer) Start() { 76 | for { 77 | select { 78 | case e := <-b.send: 79 | b.Publish(e) 80 | case s := <-b.add: 81 | if _, ok := b.subscribers[s.Host]; ok { 82 | log.Printf("A subscriber is already registered for %s\n", s.Host) 83 | continue 84 | } 85 | b.subscribers[s.Host] = s 86 | case h := <-b.del: 87 | delete(b.subscribers, h) 88 | case <-b.term: 89 | log.Println("Received on term chan") 90 | break 91 | } 92 | } 93 | } 94 | func (b *Buffer) Stop() { 95 | b.term <- true 96 | } 97 | -------------------------------------------------------------------------------- /example.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "inputs": { 3 | "lumberjack": { 4 | "host": ":7200", 5 | "ssl_key": "lumberjack.key", 6 | "ssl_crt": "lumberjack.crt" 7 | } 8 | }, 9 | "outputs": { 10 | "tcp": { 11 | "host": ":7201" 12 | }, 13 | "websocket": { 14 | "host": ":7202" 15 | }, 16 | "elasticsearch": { 17 | "hosts": [ 18 | "localhost:9200" 19 | ] 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /input/input.go: -------------------------------------------------------------------------------- 1 | package input 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/hailocab/logslam/parser" 8 | ) 9 | 10 | type Input interface { 11 | Init(json.RawMessage, parser.Receiver) error 12 | Start() error 13 | Stop() error 14 | } 15 | 16 | var ( 17 | inputs = make(map[string]Input) 18 | ) 19 | 20 | func Register(name string, input Input) error { 21 | if _, ok := inputs[name]; ok { 22 | return fmt.Errorf("Input %s already exists", name) 23 | } 24 | inputs[name] = input 25 | return nil 26 | } 27 | 28 | func Load(name string) (Input, error) { 29 | input, ok := inputs[name] 30 | if !ok { 31 | return nil, fmt.Errorf("Input %s not found", name) 32 | } 33 | return input, nil 34 | } 35 | -------------------------------------------------------------------------------- /input/lumberjack/lumberjack.go: -------------------------------------------------------------------------------- 1 | package lumberjack 2 | 3 | import ( 4 | "crypto/tls" 5 | "encoding/json" 6 | "fmt" 7 | "log" 8 | "net" 9 | 10 | "github.com/hailocab/logslam/input" 11 | "github.com/hailocab/logslam/parser" 12 | ) 13 | 14 | type Config struct { 15 | Host string `json:"host"` 16 | SSLCrt string `json:"ssl_crt"` 17 | SSLKey string `json:"ssl_key"` 18 | } 19 | 20 | type LJServer struct { 21 | Config *Config 22 | r parser.Receiver 23 | term chan bool 24 | } 25 | 26 | func init() { 27 | input.Register("lumberjack", &LJServer{ 28 | term: make(chan bool, 1), 29 | }) 30 | } 31 | 32 | // lumberConn handles an incoming connection from a lumberjack client 33 | func lumberConn(c net.Conn, r parser.Receiver) { 34 | defer c.Close() 35 | log.Printf("[%s] accepting lumberjack connection", c.RemoteAddr().String()) 36 | parser.New(c, r).Parse() 37 | log.Printf("[%s] closing lumberjack connection", c.RemoteAddr().String()) 38 | } 39 | 40 | func (lj *LJServer) Init(config json.RawMessage, r parser.Receiver) error { 41 | var ljConfig *Config 42 | if err := json.Unmarshal(config, &ljConfig); err != nil { 43 | return fmt.Errorf("Error parsing lumberjack config: %v", err) 44 | } 45 | 46 | lj.Config = ljConfig 47 | lj.r = r 48 | 49 | return nil 50 | } 51 | 52 | func (lj *LJServer) Start() error { 53 | cert, err := tls.LoadX509KeyPair(lj.Config.SSLCrt, lj.Config.SSLKey) 54 | if err != nil { 55 | return fmt.Errorf("Error loading keys: %v", err) 56 | } 57 | 58 | conn, err := net.Listen("tcp", lj.Config.Host) 59 | if err != nil { 60 | return fmt.Errorf("Listener failed: %v", err) 61 | } 62 | 63 | config := tls.Config{Certificates: []tls.Certificate{cert}} 64 | 65 | ln := tls.NewListener(conn, &config) 66 | 67 | for { 68 | select { 69 | case <-lj.term: 70 | log.Println("Lumberjack server received term signal") 71 | return nil 72 | default: 73 | conn, err := ln.Accept() 74 | if err != nil { 75 | log.Printf("Error accepting connection: %v", err) 76 | continue 77 | } 78 | go lumberConn(conn, lj.r) 79 | } 80 | } 81 | 82 | return nil 83 | } 84 | 85 | func (lj *LJServer) Stop() error { 86 | lj.term <- true 87 | return nil 88 | } 89 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | 8 | _ "github.com/hailocab/logslam/input/lumberjack" 9 | _ "github.com/hailocab/logslam/output/elasticsearch" 10 | _ "github.com/hailocab/logslam/output/tcp" 11 | _ "github.com/hailocab/logslam/output/websocket" 12 | "github.com/hailocab/logslam/server" 13 | ) 14 | 15 | var ( 16 | config string 17 | ) 18 | 19 | func init() { 20 | flag.StringVar(&config, "config", "", "Path to the config file") 21 | flag.Parse() 22 | 23 | if len(config) == 0 { 24 | fmt.Fprintln(os.Stderr, "Require a config file") 25 | flag.PrintDefaults() 26 | os.Exit(1) 27 | } 28 | } 29 | 30 | func main() { 31 | srv, err := server.New(config) 32 | if err != nil { 33 | fmt.Fprintln(os.Stderr, err.Error()) 34 | os.Exit(1) 35 | } 36 | 37 | srv.Start() 38 | } 39 | -------------------------------------------------------------------------------- /output/elasticsearch/elasticsearch.go: -------------------------------------------------------------------------------- 1 | package elasticsearch 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "log" 9 | "strconv" 10 | "time" 11 | 12 | "github.com/hailocab/elastigo/api" 13 | "github.com/hailocab/logslam/buffer" 14 | "github.com/hailocab/logslam/output" 15 | ) 16 | 17 | const ( 18 | defaultHost = "127.0.0.1" 19 | defaultIndexPrefix = "logstash" 20 | esFlushInterval = 5 21 | esMaxConns = 20 22 | esRecvBuffer = 100 23 | esSendBuffer = 100 24 | ) 25 | 26 | type Indexer struct { 27 | events int 28 | buffer *bytes.Buffer 29 | } 30 | 31 | type Config struct { 32 | Hosts []string `json:"hosts"` 33 | } 34 | 35 | type ESServer struct { 36 | host string 37 | hosts []string 38 | b buffer.Sender 39 | term chan bool 40 | } 41 | 42 | func init() { 43 | output.Register("elasticsearch", &ESServer{ 44 | host: fmt.Sprintf("%s:%d", defaultHost, time.Now().Unix()), 45 | term: make(chan bool, 1), 46 | }) 47 | } 48 | 49 | func indexName(idx string) string { 50 | if len(idx) == 0 { 51 | idx = defaultIndexPrefix 52 | } 53 | 54 | return fmt.Sprintf("%s-%s", idx, time.Now().Format("2006.01.02")) 55 | } 56 | 57 | func bulkSend(buf *bytes.Buffer) error { 58 | _, err := api.DoCommand("POST", "/_bulk", buf) 59 | if err != nil { 60 | return err 61 | } 62 | 63 | return nil 64 | } 65 | 66 | func indexDoc(ev *buffer.Event) *map[string]interface{} { 67 | f := *ev.Fields 68 | host := f["host"] 69 | file := f["file"] 70 | timestamp := f["timestamp"] 71 | message := strconv.Quote(*ev.Text) 72 | 73 | delete(f, "timestamp") 74 | delete(f, "line") 75 | delete(f, "host") 76 | delete(f, "file") 77 | 78 | return &map[string]interface{}{ 79 | "@type": f["type"], 80 | "@message": &message, 81 | "@source_path": file, 82 | "@source_host": host, 83 | "@timestamp": timestamp, 84 | "@fields": &f, 85 | "@source": ev.Source, 86 | } 87 | } 88 | 89 | func (i *Indexer) writeBulk(index string, _type string, data interface{}) error { 90 | w := `{"index":{"_index":"%s","_type":"%s"}}` 91 | 92 | i.buffer.WriteString(fmt.Sprintf(w, index, _type)) 93 | i.buffer.WriteByte('\n') 94 | 95 | switch v := data.(type) { 96 | case *bytes.Buffer: 97 | io.Copy(i.buffer, v) 98 | case []byte: 99 | i.buffer.Write(v) 100 | case string: 101 | i.buffer.WriteString(v) 102 | default: 103 | body, err := json.Marshal(data) 104 | if err != nil { 105 | log.Printf("Error writing bulk data: %v", err) 106 | return err 107 | } 108 | i.buffer.Write(body) 109 | } 110 | i.buffer.WriteByte('\n') 111 | return nil 112 | } 113 | 114 | func (i *Indexer) flush() { 115 | if i.events == 0 { 116 | return 117 | } 118 | 119 | log.Printf("Flushing %d event(s) to elasticsearch", i.events) 120 | for j := 0; j < 3; j++ { 121 | if err := bulkSend(i.buffer); err != nil { 122 | log.Printf("Failed to index event (will retry): %v", err) 123 | time.Sleep(time.Duration(50) * time.Millisecond) 124 | continue 125 | } 126 | break 127 | } 128 | 129 | i.buffer.Reset() 130 | i.events = 0 131 | } 132 | 133 | func (i *Indexer) index(ev *buffer.Event) { 134 | doc := indexDoc(ev) 135 | idx := indexName("") 136 | typ := (*ev.Fields)["type"] 137 | 138 | i.events++ 139 | i.writeBulk(idx, typ, doc) 140 | 141 | if i.events < esSendBuffer { 142 | return 143 | } 144 | 145 | log.Printf("Flushing %d event(s) to elasticsearch", i.events) 146 | for j := 0; j < 3; j++ { 147 | if err := bulkSend(i.buffer); err != nil { 148 | log.Printf("Failed to index event (will retry): %v", err) 149 | time.Sleep(time.Duration(50) * time.Millisecond) 150 | continue 151 | } 152 | break 153 | } 154 | 155 | i.buffer.Reset() 156 | i.events = 0 157 | } 158 | 159 | func (e *ESServer) Init(config json.RawMessage, b buffer.Sender) error { 160 | var esConfig *Config 161 | if err := json.Unmarshal(config, &esConfig); err != nil { 162 | return fmt.Errorf("Error parsing elasticsearch config: %v", err) 163 | } 164 | 165 | e.hosts = esConfig.Hosts 166 | e.b = b 167 | 168 | return nil 169 | } 170 | 171 | func (es *ESServer) Start() error { 172 | api.SetHosts(es.hosts) 173 | 174 | // Add the client as a subscriber 175 | r := make(chan *buffer.Event, esRecvBuffer) 176 | es.b.AddSubscriber(es.host, r) 177 | defer es.b.DelSubscriber(es.host) 178 | 179 | // Create indexer 180 | idx := &Indexer{0, bytes.NewBuffer(nil)} 181 | 182 | // Loop events and publish to elasticsearch 183 | tick := time.NewTicker(time.Duration(esFlushInterval) * time.Second) 184 | 185 | for { 186 | select { 187 | case ev := <-r: 188 | idx.index(ev) 189 | case <-tick.C: 190 | idx.flush() 191 | case <-es.term: 192 | tick.Stop() 193 | log.Println("Elasticsearch received term signal") 194 | break 195 | } 196 | } 197 | 198 | return nil 199 | } 200 | 201 | func (es *ESServer) Stop() error { 202 | es.term <- true 203 | return nil 204 | } 205 | -------------------------------------------------------------------------------- /output/output.go: -------------------------------------------------------------------------------- 1 | package output 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/hailocab/logslam/buffer" 8 | ) 9 | 10 | type Output interface { 11 | Init(json.RawMessage, buffer.Sender) error 12 | Start() error 13 | Stop() error 14 | } 15 | 16 | var ( 17 | outputs = make(map[string]Output) 18 | ) 19 | 20 | func Register(name string, output Output) error { 21 | if _, ok := outputs[name]; ok { 22 | return fmt.Errorf("Output %s already exists", name) 23 | } 24 | outputs[name] = output 25 | return nil 26 | } 27 | 28 | func Load(name string) (Output, error) { 29 | output, ok := outputs[name] 30 | if !ok { 31 | return nil, fmt.Errorf("Output %s not found", name) 32 | } 33 | return output, nil 34 | } 35 | -------------------------------------------------------------------------------- /output/tcp/tcp.go: -------------------------------------------------------------------------------- 1 | package tcp 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "net" 8 | 9 | "github.com/hailocab/logslam/buffer" 10 | "github.com/hailocab/logslam/output" 11 | ) 12 | 13 | const ( 14 | recvBuffer = 100 15 | ) 16 | 17 | type Config struct { 18 | Host string `json:"host"` 19 | } 20 | 21 | type TCPServer struct { 22 | host string 23 | b buffer.Sender 24 | term chan bool 25 | } 26 | 27 | func init() { 28 | output.Register("tcp", &TCPServer{ 29 | term: make(chan bool, 1), 30 | }) 31 | } 32 | 33 | // lumberConn handles an incoming connection from a lumberjack client 34 | func (s *TCPServer) accept(c net.Conn) { 35 | defer func() { 36 | s.b.DelSubscriber(c.RemoteAddr().String()) 37 | log.Printf("[%s] closing tcp connection", c.RemoteAddr().String()) 38 | c.Close() 39 | }() 40 | 41 | log.Printf("[%s] accepting tcp connection", c.RemoteAddr().String()) 42 | 43 | // Add the client as a subscriber 44 | r := make(chan *buffer.Event, recvBuffer) 45 | s.b.AddSubscriber(c.RemoteAddr().String(), r) 46 | 47 | for { 48 | select { 49 | case ev := <-r: 50 | _, err := c.Write([]byte(fmt.Sprintf("%s %s\n", ev.Source, *ev.Text))) 51 | if err != nil { 52 | log.Printf("[%s] error sending event to tcp connection: %v", c.RemoteAddr().String(), err) 53 | return 54 | } 55 | } 56 | } 57 | 58 | } 59 | 60 | func (s *TCPServer) Init(config json.RawMessage, b buffer.Sender) error { 61 | var tcpConfig *Config 62 | if err := json.Unmarshal(config, &tcpConfig); err != nil { 63 | return fmt.Errorf("Error parsing tcp config: %v", err) 64 | } 65 | 66 | s.host = tcpConfig.Host 67 | s.b = b 68 | return nil 69 | } 70 | 71 | func (s *TCPServer) Start() error { 72 | ln, err := net.Listen("tcp", s.host) 73 | if err != nil { 74 | return fmt.Errorf("TCPServer: listener failed: %v", err) 75 | } 76 | 77 | for { 78 | select { 79 | case <-s.term: 80 | log.Println("TCPServer received term signal") 81 | return nil 82 | default: 83 | conn, err := ln.Accept() 84 | if err != nil { 85 | log.Println("Error accepting tcp connection: %v", err) 86 | continue 87 | } 88 | go s.accept(conn) 89 | } 90 | } 91 | 92 | return nil 93 | } 94 | 95 | func (s *TCPServer) Stop() error { 96 | s.term <- true 97 | return nil 98 | } 99 | -------------------------------------------------------------------------------- /output/websocket/template.go: -------------------------------------------------------------------------------- 1 | package websocket 2 | 3 | var index = ` 4 | 5 | 6 |
7 | 8 |