Improve security and logging
This commit is contained in:
parent
affcef2eda
commit
f50f4f1919
10 changed files with 154 additions and 30 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -23,4 +23,5 @@ hibiscus
|
||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
# Because currently it's used in "prod"
|
# Because currently it's used in "prod"
|
||||||
data/
|
data/
|
||||||
|
/config/log.txt
|
|
@ -13,6 +13,7 @@ As a result of this, it is also neither secure nor idiot-proof.
|
||||||
* You can easily export everything in a zip file for backups
|
* You can easily export everything in a zip file for backups
|
||||||
|
|
||||||
* Everything is plain(text) and simple. No databases, encryption, OAuth, or anything fancy. Even the password is plainte- *wait is this a feature?*
|
* Everything is plain(text) and simple. No databases, encryption, OAuth, or anything fancy. Even the password is plainte- *wait is this a feature?*
|
||||||
|
* Telegram notifications support
|
||||||
|
|
||||||
## Data format:
|
## Data format:
|
||||||
```
|
```
|
||||||
|
|
3
TODO.md
3
TODO.md
|
@ -2,12 +2,11 @@
|
||||||
List of things to add to this project
|
List of things to add to this project
|
||||||
|
|
||||||
## Crucial
|
## Crucial
|
||||||
* Handle all the w.Write errors somehow
|
|
||||||
* Add export feature
|
* Add export feature
|
||||||
* Add missing frontend pages
|
* Add missing frontend pages
|
||||||
|
* More slog.Debug?
|
||||||
|
|
||||||
## Long-medium term considerations
|
## Long-medium term considerations
|
||||||
* Improve logging, log to files
|
|
||||||
* Make the CLI better
|
* Make the CLI better
|
||||||
* Think about timezones
|
* Think about timezones
|
||||||
* Consider more secure auth methods
|
* Consider more secure auth methods
|
||||||
|
|
69
auth.go
69
auth.go
|
@ -3,9 +3,43 @@ package main
|
||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type failedLogin struct {
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
Timestamp time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var failedLogins []failedLogin
|
||||||
|
|
||||||
|
func NoteLoginFail(username string, password string, r *http.Request) {
|
||||||
|
slog.Warn("failed auth", "username", username, "password", password, "address", r.RemoteAddr)
|
||||||
|
NotifyTelegram(fmt.Sprintf("Failed auth attempt in hibiscus:\nusername=%s\npassword=%s\nremote=%s", username, password, r.RemoteAddr))
|
||||||
|
|
||||||
|
attempt := failedLogin{username, password, time.Now()}
|
||||||
|
updatedLogins := []failedLogin{attempt}
|
||||||
|
|
||||||
|
for _, attempt := range failedLogins {
|
||||||
|
if 100 > time.Now().Sub(attempt.Timestamp).Abs().Seconds() {
|
||||||
|
updatedLogins = append(updatedLogins, attempt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
failedLogins = updatedLogins
|
||||||
|
|
||||||
|
// At least 3 failed attempts in last 100 seconds -> likely bruteforce
|
||||||
|
if len(failedLogins) >= 3 {
|
||||||
|
Scram()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// BasicAuth is a middleware that handles authentication & authorization for the app.
|
// BasicAuth is a middleware that handles authentication & authorization for the app.
|
||||||
// It uses BasicAuth because I doubt there is a need for something sophisticated in a small hobby project
|
// It uses BasicAuth because I doubt there is a need for something sophisticated in a small hobby project
|
||||||
// Originally taken from https://www.alexedwards.net/blog/basic-authentication-in-go (13.03.2024)
|
// Originally taken from https://www.alexedwards.net/blog/basic-authentication-in-go (13.03.2024)
|
||||||
|
@ -26,8 +60,9 @@ func BasicAuth(next http.Handler) http.Handler {
|
||||||
if usernameMatch && passwordMatch {
|
if usernameMatch && passwordMatch {
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
|
} else {
|
||||||
|
NoteLoginFail(username, password, r)
|
||||||
}
|
}
|
||||||
// TODO: Note failed login attempt?
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unauthorized, inform client that we have auth and return 401
|
// Unauthorized, inform client that we have auth and return 401
|
||||||
|
@ -35,3 +70,35 @@ func BasicAuth(next http.Handler) http.Handler {
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scram shuts down the service, useful in case of suspected attack
|
||||||
|
func Scram() {
|
||||||
|
slog.Warn("SCRAM triggered, shutting down")
|
||||||
|
NotifyTelegram("Hibiscus SCRAM triggered, shutting down")
|
||||||
|
os.Exit(0) // TODO: should this be 0 or 1?
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotifyTelegram attempts to send a message to admin through telegram
|
||||||
|
func NotifyTelegram(msg string) {
|
||||||
|
if Cfg.TelegramChat == "" || Cfg.TelegramToken == "" {
|
||||||
|
slog.Warn("ignoring telegram request due to lack of credentials")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
client := &http.Client{}
|
||||||
|
var data = strings.NewReader("chat_id=" + Cfg.TelegramChat + "&text=" + msg)
|
||||||
|
req, err := http.NewRequest("POST", "https://api.telegram.org/bot"+Cfg.TelegramToken+"/sendMessage", data)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed telegram request", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed telegram request", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
slog.Error("failed telegram request", "status", resp.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
10
config.go
10
config.go
|
@ -16,10 +16,16 @@ type Config struct {
|
||||||
Username string
|
Username string
|
||||||
Password string
|
Password string
|
||||||
Port int
|
Port int
|
||||||
|
|
||||||
|
TelegramToken string
|
||||||
|
TelegramChat string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) Save() error {
|
func (c *Config) Save() error {
|
||||||
output := fmt.Sprintf("port=%d\nusername=%s\npassword=%s", c.Port, c.Username, c.Password)
|
output := fmt.Sprintf("port=%d\nusername=%s\npassword=%s", c.Port, c.Username, c.Password)
|
||||||
|
if c.TelegramToken != "" && c.TelegramChat != "" {
|
||||||
|
output += fmt.Sprintf("\ntg_token=%s\ntg_chat=%s", c.TelegramToken, c.TelegramChat)
|
||||||
|
}
|
||||||
|
|
||||||
f, err := os.OpenFile(ConfigFile, os.O_CREATE|os.O_WRONLY, 0644)
|
f, err := os.OpenFile(ConfigFile, os.O_CREATE|os.O_WRONLY, 0644)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -62,6 +68,10 @@ func (c *Config) Reload() error {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
c.Port = numVal
|
c.Port = numVal
|
||||||
}
|
}
|
||||||
|
} else if key == "tg_token" {
|
||||||
|
c.TelegramToken = value
|
||||||
|
} else if key == "tg_chat" {
|
||||||
|
c.TelegramChat = value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := scanner.Err(); err != nil {
|
if err := scanner.Err(); err != nil {
|
||||||
|
|
41
files.go
41
files.go
|
@ -3,9 +3,9 @@ package main
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"io"
|
"io"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
|
@ -14,6 +14,13 @@ import (
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// HandleWrite checks for error in ResponseWriter.Write output
|
||||||
|
func HandleWrite(_ int, err error) {
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("error writing response", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// GetFile returns raw contents of a txt file in data directory
|
// GetFile returns raw contents of a txt file in data directory
|
||||||
func GetFile(filename string, w http.ResponseWriter) {
|
func GetFile(filename string, w http.ResponseWriter) {
|
||||||
filename = "data/" + path.Clean(filename) + ".txt" // Does this sanitize the path?
|
filename = "data/" + path.Clean(filename) + ".txt" // Does this sanitize the path?
|
||||||
|
@ -25,6 +32,9 @@ func GetFile(filename string, w http.ResponseWriter) {
|
||||||
|
|
||||||
fileContents, err := os.ReadFile(filename)
|
fileContents, err := os.ReadFile(filename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
slog.Error("error reading file",
|
||||||
|
"error", err,
|
||||||
|
"file", filename)
|
||||||
http.Error(w, "error reading file", http.StatusInternalServerError)
|
http.Error(w, "error reading file", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -41,25 +51,30 @@ func PostFile(filename string, w http.ResponseWriter, r *http.Request) {
|
||||||
body, err := io.ReadAll(r.Body)
|
body, err := io.ReadAll(r.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
w.Write([]byte("error reading body"))
|
HandleWrite(w.Write([]byte("error reading body")))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
f, err := os.OpenFile("data/"+filename+".txt", os.O_CREATE|os.O_WRONLY, 0644)
|
filename = "data/" + filename + ".txt"
|
||||||
|
f, err := os.OpenFile(filename, os.O_CREATE|os.O_WRONLY, 0644)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println("error opening/making file")
|
slog.Error("error opening/making file",
|
||||||
w.Write([]byte("error opening or creating file"))
|
"error", err,
|
||||||
|
"file", filename)
|
||||||
|
HandleWrite(w.Write([]byte("error opening or creating file")))
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := f.Write(body); err != nil {
|
if _, err := f.Write(body); err != nil {
|
||||||
fmt.Println("error writing to the file")
|
slog.Error("error writing to file",
|
||||||
w.Write([]byte("error writing to file"))
|
"error", err,
|
||||||
|
"file", filename)
|
||||||
|
HandleWrite(w.Write([]byte("error writing to file")))
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Write([]byte("wrote to file"))
|
HandleWrite(w.Write([]byte("wrote to file")))
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -75,7 +90,7 @@ func ListFiles(directory string, w http.ResponseWriter) {
|
||||||
filenames[i] = file
|
filenames[i] = file
|
||||||
}
|
}
|
||||||
filenamesJson, err := json.Marshal(filenames)
|
filenamesJson, err := json.Marshal(filenames)
|
||||||
w.Write(filenamesJson)
|
HandleWrite(w.Write(filenamesJson))
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDay returns a day specified in URL
|
// GetDay returns a day specified in URL
|
||||||
|
@ -83,14 +98,14 @@ func GetDay(w http.ResponseWriter, r *http.Request) {
|
||||||
dayString := chi.URLParam(r, "day")
|
dayString := chi.URLParam(r, "day")
|
||||||
if dayString == "" {
|
if dayString == "" {
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
w.Write([]byte("day not specified"))
|
HandleWrite(w.Write([]byte("day not specified")))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
GetFile("day/"+dayString, w)
|
GetFile("day/"+dayString, w)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetToday runs GetFile with today's daily txt
|
// GetToday runs GetFile with today's daily txt
|
||||||
func GetToday(w http.ResponseWriter) {
|
func GetToday(w http.ResponseWriter, _ *http.Request) {
|
||||||
GetFile("day/"+time.Now().Format("2006-01-02"), w)
|
GetFile("day/"+time.Now().Format("2006-01-02"), w)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -104,7 +119,7 @@ func GetNote(w http.ResponseWriter, r *http.Request) {
|
||||||
noteString := chi.URLParam(r, "note")
|
noteString := chi.URLParam(r, "note")
|
||||||
if noteString == "" {
|
if noteString == "" {
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
w.Write([]byte("note not specified"))
|
HandleWrite(w.Write([]byte("note not specified")))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
GetFile("notes/"+noteString, w)
|
GetFile("notes/"+noteString, w)
|
||||||
|
@ -115,7 +130,7 @@ func PostNote(w http.ResponseWriter, r *http.Request) {
|
||||||
noteString := chi.URLParam(r, "note")
|
noteString := chi.URLParam(r, "note")
|
||||||
if noteString == "" {
|
if noteString == "" {
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
w.Write([]byte("note not specified"))
|
HandleWrite(w.Write([]byte("note not specified")))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
PostFile("notes/"+noteString, w, r)
|
PostFile("notes/"+noteString, w, r)
|
||||||
|
|
17
log.go
17
log.go
|
@ -1,8 +1,8 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
@ -12,20 +12,27 @@ import (
|
||||||
// AppendLog adds the input string to the end of the log file with a timestamp
|
// AppendLog adds the input string to the end of the log file with a timestamp
|
||||||
func appendLog(input string) error {
|
func appendLog(input string) error {
|
||||||
t := time.Now().Format("2006-01-02 15:04") // yyyy-mm-dd HH:MM
|
t := time.Now().Format("2006-01-02 15:04") // yyyy-mm-dd HH:MM
|
||||||
|
filename := "data/log.txt"
|
||||||
|
|
||||||
f, err := os.OpenFile("./data/log.txt", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
f, err := os.OpenFile(filename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println("Error opening/making file")
|
slog.Error("error opening/making file",
|
||||||
|
"error", err,
|
||||||
|
"file", filename)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
input = strings.Replace(input, "\n", "", -1) // Remove newlines to maintain structure
|
input = strings.Replace(input, "\n", "", -1) // Remove newlines to maintain structure
|
||||||
if _, err := f.Write([]byte(t + " | " + input + "\n")); err != nil {
|
if _, err := f.Write([]byte(t + " | " + input + "\n")); err != nil {
|
||||||
fmt.Println("Error appending to the file")
|
slog.Error("error appending to file",
|
||||||
|
"error", err,
|
||||||
|
"file", filename)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := f.Close(); err != nil {
|
if err := f.Close(); err != nil {
|
||||||
fmt.Println("Error closing the file")
|
slog.Error("error closing file",
|
||||||
|
"error", err,
|
||||||
|
"file", filename)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|
23
logger.go
Normal file
23
logger.go
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
var LogFile = "config/log.txt"
|
||||||
|
|
||||||
|
// LogInit makes slog output to both stdout and a file
|
||||||
|
func LogInit() {
|
||||||
|
f, err := os.OpenFile(LogFile, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("error opening log file: %v", err)
|
||||||
|
}
|
||||||
|
// No defer f.Close() because that breaks the MultiWriter
|
||||||
|
|
||||||
|
w := io.MultiWriter(f, os.Stdout)
|
||||||
|
|
||||||
|
slog.SetDefault(slog.New(slog.NewTextHandler(w, nil)))
|
||||||
|
}
|
1
main.go
1
main.go
|
@ -3,5 +3,6 @@ package main
|
||||||
var Cfg = ConfigInit()
|
var Cfg = ConfigInit()
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
LogInit()
|
||||||
Serve()
|
Serve()
|
||||||
}
|
}
|
||||||
|
|
16
serve.go
16
serve.go
|
@ -1,10 +1,10 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
"log"
|
"log"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
)
|
)
|
||||||
|
@ -29,17 +29,17 @@ func Serve() {
|
||||||
apiRouter.Get("/readme", func(w http.ResponseWriter, r *http.Request) { GetFile("readme", w) })
|
apiRouter.Get("/readme", func(w http.ResponseWriter, r *http.Request) { GetFile("readme", w) })
|
||||||
apiRouter.Post("/readme", func(w http.ResponseWriter, r *http.Request) { PostFile("readme", w, r) })
|
apiRouter.Post("/readme", func(w http.ResponseWriter, r *http.Request) { PostFile("readme", w, r) })
|
||||||
apiRouter.Get("/log", func(w http.ResponseWriter, r *http.Request) { GetFile("log", w) })
|
apiRouter.Get("/log", func(w http.ResponseWriter, r *http.Request) { GetFile("log", w) })
|
||||||
apiRouter.Post("/log", func(w http.ResponseWriter, r *http.Request) { PostLog(w, r) })
|
apiRouter.Post("/log", PostLog)
|
||||||
|
|
||||||
apiRouter.Get("/day", func(w http.ResponseWriter, r *http.Request) { ListFiles("day", w) })
|
apiRouter.Get("/day", func(w http.ResponseWriter, r *http.Request) { ListFiles("day", w) })
|
||||||
apiRouter.Get("/day/{day}", func(w http.ResponseWriter, r *http.Request) { GetDay(w, r) })
|
apiRouter.Get("/day/{day}", GetDay)
|
||||||
|
|
||||||
apiRouter.Get("/notes", func(w http.ResponseWriter, r *http.Request) { ListFiles("notes", w) })
|
apiRouter.Get("/notes", func(w http.ResponseWriter, r *http.Request) { ListFiles("notes", w) })
|
||||||
apiRouter.Get("/notes/{note}", func(w http.ResponseWriter, r *http.Request) { GetNote(w, r) })
|
apiRouter.Get("/notes/{note}", GetNote)
|
||||||
apiRouter.Post("/notes/{note}", func(w http.ResponseWriter, r *http.Request) { PostNote(w, r) })
|
apiRouter.Post("/notes/{note}", PostNote)
|
||||||
|
|
||||||
apiRouter.Get("/today", func(w http.ResponseWriter, r *http.Request) { GetToday(w) })
|
apiRouter.Get("/today", GetToday)
|
||||||
apiRouter.Post("/today", func(w http.ResponseWriter, r *http.Request) { PostToday(w, r) })
|
apiRouter.Post("/today", PostToday)
|
||||||
|
|
||||||
r.Mount("/api", apiRouter)
|
r.Mount("/api", apiRouter)
|
||||||
|
|
||||||
|
@ -47,6 +47,6 @@ func Serve() {
|
||||||
fs := http.FileServer(http.Dir("public"))
|
fs := http.FileServer(http.Dir("public"))
|
||||||
r.Handle("/public/*", http.StripPrefix("/public/", fs))
|
r.Handle("/public/*", http.StripPrefix("/public/", fs))
|
||||||
|
|
||||||
fmt.Println("Website working on port: ", Cfg.Port)
|
slog.Info("🌺 Website working", "port", Cfg.Port)
|
||||||
log.Fatal(http.ListenAndServe(":"+strconv.Itoa(Cfg.Port), r))
|
log.Fatal(http.ListenAndServe(":"+strconv.Itoa(Cfg.Port), r))
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue