1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
|
/* Copyright © 2021 aabacchus
* Apache License, Version 2.0
* See LICENSE file for copyright and license details.
*
* Copyright (c) 2015 Matt Baer
* MIT license
*/
// this program is a rewrite of [https://github.com/thebaer]'s tildes/where
// (MIT licence)
// which plots a map of the locations of ctrl-c.club users.
// However, that project has not been updated for several years, and it has a
// small issue with showing many IPs at the location (0,0).
// Therefore, as an excercise in Go and for the benefit of the ctrl-c.club community,
// I'm trying to make what is basically the same thing.
package main
import (
"encoding/json"
"errors"
"flag"
"fmt"
"io/ioutil"
"net/http"
"os"
"os/exec"
"strings"
)
var verbose *bool
func usage() {
fmt.Fprintf(os.Stderr, "usage: %s\t[-h] [-p] [-v]\n\t\t[-c | -k -mboxu -mboxa -mboxs [-mboxp]]\n", os.Args[0])
flag.PrintDefaults()
fmt.Fprintf(os.Stderr, "\nwhere finds users who have opted in by creating a \".here\" file in their home directory,\nfinds their approximate location from their IP address, and creates a map of the locations of those users.\n")
}
// exists returns true if fname is a file that exists.
func exists(fname string) bool {
_, err := os.Stat(fname)
return !os.IsNotExist(err)
}
// isOptedIn checks if the user has opted in by having a file
// named ".here" or ".somewhere" in their home directory.
// (all data is anonymous so both are used in the same way)
func isOptedIn(user string) bool {
homedir := fmt.Sprintf("/home/%s/", user)
return exists(homedir+".here") || exists(homedir+".somewhere")
}
func log(msg ...interface{}) {
if *verbose {
fmt.Println(msg...)
}
}
func main() {
apiKey := flag.String("k", "", "API key for ipstack")
usePretendWhoips := flag.Bool("p", false, "use a cached output of who --ips")
useCredFile := flag.String("c", "", "read credentials from a json file (keys are command-line flags)")
verbose = flag.Bool("v", false, "turn on verbose output")
var mboxDetails MapboxDetails
flag.StringVar(&mboxDetails.Uname, "mboxu", "", "mapbox.com username")
flag.StringVar(&mboxDetails.Apikey, "mboxa", "", "mapbox.com API key")
flag.StringVar(&mboxDetails.Style, "mboxs", "", "mapbox map style")
flag.IntVar(&mboxDetails.Padding, "mboxp", 5, "mapbox map padding (a percentage without the %)")
flag.Usage = usage
flag.Parse()
// get the who data, either from a file or the command itself
var ips []byte
var err error
if *usePretendWhoips {
ips, err = read("whoips")
if err != nil {
fmt.Printf("error reading sample who --ips file: %s\n", err.Error())
os.Exit(1)
}
} else {
ips, err = exec.Command("who", "--ips").Output()
if err != nil {
fmt.Println("error running `who --ips`: " + err.Error())
}
}
// if necessary, get the credentials from a file
// (overrides credentials specified with flags)
if *useCredFile != "" {
bytes, err := read(*useCredFile)
if err != nil {
fmt.Printf("error reading cred file: %s\n", err)
os.Exit(1)
}
var creds struct {
K string
Mboxa string
Mboxp int
Mboxs string
Mboxu string
}
err = json.Unmarshal(bytes, &creds)
if err != nil {
fmt.Printf("error unmarshalling creds: %s\n", err)
os.Exit(1)
}
// set global variables to our credentials
*apiKey = creds.K
mboxDetails = MapboxDetails{
Apikey: creds.Mboxa,
Padding: creds.Mboxp,
Style: creds.Mboxs,
Uname: creds.Mboxu,
}
}
// extract data from the who output
rawlines := parseLines(ips)
log("raw who data: ", rawlines)
lines := make([][]string, 0)
// only keep users who have opted in
for _, line := range rawlines {
name := line[0]
if isOptedIn(name) {
lines = append(lines, line)
}
}
responseChan := make(chan MarkResponse)
var results = make([]Marker, len(lines))
for _, line := range lines {
ip := line[4]
if ip[0] == '(' {
if strings.Contains(ip, "mosh") || strings.Contains(ip, "tmux") {
ip = ""
} else {
endidx := strings.Index(ip, ":")
if endidx == -1 {
endidx = strings.Index(ip, ")")
if endidx == -1 {
endidx = len(ip)
}
}
ip = ip[1:endidx]
}
}
go ipLatLng(*apiKey, line[0], ip, responseChan)
}
var resp MarkResponse
for i := range lines {
resp = <-responseChan
if resp.Err != nil {
fmt.Printf("error getting ip location for %s: %s\n", resp.Mark.Name, resp.Err)
continue
}
log("found location: ", resp.Mark)
results[i] = resp.Mark
}
// check if there's a file of results already
cacheFname := "ips.json"
if exists(cacheFname) {
bytes, err := read(cacheFname)
if err != nil {
fmt.Printf("error reading ips cache: %s\n", err)
os.Exit(1)
}
log(fmt.Sprintf("found previous results file %s", cacheFname))
var cache []Marker
err = json.Unmarshal(bytes, &cache)
if err != nil {
fmt.Printf("error unmarshalling ips cache: %s\n", err)
os.Exit(1)
}
// make some temporary maps in order to merge the two slices
tmpCache := MarkersMakeMap(cache)
tmpResults := MarkersMakeMap(results)
var out []Marker
// remove duplicates, using the one which isn't 0,0
// append the good Marker to out
// also check that the user is still opted in
for k, val := range tmpResults {
if cachedVal, ok := tmpCache[k]; ok && isOptedIn(k) {
if val.Lat == 0 && val.Lng == 0 {
out = append(out, cachedVal)
} else {
out = append(out, val)
}
} else {
// not a duplicate
out = append(out, val)
}
}
// add the cached values which aren't in the new lot
// also check that the user is still opted in
for k, cVal := range tmpCache {
if _, ok := tmpResults[k]; !ok && isOptedIn(k) {
out = append(out, cVal)
}
}
results = out
log(results)
}
// save our results to a json file
err = MarkersSaveJson(results, "ips.json")
if err != nil {
fmt.Printf("error saving as json: %s\n", err)
os.Exit(1)
}
err = LeafletDynamic(results, "dynamic.html")
if err != nil {
fmt.Printf("error making leaflet dynamic map: %s\n", err)
os.Exit(1)
}
// make a map of the markers and save it as a png
imageFile := "map.png"
err = MapboxStatic(results, imageFile, mboxDetails)
if err != nil {
fmt.Printf("error creating static map: %s\n", err)
os.Exit(1)
}
fmt.Printf("saved static map to %s\n", imageFile)
}
func LeafletDynamic(m []Marker, fname string) error {
f, err := os.Create(fname)
if err != nil {
return err
}
defer f.Close()
fmt.Fprintf(f, `<!DOCTYPE html>
<html>
<!-- copyright 2023 ~phoebos <phoebos@ctrl-c.club> -->
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>dynamic map of ctrl-c club users</title>
<link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.3/dist/leaflet.css" integrity="sha256-kLaT2GOSpHechhsozzB+flnD+zUyjE2LlfWPgU04xyI=" crossorigin=""/>
<script src="https://unpkg.com/leaflet@1.9.3/dist/leaflet.js"
integrity="sha256-WBkoXOwTeyKclOHuWtc+i2uENFpDZ9YPdf5Hf+D7ewM="
crossorigin=""></script>
</head>
<body>
<h1>Map of Ctrl-C.Club users (dynamic version)</h1>
<p>To opt-in, add a file named <code style="font-family: monospace">.here</code> to your home directory.
The map is updated every 15 minutes.</p>
<p>To view a map without Javascript, see <a href="map.html">here</a>.</p>
<div id="map" style="height: 500px;"></div>
<p><a href="https://github.com/aabacchus/where">Repo</a></p>
<p><a href="/~bear/where.html">Inspiration</a></p>
<script>
var map = L.map('map').setView([20,10], 1);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxzoom: 19,
attribution: '© <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map);`)
for _, k := range m {
if k.Lat == 0 && k.Lng == 0 {
continue
}
_, err = fmt.Fprintf(f, "L.marker([%f,%f]).addTo(map);\n", k.Lat, k.Lng)
if err != nil {
return err
}
}
fmt.Fprintf(f, `</script></body></html>`)
return err
}
func MarkersMakeMap(m []Marker) map[string]Marker {
r := make(map[string]Marker)
for _, k := range m {
r[k.Name] = k
}
return r
}
// MarkersSaveJson saves a slice of Markers to fname in json format.
func MarkersSaveJson(m []Marker, fname string) error {
f, err := os.Create(fname)
if err != nil {
return err
}
defer f.Close()
bytes, err := json.MarshalIndent(m, "", "\t")
if err != nil {
return err
}
_, err = f.Write(bytes)
return err
}
// Marker holds the data for a named location.
type Marker struct {
Name string
Lat, Lng float64
}
// MarkResponse is used as a channel to get the results from ipLatLng.
// Making a struct and sending the results on a channel isn't a great method
// (I intend to make this better)
type MarkResponse struct {
Mark Marker
Err error
}
// parseLines converts a slice of bytes into a slice of slices of strings,
// where the bytes are separated by whitespace and newlines.
// Lines in the input with the same first field are considered
// duplicates, and only one version is kept.
func parseLines(ips []byte) [][]string {
var word string
var words [][]string
var line int = 0
words = append(words, []string{})
for _, ip := range ips {
if ip == ' ' {
if word == "" {
continue
}
words[line] = append(words[line], word)
word = ""
continue
}
if ip == '\n' {
// remove duplicates by username
isDuplicate := false
// no need to check if we're on the first line
if line != 0 {
for _, prevline := range words[:line] {
if words[line][0] == prevline[0] {
isDuplicate = true
}
}
}
if isDuplicate {
words[line] = []string{}
word = ""
} else {
words[line] = append(words[line], word)
words = append(words, []string{})
line++
word = ""
}
continue
}
word = word + string(ip)
}
words = words[:len(words)-1]
return words
}
// ipLatLng uses the IPStack api to convert an IP address into
// a latitude and longitude, sending the result on the channel
func ipLatLng(apikey, name, ip string, ch chan MarkResponse) {
if ip == "" {
ch <- MarkResponse{Marker{Name: name}, errors.New("no IP provided")}
return
}
query := fmt.Sprintf("https://freegeoip.app/json/%s", ip)
resp, err := http.Get(query)
if err != nil {
ch <- MarkResponse{Marker{Name: name}, err}
return
}
defer resp.Body.Close()
// freegeoip.app will give 403 if we've made more than 15,000 queries per hour.
// Unlikely, yes, but good to be careful.
if resp.StatusCode == 403 {
// this is logged to stderr, so it should be picked up by the cron daemon
fmt.Fprintf(os.Stderr, "The request to freegeoip.app returned %s", resp.Status)
}
bytes, err := ioutil.ReadAll(resp.Body)
if err != nil {
ch <- MarkResponse{Marker{Name: name}, err}
return
}
place := struct {
Lat float64 `json:"latitude"`
Lng float64 `json:"longitude"`
}{}
if err := json.Unmarshal(bytes, &place); err != nil {
ch <- MarkResponse{Marker{Name: name}, err}
return
}
ch <- MarkResponse{Marker{
Name: name,
Lat: place.Lat,
Lng: place.Lng,
}, nil}
return
}
// read is just a wrapper to read a file.
func read(fname string) ([]byte, error) {
f, err := os.Open(fname)
defer f.Close()
if err != nil {
return []byte{}, err
}
return ioutil.ReadAll(f)
}
|