-
Notifications
You must be signed in to change notification settings - Fork 5
/
main.go
153 lines (135 loc) · 5.05 KB
/
main.go
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
package main
import (
"context"
"database/sql"
"errors"
"fmt"
"log"
"net/http"
"os"
"os/exec"
"time"
_ "github.com/lib/pq"
)
func main() {
if err := startTailscale(context.Background()); err != nil {
log.Fatal(err)
}
if err := startWikiServer(context.Background()); err != nil {
log.Fatal(err)
}
if err := startPublicDummyServer(); err != nil {
log.Fatal(err)
}
}
const (
stateFilePath = "/wiki/ts.state"
socketPath = "/wiki/ts.sock"
)
const tsTableQuery = `
CREATE TABLE IF NOT EXISTS "tailscale_data" (
"id" serial primary key,
"state" text not null
);
`
func startTailscale(ctx context.Context) error {
// The TAILSCALE_AUTHKEY is a required environment variable.
// Users will see a textfield to add their key on the Heroku deploy dashboard.
// When quick-deploying from the admin panel, the key value gets set automatically.
tsAuthKey := os.Getenv("TAILSCALE_AUTHKEY")
// We ask heroku to startup a "heroku-postgresql" add-on (see app.json).
// We open up the db here to help us manage our tailscale state file.
databaseURL := os.Getenv("DATABASE_URL")
if databaseURL == "" {
return fmt.Errorf("DATABASE_URL is not set")
}
db, err := sql.Open("postgres", databaseURL+"?sslmode=require")
if err != nil {
return fmt.Errorf("failed to open database: %w", err)
}
defer db.Close()
// When the Heroku dyno restarts, all state/local storage gets wiped.
// To avoid creating new tailscale nodes on each restart (restarts happen fairly frequently on the Heroku free tier),
// we store the tailscale state file contents in our postgres db in a "tailscale_data" table.
_, err = db.ExecContext(ctx, tsTableQuery)
if err != nil {
return fmt.Errorf("failed to create tailscale_data table: %w", err)
}
var tsState string
// Try to grab our state file contents from the db.
// It's fine if nothing is found. That means we likely haven't authenticated tailscale for the first time yet.
err = db.QueryRowContext(ctx, "SELECT state FROM tailscale_data ORDER BY id DESC LIMIT 1").Scan(&tsState)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("failed to read state file from database: %w", err)
}
if tsState != "" {
// If we do have state already, write it out to our locally state file.
// We will pass this filepath as a flag when starting up tailscale below.
err := os.WriteFile(stateFilePath, []byte(tsState), 0644)
if err != nil {
return fmt.Errorf("failed to write state file: %w", err)
}
}
if tsAuthKey == "" && tsState == "" {
return fmt.Errorf("TAILSCALE_AUTHKEY or state file must be present")
}
// Start `tailscaled`.
daemoncmd := exec.CommandContext(ctx, "/app/tailscaled", "--socket", socketPath, "--state", stateFilePath, "--tun", "userspace-networking")
if err = daemoncmd.Start(); err != nil {
return fmt.Errorf("failed to start tailscaled: %w", err)
}
time.Sleep(1 * time.Second) // TODO: this is hacky
// Start `tailscale`.
args := []string{"--socket", socketPath, "up", "--hostname", "wiki-server"}
if tsAuthKey != "" {
args = append(args, "--authkey", tsAuthKey)
}
cmd := exec.CommandContext(ctx, "/app/tailscale", args...)
if err = cmd.Run(); err != nil {
return fmt.Errorf("failed to start tailscale: %w", err)
}
// Now that we've started up tailscale, store the state file contents back
// to the db so we can restore them the next time the dyno restarts.
b, err := os.ReadFile(stateFilePath)
if err != nil {
return fmt.Errorf("failed to read state file: %w", err)
}
if string(b) != tsState {
_, err := db.ExecContext(ctx, "INSERT INTO tailscale_data(state) VALUES($1)", string(b))
if err != nil {
return fmt.Errorf("failed to update state file in database: %w", err)
}
}
return nil
}
func startWikiServer(ctx context.Context) error {
cmd := exec.CommandContext(ctx, "node", "/wiki/server")
// Without specifying the port here, wiki.js will use the default random public port of the Heroku dyno.
// We force it to port 3000 so we can make it only accessible via tailscale.
cmd.Env = append(os.Environ(), "PORT=3000")
if err := cmd.Start(); err != nil {
return fmt.Errorf("failed to start wiki server: %w", err)
}
return nil
}
// startPublicDummyServer starts a go webserver that displays a simple welcome prompt to viewers.
// Heroku requires something to be running at it's public port, otherwise it shuts down the dyno.
// We only want our wiki server acessible over tailscale, though so we don't want to serve that over
// the public Heroku port. Instead, we place this dummy server at the public endpoint.
func startPublicDummyServer() error {
// Grab the Heroku random public port assigned to our dyno.
port := os.Getenv("PORT")
if port == "" {
return fmt.Errorf("failed to find Heroku public port")
}
hostname, err := os.Hostname()
if err != nil {
return err
}
publicMux := http.NewServeMux()
publicMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Welcome! Hello from %s", hostname) // TODO: make this view a little more useful (maybe add instructions for accessing the wiki)
})
http.ListenAndServe(":"+port, publicMux)
return nil
}