1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
2//
3// SPDX-License-Identifier: BSD-2-Clause
4
5package main
6
7import (
8 "embed"
9 "encoding/base64"
10 "errors"
11 "fmt"
12 "html/template"
13 "io"
14 "net/http"
15 "os"
16 "strings"
17
18 "github.com/BurntSushi/toml"
19 flag "github.com/spf13/pflag"
20 "gopkg.in/gomail.v2"
21)
22
23//go:embed static
24var fs embed.FS
25
26var flagConfig *string = flag.StringP("config", "c", "config.toml", "Path to config file")
27
28type (
29 Config struct {
30 Server server
31 Org org
32 Smtpconfig smtpconfig
33 Gotify gotify
34 }
35 server struct {
36 Listen string
37 Password string
38 }
39 org struct {
40 Name string
41 Primarycolour string
42 Agepubkey string
43 Notify []string
44 }
45 smtpconfig struct {
46 Enabled bool
47 Hostname string
48 Port int
49 From string
50 User string
51 Password string
52 }
53 gotify struct {
54 Enabled bool
55 Server string
56 Token string
57 Priority int
58 }
59)
60
61var config Config
62
63func main() {
64 flag.Parse()
65 // Check whether config exists
66 if _, err := os.Stat(*flagConfig); os.IsNotExist(err) {
67 fmt.Println("Creating config file")
68 err = createConfig()
69 if err != nil {
70 fmt.Println(err)
71 os.Exit(1)
72 }
73 fmt.Println("Config file created at " + *flagConfig + ", please edit it and restart the program.")
74 os.Exit(0)
75 }
76 _, err := toml.DecodeFile(*flagConfig, &config)
77 if err != nil {
78 fmt.Println(err)
79 os.Exit(1)
80 }
81 if config.Server.Password == "" {
82 fmt.Println("Password not set, please set it in the config file and restart the program.")
83 os.Exit(1)
84 }
85 fmt.Println("Listening on", config.Server.Listen)
86 mux := http.NewServeMux()
87 httpServer := &http.Server{
88 Addr: config.Server.Listen,
89 Handler: mux,
90 }
91 mux.HandleFunc("/", fourOhFour)
92 mux.HandleFunc("/static/", static)
93 mux.HandleFunc("/success/", status)
94 mux.HandleFunc("/error/", status)
95 mux.HandleFunc("/404/", fourOhFour)
96 mux.HandleFunc("/"+config.Server.Password, form)
97 if err = httpServer.ListenAndServe(); errors.Is(err, http.ErrServerClosed) {
98 fmt.Println("Web server closed")
99 } else {
100 panic(err)
101 }
102}
103
104// Writes default config to file
105func createConfig() error {
106 _, err := os.Create(*flagConfig)
107 if err != nil {
108 return err
109 }
110 // Write default config to file
111 defaultConfig := `[server]
112listen = "localhost:8257"
113password = ""
114
115[org]
116name = "Organisation Name"
117primarycolour = "#00897b"
118agepubkey = ""
119notify = ["credentials@example.com"]
120
121[smtpConfig]
122enabled = true
123hostname = "smtp.example.com"
124port = 587
125from = "Credentials <noreply@example.com>"
126user = "noreply@example.com"
127password = "super-secure-password"
128
129# Not implemented yet
130[gotify]
131enabled = true
132server = "https://notify.example.com"
133token = "super-secure-token"
134priority = 4
135`
136 f, err := os.OpenFile(*flagConfig, os.O_APPEND|os.O_WRONLY, 0o600)
137 if err != nil {
138 return err
139 }
140 defer f.Close()
141 _, err = f.WriteString(defaultConfig)
142 if err != nil {
143 return err
144 }
145 return nil
146}
147
148func static(writer http.ResponseWriter, request *http.Request) {
149 resource := strings.TrimPrefix(request.URL.Path, "/")
150 // if path ends in .css, set content type to text/css
151 if strings.HasSuffix(resource, ".css") {
152 writer.Header().Set("Content-Type", "text/css")
153 } else if strings.HasSuffix(resource, ".js") {
154 writer.Header().Set("Content-Type", "text/javascript")
155 }
156 home, err := fs.ReadFile(resource)
157 if err != nil {
158 fmt.Println(err)
159 }
160 if _, err = io.WriteString(writer, string(home)); err != nil {
161 fmt.Println(err)
162 }
163}
164
165func status(writer http.ResponseWriter, request *http.Request) {
166 path := strings.ReplaceAll(request.URL.Path, "/", "")
167 page, err := fs.ReadFile("static/" + path + ".html")
168 if err != nil {
169 fmt.Println(err)
170 http.Redirect(writer, request, "/404", http.StatusNotFound)
171 return
172 }
173 tmpl, err := template.New("page").Parse(string(page))
174 if err != nil {
175 fmt.Println(err)
176 }
177 writer.Header().Set("Content-Type", "text/html")
178 if err = tmpl.Execute(writer, config); err != nil {
179 fmt.Println(err)
180 }
181}
182
183func fourOhFour(writer http.ResponseWriter, request *http.Request) {
184 page, err := fs.ReadFile("static/404.html")
185 if err != nil {
186 fmt.Println(err)
187 }
188 tmpl, err := template.New("page").Parse(string(page))
189 if err != nil {
190 fmt.Println(err)
191 }
192 writer.Header().Set("Content-Type", "text/html")
193 if err = tmpl.Execute(writer, config); err != nil {
194 fmt.Println(err)
195 }
196}
197
198func form(writer http.ResponseWriter, request *http.Request) {
199 if request.Method == "GET" {
200 home, err := fs.ReadFile("static/form.html")
201 if err != nil {
202 fmt.Println(err)
203 }
204 tmpl, err := template.New("home").Parse(string(home))
205 if err != nil {
206 fmt.Println(err)
207 }
208 writer.Header().Set("Content-Type", "text/html")
209 if err = tmpl.Execute(writer, config); err != nil {
210 fmt.Println(err)
211 }
212 return
213 }
214
215 bodyBuf := new(strings.Builder)
216 _, err := io.Copy(bodyBuf, request.Body)
217 if err != nil {
218 fmt.Println(err)
219 }
220
221 body := bodyBuf.String()
222
223 correctHeader := "-----BEGIN AGE ENCRYPTED FILE-----\n"
224 correctFooter := "-----END AGE ENCRYPTED FILE-----\n"
225 correctBodyBeginning := "age-encryption.org/v1\n-> X25519"
226
227 if !strings.HasPrefix(body, correctHeader) {
228 fmt.Println("Malformed submission from " + request.RemoteAddr + ": header check failed")
229 http.Redirect(writer, request, "/error", http.StatusSeeOther)
230 return
231 }
232 if !strings.HasSuffix(body, correctFooter) {
233 fmt.Println("Malformed submission from " + request.RemoteAddr + ": footer check failed")
234 http.Redirect(writer, request, "/error", http.StatusSeeOther)
235 return
236 }
237
238 base64Body := strings.TrimPrefix(body, correctHeader)
239 base64Body = strings.TrimSuffix(base64Body, correctFooter)
240 base64Body = strings.ReplaceAll(base64Body, " ", "")
241 base64Body = strings.ReplaceAll(base64Body, "\n", "")
242 decodedBodyBytes, err := base64.StdEncoding.DecodeString(base64Body)
243 if err != nil {
244 fmt.Println("Malformed submission from " + request.RemoteAddr + ": base64 decoding failed")
245 http.Redirect(writer, request, "/error", http.StatusSeeOther)
246 return
247 }
248
249 decodedBody := string(decodedBodyBytes)
250
251 if !strings.HasPrefix(decodedBody, correctBodyBeginning) {
252 fmt.Println("Malformed submission from " + request.RemoteAddr + ": body check failed")
253 http.Redirect(writer, request, "/error", http.StatusSeeOther)
254 return
255 }
256
257 for _, to := range config.Org.Notify {
258 m := gomail.NewMessage()
259 m.SetHeader("From", config.Smtpconfig.From)
260 m.SetHeader("To", to)
261 m.SetHeader("Subject", "New credentials from client")
262 m.SetBody("text/plain", body+"\nThe text above is encrypted with age; please install age and use your\norganisation's private key to decrypt it.\n\nhttps://github.com/FiloSottile/age\n")
263 d := gomail.NewDialer(config.Smtpconfig.Hostname, config.Smtpconfig.Port, config.Smtpconfig.User, config.Smtpconfig.Password)
264 if err := d.DialAndSend(m); err != nil {
265 fmt.Println(err)
266 }
267 }
268 http.Redirect(writer, request, "/success", http.StatusSeeOther)
269}