main.go

  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}