├── .gitignore ├── LICENSE ├── README.md ├── config.toml.sample ├── datastore.go ├── dns.go ├── fetch.sh ├── oauth.go └── planeboard.go /.gitignore: -------------------------------------------------------------------------------- 1 | config.toml 2 | tweets.db 3 | planeboard 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2016 Mark Percival 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PlaneBoard 2 | #### Read your tweets, even behind captive WiFi portals, using DNS TXT records 3 | 4 | ![aeropuerto](https://cloud.githubusercontent.com/assets/2868/15951028/737d6ea8-2e69-11e6-8eda-a9a82d57a0ee.png) 5 | 6 | ## Quick Demo 7 | 8 | From your command line, lets fire up 'dig' and try it out! 9 | 10 | `dig txt p0.t.news.pb.mdp.im` 11 | 12 | ## Why 13 | 14 | This project has no serious application, it's merely a fun experiment that I prototyped while waiting on a delayed flight. There are numerous methods to tunnel traffic through DNS queries, iodine being the most popular. This is just a simple demonstration of how it's possible to get up to date, human readable information from a DNS query. 15 | 16 | ## How 17 | 18 | Nearly all captive portals will still proxy outbound DNS requests. We can use this proxy of DNS requests to 'leak' information we might be interested in. In this case I'm returning my Twitter stream as TXT DNS records. 19 | 20 | ## Installation 21 | 22 | git clone https://github.com/mdp/planeboard 23 | cd planeboard 24 | go get -u 25 | go build 26 | cp config.toml.sample config.toml 27 | vi config.toml # Update with relevant information 28 | 29 | What you'll need to use this: 30 | 31 | 1. An NS record pointing at the host you're running this on 32 | 2. A [Twitter OAuth application](https://apps.twitter.com/) and it's consumer keys 33 | 3. A Twitter account to authenticate with the Twitter OAuth application 34 | 35 | #### NS record example 36 | 37 | pb IN NS server.myhost.com. 38 | server IN A 192.168.1.1 39 | 40 | #### Authentication with Twitter 41 | 42 | Once you got config.toml setup with your Twitter keys, just run `./planeboard auth` and follow the instructions. You'll get back a set of access keys tied to the Twitter account you want to read from. 43 | 44 | #### Running planeboard 45 | 46 | sudo ./planeboard 47 | 48 | ## Usage 49 | 50 | Lets say I'm using pb.mdp.im as my host 51 | 52 | # The following are example 'dig' commands you would enter on your command line 53 | 54 | # Paginate with 'p' 55 | dig txt p0.pb.mdp.im 56 | # Gives me the first tweet 57 | dig txt p1.pb.mdp.im 58 | # Gives me the second tweet 59 | 60 | # Toss in a 'b'(before) with a unix timestamp to help with proper pagination 61 | dig txt b1465514642.p1.pb.mdp.im 62 | 63 | # Toss in a 'c'(cachebuster) to prevent caching 64 | dig txt c8y7tnpynb0.b1465514642.p0.pb.mdp.im 65 | 66 | # Finally, you can have topics setup in config.toml to help you filter 67 | # tweets into relevant groups. For example, lets say we have a 'news' topic 68 | # which consists of @cnn, @ap and @nytimes. We just need to add a 't' flag 69 | # and group name to the request 70 | dig txt c8y7tnpynb0.b1465514642.p0.t.news.pb.mdp.im 71 | 72 | ### Using the bash script 'fetch.sh' 73 | 74 | This is all automated in [a bash script](https://github.com/mdp/PlaneBoard/blob/master/fetch.sh) to help with fetching a large number of tweets in a timeline 75 | 76 | ./fetch.sh -t -n 20 -h pb.mdp.im news 77 | # grabs 20 most recent tweets from the news topic 78 | 79 | 80 | ## The nitty gritty details 81 | 82 | How it works in a nutshell: 83 | 84 | - All built in Go 85 | - Tweets are gathered by consuming the Twitter streaming API 86 | - Tweets are stored in a BoltDB database 87 | - Inbound queries are parsed and relevant tweets are returned from the database 88 | 89 | ## License 90 | 91 | MIT of course. Do with it as you please. 92 | -------------------------------------------------------------------------------- /config.toml.sample: -------------------------------------------------------------------------------- 1 | ConsumerKey = "Get this from Twitter" 2 | ConsumerSecret = "Get this from Twitter" 3 | TokenKey = "run planebord auth" 4 | TokenSecret = "run planeboard auth" 5 | Host = "foo.com" // Update to your NS host 6 | Port = 53 7 | DBFile = "tweets.db" 8 | 9 | [Groups.News] 10 | Accounts = ["cnn", "ap", "wsj"] 11 | [Groups.Sports] 12 | Account = ["espn"] 13 | -------------------------------------------------------------------------------- /datastore.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "strconv" 8 | "strings" 9 | "time" 10 | 11 | "github.com/boltdb/bolt" 12 | "github.com/dghubble/go-twitter/twitter" 13 | ) 14 | 15 | // TwitterTimeLayout is the format Twitter returns for CreatedAt 16 | var TwitterTimeLayout = "Mon Jan 02 15:04:05 -0700 2006" 17 | 18 | // Key lets us quickly iterater through tweets and get only 19 | // the ones we care about without unmarshalling json 20 | // createdAt is critical to deleting older tweets to 21 | // keep the database manageable 22 | type Key struct { 23 | ID int64 24 | ScreenName string 25 | CreatedAt int64 26 | } 27 | 28 | // Serialize a Key to a string 29 | func (k *Key) Serialize() (string, error) { 30 | return fmt.Sprintf("%d:%s:%d", k.CreatedAt, k.ScreenName, k.ID), nil 31 | } 32 | 33 | // Deserialize a Key from a string 34 | func (k *Key) Deserialize(str string) error { 35 | keys := strings.Split(str, ":") 36 | 37 | id, err := strconv.ParseInt(keys[2], 10, 64) 38 | if err != nil { 39 | return err 40 | } 41 | 42 | timestamp, err := strconv.ParseInt(keys[0], 10, 64) 43 | if err != nil { 44 | return err 45 | } 46 | 47 | k.ID = id 48 | k.ScreenName = keys[1] 49 | k.CreatedAt = timestamp 50 | return nil 51 | } 52 | 53 | // SetupDataStore is fucking obvious 54 | func SetupDataStore(dbFile string) (*DataStore, error) { 55 | db, err := bolt.Open(dbFile, 0600, nil) 56 | if err != nil { 57 | log.Fatal(err) 58 | return nil, err 59 | } 60 | 61 | db.Update(func(tx *bolt.Tx) error { 62 | _, err := tx.CreateBucketIfNotExists([]byte("Tweets")) 63 | return err 64 | }) 65 | 66 | return &DataStore{ 67 | DB: db, 68 | }, nil 69 | } 70 | 71 | // DataStore holds our database instance and config 72 | type DataStore struct { 73 | DB *bolt.DB 74 | } 75 | 76 | // AddTweet to the database 77 | func (d *DataStore) AddTweet(tweet *twitter.Tweet) { 78 | tweetJSON, err := json.Marshal(tweet) 79 | if err != nil { 80 | log.Println(err) 81 | return 82 | } 83 | 84 | createdAt, err := time.Parse(TwitterTimeLayout, tweet.CreatedAt) 85 | 86 | key := &Key{ 87 | ID: tweet.ID, 88 | ScreenName: strings.ToLower(tweet.User.ScreenName), 89 | CreatedAt: createdAt.Unix(), 90 | } 91 | 92 | keyStr, err := key.Serialize() 93 | if err != nil { 94 | log.Println(err) 95 | return 96 | } 97 | 98 | d.DB.Update(func(tx *bolt.Tx) error { 99 | b, err := tx.CreateBucketIfNotExists([]byte("Tweets")) 100 | if err != nil { 101 | return fmt.Errorf("create bucket: %s", err) 102 | } 103 | err = b.Put([]byte(keyStr), []byte(tweetJSON)) 104 | return err 105 | }) 106 | 107 | } 108 | 109 | // FindTweet to find the most recent tweets for a series of accounts 110 | func (d *DataStore) FindTweet(screenNames []string, before int64, page int) *twitter.Tweet { 111 | if before == 0 { 112 | before = time.Now().Unix() 113 | } 114 | var matchingRecords = [][]byte{} 115 | 116 | d.DB.View(func(tx *bolt.Tx) error { 117 | b := tx.Bucket([]byte("Tweets")) 118 | 119 | b.ForEach(func(k, v []byte) error { 120 | key := &Key{} 121 | key.Deserialize(string(k)) 122 | if len(screenNames) == 1 && screenNames[0] == "home" { 123 | if key.CreatedAt < before { 124 | matchingRecords = append(matchingRecords, k) 125 | } 126 | } else { 127 | for _, sn := range screenNames { 128 | if sn == key.ScreenName && key.CreatedAt < before { 129 | matchingRecords = append(matchingRecords, k) 130 | break 131 | } 132 | } 133 | } 134 | return nil 135 | }) 136 | return nil 137 | }) 138 | if len(matchingRecords)-1 < page { 139 | return nil 140 | } 141 | 142 | record := matchingRecords[len(matchingRecords)-page-1] 143 | tweet := &twitter.Tweet{} 144 | 145 | d.DB.View(func(tx *bolt.Tx) error { 146 | b := tx.Bucket([]byte("Tweets")) 147 | tweetJSON := b.Get(record) 148 | json.Unmarshal(tweetJSON, tweet) 149 | return nil 150 | }) 151 | 152 | return tweet 153 | } 154 | 155 | // Clean gets rid of old tweets we no longer care about 156 | func (d *DataStore) Clean(before int64) { 157 | log.Printf("DataStore Clean - Deleting keys older than %d\n", before) 158 | var matchingRecords = [][]byte{} 159 | 160 | d.DB.View(func(tx *bolt.Tx) error { 161 | b := tx.Bucket([]byte("Tweets")) 162 | b.ForEach(func(k, v []byte) error { 163 | key := &Key{} 164 | key.Deserialize(string(k)) 165 | if key.CreatedAt < before { 166 | matchingRecords = append(matchingRecords, k) 167 | } 168 | return nil 169 | }) 170 | return nil 171 | }) 172 | 173 | d.DB.Update(func(tx *bolt.Tx) error { 174 | b := tx.Bucket([]byte("Tweets")) 175 | for _, k := range matchingRecords { 176 | fmt.Printf("Deleting Old Tweet %s\n", k) 177 | b.Delete(k) 178 | } 179 | return nil 180 | }) 181 | } 182 | 183 | // Close the database 184 | func (d *DataStore) Close() { 185 | d.DB.Close() 186 | } 187 | -------------------------------------------------------------------------------- /dns.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "log" 7 | "net" 8 | "strconv" 9 | "strings" 10 | "time" 11 | 12 | "github.com/miekg/dns" 13 | ) 14 | 15 | // DNSServer holds our Server config and state 16 | type DNSServer struct { 17 | Host string 18 | Port int 19 | DataStore *DataStore 20 | Groups map[string]Group 21 | } 22 | 23 | // RecordQuery represent and parsed incoming txt request 24 | // Queries look like: 25 | // "cachebuster123.sinceId.accountName.host.com" 26 | // or "accountName.host.com" 27 | type RecordQuery struct { 28 | Name string 29 | Before int64 30 | Page int 31 | CacheBuster string 32 | Topic bool 33 | } 34 | 35 | func parseFlagInt(s string, def int64) int64 { 36 | i, err := strconv.ParseInt(s[1:len(s)], 10, 64) 37 | if err != nil { 38 | return def 39 | } 40 | return i 41 | } 42 | 43 | func parseRecordName(name string, host string) (*RecordQuery, error) { 44 | name = strings.ToLower(name) 45 | if strings.Index(name, host) == -1 { 46 | return nil, errors.New("Query doesn't contain a matching host") 47 | } 48 | name = strings.TrimSuffix(name, host+".") 49 | name = strings.TrimSuffix(name, ".") 50 | names := strings.Split(name, ".") 51 | if len(names) > 5 { 52 | return nil, errors.New("Invalid query - Should be pPageNum.bTimestamp.accountName.host.com") 53 | } 54 | 55 | rq := &RecordQuery{ 56 | Name: "home", 57 | Before: time.Now().Unix(), 58 | Page: 0, 59 | } 60 | 61 | for _, name := range names { 62 | if strings.HasPrefix(name, "p") { 63 | rq.Page = int(parseFlagInt(name, 0)) 64 | } else if strings.HasPrefix(name, "b") { 65 | rq.Before = parseFlagInt(name, time.Now().Unix()) 66 | } else if strings.HasPrefix(name, "c") { 67 | rq.CacheBuster = name[1:len(name)] 68 | } else if name == "t" { 69 | rq.Topic = true 70 | } else if len(name) > 0 { 71 | rq.Name = name 72 | } 73 | } 74 | return rq, nil 75 | } 76 | 77 | // HandleRequest - Handle inbound DNS queries and return Tweets 78 | func (s *DNSServer) HandleRequest(w dns.ResponseWriter, r *dns.Msg) { 79 | q := r.Question[0] 80 | m := new(dns.Msg) 81 | m.SetReply(r) 82 | 83 | switch q.Qtype { 84 | case dns.TypeA: 85 | m.Answer = make([]dns.RR, 1) 86 | ip := net.IPv4(3, 1, 33, 7) 87 | m.Answer[0] = &dns.A{Hdr: dns.RR_Header{Name: m.Question[0].Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 0}, A: ip} 88 | case dns.TypeTXT: 89 | log.Printf("Incoming request - %s", q.Name) 90 | query, err := parseRecordName(q.Name, s.Host) 91 | if err != nil { 92 | break 93 | } 94 | 95 | names := []string{query.Name} 96 | if query.Topic { 97 | names = s.Groups[strings.Title(query.Name)].Accounts 98 | } 99 | 100 | tweet := s.DataStore.FindTweet(names, query.Before, query.Page) 101 | tweetTxt := "Sorry, no tweets found" 102 | if tweet != nil { 103 | tweetTxt = tweet.Text + " - @" + tweet.User.ScreenName 104 | } 105 | 106 | m.Answer = make([]dns.RR, 1) 107 | m.Answer[0] = &dns.TXT{Hdr: dns.RR_Header{ 108 | Name: m.Question[0].Name, 109 | Rrtype: dns.TypeTXT, 110 | Class: dns.ClassINET, Ttl: 0}, 111 | Txt: []string{tweetTxt}} 112 | default: 113 | log.Println("Uhandled qtype") 114 | } 115 | w.WriteMsg(m) 116 | } 117 | 118 | // Serve - Start the DNS Server 119 | func (s *DNSServer) Serve() error { 120 | dns.HandleFunc(".", s.HandleRequest) 121 | 122 | addr := fmt.Sprintf(":%d", s.Port) 123 | server := &dns.Server{Addr: addr, Net: "udp"} 124 | log.Printf("DNS Serving to %s", addr) 125 | 126 | err := server.ListenAndServe() 127 | if err != nil { 128 | log.Fatal(err) 129 | } 130 | return nil 131 | } 132 | -------------------------------------------------------------------------------- /fetch.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | timestamp=$(date +%s) 4 | host='' 5 | subdomain='' 6 | account='' 7 | 8 | n=10 9 | 10 | cache_buster=$(cat /dev/urandom | env LC_CTYPE=C tr -dc 'a-z0-9' | fold -w 16 | head -n 1) 11 | 12 | usage() { echo "Usage: fetch [-n ] [-h ] [-t topic] account/topic/home" 1>&2; exit 1; } 13 | 14 | 15 | while getopts ":h:n:t" o; do 16 | case "${o}" in 17 | h) 18 | host=${OPTARG} 19 | ;; 20 | n) 21 | n=${OPTARG} 22 | ;; 23 | t) 24 | t=1 25 | ;; 26 | *) 27 | usage 28 | ;; 29 | esac 30 | done 31 | 32 | shift "$((OPTIND - 1))" 33 | account=${@: -1} 34 | if [ -z "${account}" ]; then 35 | account='home' 36 | fi 37 | 38 | if [ -n "${t}" ]; then 39 | subdomain='t.'${subdomain} 40 | fi 41 | 42 | if [ -z "${host}" ]; then 43 | usage 44 | fi 45 | 46 | for i in `seq 0 ${n}`; 47 | do 48 | #echo c${cache_buster}.b${timestamp}.p${i}.${account}.${subdomain}${host} 49 | dig txt c${cache_buster}.b${timestamp}.p${i}.${account}.${subdomain}${host}| grep -A1 "ANSWER SECTION" | sed -n -e 's/.*TXT.//p' 50 | done 51 | -------------------------------------------------------------------------------- /oauth.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/mrjones/oauth" 7 | ) 8 | 9 | const ( 10 | twAuthURL = "https://api.twitter.com/oauth/authorize" 11 | twTokenURL = "https://api.twitter.com/oauth/request_token" 12 | twAccessTokenURL = "https://api.twitter.com/oauth/access_token" 13 | ) 14 | 15 | // Oauth struct for holding a consumer 16 | type Oauth struct { 17 | Consumer *oauth.Consumer 18 | } 19 | 20 | // AuthenticationRequest holds our request for later confirmation 21 | type AuthenticationRequest struct { 22 | RequestToken *oauth.RequestToken 23 | URL string 24 | } 25 | 26 | func newOauth(ConsumerKey, ConsumerSecret string) Oauth { 27 | c := oauth.NewConsumer( 28 | ConsumerKey, 29 | ConsumerSecret, 30 | oauth.ServiceProvider{ 31 | RequestTokenUrl: twTokenURL, 32 | AuthorizeTokenUrl: twAuthURL, 33 | AccessTokenUrl: twAccessTokenURL, 34 | }) 35 | 36 | return Oauth{c} 37 | } 38 | 39 | func (o Oauth) newAuthenticationRequest() (*AuthenticationRequest, error) { 40 | requestToken, url, err := o.Consumer.GetRequestTokenAndUrl("oob") 41 | if err != nil { 42 | return nil, err 43 | } 44 | 45 | return &AuthenticationRequest{requestToken, url}, nil 46 | } 47 | 48 | func (o Oauth) getAccessToken(RequestToken *oauth.RequestToken, code string) (*oauth.AccessToken, error) { 49 | accessToken, err := o.Consumer.AuthorizeToken(RequestToken, code) 50 | return accessToken, err 51 | } 52 | 53 | // AuthWithTwitter call with the consumer key and secret to start 54 | // an OOB/PIN authentication with Twitter 55 | func AuthWithTwitter(consumerKey, consumerSecret string) { 56 | oauth := newOauth(consumerKey, consumerSecret) 57 | ar, _ := oauth.newAuthenticationRequest() 58 | fmt.Printf("In your browser, log in to your twitter account. Then visit: \n%s\n", ar.URL) 59 | fmt.Println("After logged in, you will be promoted with a pin number") 60 | fmt.Println("Enter the pin number here:") 61 | 62 | pinCode := "" 63 | fmt.Scanln(&pinCode) 64 | 65 | accessToken, err := oauth.getAccessToken(ar.RequestToken, pinCode) 66 | if err != nil { 67 | fmt.Printf("Error getting your access token: %s\n", err) 68 | return 69 | } 70 | 71 | fmt.Println("Success! The following are your access token and secret. Update config.toml with these keys") 72 | fmt.Printf("TokenKey = \"%s\"\nTokenSecret = \"%s\"\n", accessToken.Token, accessToken.Secret) 73 | } 74 | -------------------------------------------------------------------------------- /planeboard.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "os/signal" 7 | "time" 8 | 9 | "github.com/BurntSushi/toml" 10 | "github.com/dghubble/go-twitter/twitter" 11 | "github.com/dghubble/oauth1" 12 | ) 13 | 14 | // Config holds our basic server config, pull from config.toml 15 | type Config struct { 16 | ConsumerKey string 17 | ConsumerSecret string 18 | TokenKey string 19 | TokenSecret string 20 | DBFile string 21 | Host string 22 | Port int 23 | Groups map[string]Group 24 | } 25 | 26 | // Group of accounts 27 | type Group struct { 28 | Accounts []string 29 | } 30 | 31 | func streamTweets(d *DataStore, config *Config) { 32 | consumer := oauth1.NewConfig(config.ConsumerKey, config.ConsumerSecret) 33 | token := oauth1.NewToken(config.TokenKey, config.TokenSecret) 34 | 35 | httpClient := consumer.Client(oauth1.NoContext, token) 36 | demux := twitter.NewSwitchDemux() 37 | demux.Tweet = func(tweet *twitter.Tweet) { 38 | log.Printf("Incoming tweet: @%s - '%s'", tweet.User.ScreenName, tweet.Text) 39 | d.AddTweet(tweet) 40 | } 41 | 42 | // Twitter client 43 | client := twitter.NewClient(httpClient) 44 | params := &twitter.StreamUserParams{ 45 | StallWarnings: twitter.Bool(true), 46 | } 47 | stream, err := client.Streams.User(params) 48 | if err != nil { 49 | log.Fatal("Error", err) 50 | } 51 | log.Println("Streaming tweets") 52 | 53 | go demux.HandleChan(stream.Messages) 54 | } 55 | 56 | func parseConfig() Config { 57 | conf := Config{} 58 | log.Println("Parsing config.toml") 59 | if _, err := toml.DecodeFile("config.toml", &conf); err != nil { 60 | log.Fatal("Toml parsing error", err) 61 | } 62 | return conf 63 | } 64 | 65 | func main() { 66 | log.Println("Starting server") 67 | 68 | terminate := make(chan os.Signal) 69 | signal.Notify(terminate, os.Interrupt) 70 | 71 | config := parseConfig() 72 | 73 | arg := os.Args[len(os.Args)-1] 74 | if arg == "auth" { 75 | AuthWithTwitter(config.ConsumerKey, config.ConsumerSecret) 76 | return 77 | } 78 | 79 | dataStore, _ := SetupDataStore(config.DBFile) 80 | defer dataStore.Close() 81 | 82 | dnsServer := &DNSServer{ 83 | Host: config.Host, 84 | DataStore: dataStore, 85 | Port: config.Port, 86 | Groups: config.Groups, 87 | } 88 | 89 | go dnsServer.Serve() 90 | go streamTweets(dataStore, &config) 91 | go func() { 92 | for { 93 | dataStore.Clean(time.Now().Unix() - 60*60*3) 94 | time.Sleep(15 * time.Minute) 95 | } 96 | }() 97 | 98 | <-terminate 99 | log.Printf("PlaneBoard: signal received, stopping") 100 | } 101 | --------------------------------------------------------------------------------