diff --git a/config.go b/config.go
new file mode 100644
index 0000000..e2c5a95
--- /dev/null
+++ b/config.go
@@ -0,0 +1,112 @@
+// gomuks - A Matrix client written in Go.
+// Copyright (C) 2024 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package main
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "github.com/rs/zerolog"
+ "go.mau.fi/util/ptr"
+ "go.mau.fi/util/random"
+ "go.mau.fi/zeroconfig"
+ "golang.org/x/crypto/bcrypt"
+ "gopkg.in/yaml.v3"
+)
+
+type Config struct {
+ Web WebConfig `yaml:"web"`
+ Logging zeroconfig.Config `yaml:"logging"`
+}
+
+type WebConfig struct {
+ ListenAddress string `yaml:"listen_address"`
+ Username string `yaml:"username"`
+ PasswordHash string `yaml:"password_hash"`
+ TokenKey string `yaml:"token_key"`
+}
+
+var defaultConfig = Config{
+ Web: WebConfig{
+ ListenAddress: "localhost:29325",
+ },
+ Logging: zeroconfig.Config{
+ MinLevel: ptr.Ptr(zerolog.TraceLevel),
+ Writers: []zeroconfig.WriterConfig{{
+ Type: zeroconfig.WriterTypeStdout,
+ Format: zeroconfig.LogFormatPrettyColored,
+ }},
+ },
+}
+
+func (gmx *Gomuks) LoadConfig() error {
+ file, err := os.Open(filepath.Join(gmx.ConfigDir, "config.yaml"))
+ if err != nil && !errors.Is(err, os.ErrNotExist) {
+ return err
+ }
+ gmx.Config = defaultConfig
+ changed := false
+ if file != nil {
+ err = yaml.NewDecoder(file).Decode(&gmx.Config)
+ if err != nil {
+ return err
+ }
+ } else {
+ changed = true
+ }
+ if gmx.Config.Web.TokenKey == "" {
+ gmx.Config.Web.TokenKey = random.String(64)
+ changed = true
+ }
+ if gmx.Config.Web.Username == "" || gmx.Config.Web.PasswordHash == "" {
+ fmt.Println("Please create a username and password for authenticating the web app")
+ fmt.Print("Username: ")
+ _, err = fmt.Scanln(&gmx.Config.Web.Username)
+ if err != nil {
+ return fmt.Errorf("failed to read username: %w", err)
+ }
+ fmt.Print("Password: ")
+ var passwd string
+ _, err = fmt.Scanln(&passwd)
+ if err != nil {
+ return fmt.Errorf("failed to read password: %w", err)
+ }
+ hash, err := bcrypt.GenerateFromPassword([]byte(passwd), 12)
+ if err != nil {
+ return fmt.Errorf("failed to hash password: %w", err)
+ }
+ gmx.Config.Web.PasswordHash = string(hash)
+ changed = true
+ }
+ if changed {
+ err = gmx.SaveConfig()
+ if err != nil {
+ return fmt.Errorf("failed to save config: %w", err)
+ }
+ }
+ return nil
+}
+
+func (gmx *Gomuks) SaveConfig() error {
+ file, err := os.OpenFile(filepath.Join(gmx.ConfigDir, "config.yaml"), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
+ if err != nil {
+ return err
+ }
+ return yaml.NewEncoder(file).Encode(&gmx.Config)
+}
diff --git a/go.mod b/go.mod
index 561cf57..c7db0d7 100644
--- a/go.mod
+++ b/go.mod
@@ -8,8 +8,10 @@ require (
github.com/coder/websocket v1.8.12
github.com/mattn/go-sqlite3 v1.14.23
github.com/rs/zerolog v1.33.0
- go.mau.fi/util v0.8.1-0.20241007163534-d608488d5cff
+ go.mau.fi/util v0.8.1-0.20241011175353-d86e4c6b8fa7
go.mau.fi/zeroconfig v0.1.3
+ golang.org/x/crypto v0.27.0
+ gopkg.in/yaml.v3 v3.0.1
maunium.net/go/mauflag v1.0.0
maunium.net/go/mautrix v0.21.1-0.20241010172818-50f4a2eec193
)
@@ -25,7 +27,6 @@ require (
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
- golang.org/x/crypto v0.27.0 // indirect
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect
golang.org/x/net v0.29.0 // indirect
golang.org/x/sys v0.25.0 // indirect
diff --git a/go.sum b/go.sum
index 8a06294..e7d846e 100644
--- a/go.sum
+++ b/go.sum
@@ -37,8 +37,8 @@ github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
-go.mau.fi/util v0.8.1-0.20241007163534-d608488d5cff h1:oQJHc5pdU9Xc6mhvfxSdWXoTreFYvlkM7g65MJWhR40=
-go.mau.fi/util v0.8.1-0.20241007163534-d608488d5cff/go.mod h1:L9qnqEkhe4KpuYmILrdttKTXL79MwGLyJ4EOskWxO3I=
+go.mau.fi/util v0.8.1-0.20241011175353-d86e4c6b8fa7 h1:BmQx/qeiR84yGDCir8QL5BtW8XLV7NNDjb44SWnPOOw=
+go.mau.fi/util v0.8.1-0.20241011175353-d86e4c6b8fa7/go.mod h1:L9qnqEkhe4KpuYmILrdttKTXL79MwGLyJ4EOskWxO3I=
go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM=
go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70=
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
@@ -52,6 +52,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
diff --git a/gomuks.go b/gomuks.go
index 7ca4a7e..4d58cf7 100644
--- a/gomuks.go
+++ b/gomuks.go
@@ -18,9 +18,7 @@ package main
import (
"context"
- "errors"
"fmt"
- "io/fs"
"maps"
"net/http"
"os"
@@ -33,17 +31,10 @@ import (
"github.com/coder/websocket"
"github.com/rs/zerolog"
- "github.com/rs/zerolog/hlog"
"go.mau.fi/util/dbutil"
"go.mau.fi/util/exerrors"
- "go.mau.fi/util/exhttp"
"go.mau.fi/util/exzerolog"
- "go.mau.fi/util/ptr"
- "go.mau.fi/util/requestlog"
- "go.mau.fi/zeroconfig"
"maunium.net/go/mautrix/hicli"
-
- "go.mau.fi/gomuks/web"
)
type Gomuks struct {
@@ -57,6 +48,8 @@ type Gomuks struct {
TempDir string
LogDir string
+ Config Config
+
stopOnce sync.Once
stopChan chan struct{}
@@ -74,7 +67,7 @@ func NewGomuks() *Gomuks {
}
}
-func (gmx *Gomuks) LoadConfig() {
+func (gmx *Gomuks) InitDirectories() {
// We need 4 directories: config, data, cache, logs
//
// 1. If GOMUKS_ROOT is set, all directories are created under that.
@@ -146,46 +139,10 @@ func (gmx *Gomuks) LoadConfig() {
}
func (gmx *Gomuks) SetupLog() {
- gmx.Log = exerrors.Must((&zeroconfig.Config{
- MinLevel: ptr.Ptr(zerolog.TraceLevel),
- Writers: []zeroconfig.WriterConfig{{
- Type: zeroconfig.WriterTypeStdout,
- Format: zeroconfig.LogFormatPrettyColored,
- }},
- }).Compile())
+ gmx.Log = exerrors.Must(gmx.Config.Logging.Compile())
exzerolog.SetupDefaults(gmx.Log)
}
-func (gmx *Gomuks) StartServer(addr string) {
- api := http.NewServeMux()
- api.HandleFunc("GET /websocket", gmx.HandleWebsocket)
- api.HandleFunc("GET /media/{server}/{media_id}", gmx.DownloadMedia)
- apiHandler := exhttp.ApplyMiddleware(
- http.StripPrefix("/_gomuks", api),
- hlog.NewHandler(*gmx.Log),
- hlog.RequestIDHandler("request_id", "Request-ID"),
- requestlog.AccessLogger(false),
- )
- router := http.NewServeMux()
- router.Handle("/_gomuks/", apiHandler)
- if frontend, err := fs.Sub(web.Frontend, "dist"); err != nil {
- gmx.Log.Warn().Msg("Frontend not found")
- } else {
- router.Handle("/", http.FileServerFS(frontend))
- }
- gmx.Server = &http.Server{
- Addr: addr,
- Handler: router,
- }
- go func() {
- err := gmx.Server.ListenAndServe()
- if err != nil && !errors.Is(err, http.ErrServerClosed) {
- panic(err)
- }
- }()
- gmx.Log.Info().Str("address", addr).Msg("Server started")
-}
-
func (gmx *Gomuks) StartClient() {
rawDB, err := dbutil.NewFromConfig("gomuks", dbutil.Config{
PoolConfig: dbutil.PoolConfig{
diff --git a/main.go b/main.go
index 3f88180..9706301 100644
--- a/main.go
+++ b/main.go
@@ -48,7 +48,6 @@ var (
var wantHelp, _ = flag.MakeHelpFlag()
var version = flag.MakeFull("v", "version", "View gomuks version and quit.", "false").Bool()
-var listenAddress = flag.MakeFull("l", "listen", "Address to listen on.", "localhost:29325").String()
func main() {
hicli.InitialDeviceDisplayName = "gomuks web"
@@ -72,14 +71,19 @@ func main() {
}
gmx := NewGomuks()
- gmx.LoadConfig()
+ gmx.InitDirectories()
+ err = gmx.LoadConfig()
+ if err != nil {
+ _, _ = fmt.Fprintln(os.Stderr, "Failed to load config:", err)
+ os.Exit(9)
+ }
gmx.SetupLog()
gmx.Log.Info().
Str("version", Version).
Str("go_version", runtime.Version()).
Time("built_at", ParsedBuildTime).
Msg("Initializing gomuks")
- gmx.StartServer(*listenAddress)
+ gmx.StartServer()
gmx.StartClient()
gmx.Log.Info().Msg("Initialization complete")
gmx.WaitForInterrupt()
diff --git a/server.go b/server.go
new file mode 100644
index 0000000..d9f6c1a
--- /dev/null
+++ b/server.go
@@ -0,0 +1,181 @@
+// gomuks - A Matrix client written in Go.
+// Copyright (C) 2024 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package main
+
+import (
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "io/fs"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/rs/zerolog/hlog"
+ "go.mau.fi/util/exerrors"
+ "go.mau.fi/util/exhttp"
+ "go.mau.fi/util/jsontime"
+ "go.mau.fi/util/requestlog"
+ "golang.org/x/crypto/bcrypt"
+ "maunium.net/go/mautrix"
+
+ "go.mau.fi/gomuks/web"
+)
+
+func (gmx *Gomuks) StartServer() {
+ api := http.NewServeMux()
+ api.HandleFunc("GET /websocket", gmx.HandleWebsocket)
+ api.HandleFunc("POST /auth", gmx.Authenticate)
+ api.HandleFunc("GET /media/{server}/{media_id}", gmx.DownloadMedia)
+ apiHandler := exhttp.ApplyMiddleware(
+ api,
+ hlog.NewHandler(*gmx.Log),
+ hlog.RequestIDHandler("request_id", "Request-ID"),
+ requestlog.AccessLogger(false),
+ exhttp.StripPrefix("/_gomuks"),
+ gmx.AuthMiddleware,
+ )
+ router := http.NewServeMux()
+ router.Handle("/_gomuks/", apiHandler)
+ if frontend, err := fs.Sub(web.Frontend, "dist"); err != nil {
+ gmx.Log.Warn().Msg("Frontend not found")
+ } else {
+ router.Handle("/", http.FileServerFS(frontend))
+ }
+ gmx.Server = &http.Server{
+ Addr: gmx.Config.Web.ListenAddress,
+ Handler: router,
+ }
+ go func() {
+ err := gmx.Server.ListenAndServe()
+ if err != nil && !errors.Is(err, http.ErrServerClosed) {
+ panic(err)
+ }
+ }()
+ gmx.Log.Info().Str("address", gmx.Config.Web.ListenAddress).Msg("Server started")
+}
+
+var (
+ ErrInvalidHeader = mautrix.RespError{ErrCode: "FI.MAU.GOMUKS.INVALID_HEADER", StatusCode: http.StatusBadRequest}
+ ErrMissingCookie = mautrix.RespError{ErrCode: "FI.MAU.GOMUKS.MISSING_COOKIE", Err: "Missing gomuks_auth cookie", StatusCode: http.StatusUnauthorized}
+ ErrInvalidCookie = mautrix.RespError{ErrCode: "FI.MAU.GOMUKS.INVALID_COOKIE", Err: "Invalid gomuks_auth cookie", StatusCode: http.StatusUnauthorized}
+)
+
+type tokenData struct {
+ Username string `json:"username"`
+ Expiry jsontime.Unix `json:"expiry"`
+}
+
+func (gmx *Gomuks) validateAuth(token string) bool {
+ if len(token) > 100 {
+ return false
+ }
+ parts := strings.Split(token, ".")
+ if len(parts) != 2 {
+ return false
+ }
+ rawJSON, err := base64.RawURLEncoding.DecodeString(parts[0])
+ if err != nil {
+ return false
+ }
+ checksum, err := base64.RawURLEncoding.DecodeString(parts[1])
+ if err != nil {
+ return false
+ }
+ hasher := hmac.New(sha256.New, []byte(gmx.Config.Web.TokenKey))
+ hasher.Write(rawJSON)
+ if !hmac.Equal(hasher.Sum(nil), checksum) {
+ return false
+ }
+
+ var td tokenData
+ err = json.Unmarshal(rawJSON, &td)
+ return err == nil && td.Username == gmx.Config.Web.Username && td.Expiry.After(time.Now())
+}
+
+func (gmx *Gomuks) generateToken() (string, time.Time) {
+ expiry := time.Now().Add(7 * 24 * time.Hour)
+ data := exerrors.Must(json.Marshal(tokenData{
+ Username: gmx.Config.Web.Username,
+ Expiry: jsontime.U(expiry),
+ }))
+ hasher := hmac.New(sha256.New, []byte(gmx.Config.Web.TokenKey))
+ hasher.Write(data)
+ checksum := hasher.Sum(nil)
+ return base64.RawURLEncoding.EncodeToString(data) + "." + base64.RawURLEncoding.EncodeToString(checksum), expiry
+}
+
+func (gmx *Gomuks) writeTokenCookie(w http.ResponseWriter) {
+ token, expiry := gmx.generateToken()
+ http.SetCookie(w, &http.Cookie{
+ Name: "gomuks_auth",
+ Value: token,
+ Expires: expiry,
+ HttpOnly: true,
+ })
+}
+
+func (gmx *Gomuks) Authenticate(w http.ResponseWriter, r *http.Request) {
+ authCookie, err := r.Cookie("gomuks_auth")
+ if err == nil && gmx.validateAuth(authCookie.Value) {
+ gmx.writeTokenCookie(w)
+ w.WriteHeader(http.StatusOK)
+ } else if username, password, ok := r.BasicAuth(); !ok {
+ w.Header().Set("WWW-Authenticate", `Basic realm="gomuks web" charset="UTF-8"`)
+ w.WriteHeader(http.StatusUnauthorized)
+ } else {
+ usernameHash := sha256.Sum256([]byte(username))
+ expectedUsernameHash := sha256.Sum256([]byte(gmx.Config.Web.Username))
+ usernameCorrect := hmac.Equal(usernameHash[:], expectedUsernameHash[:])
+ passwordCorrect := bcrypt.CompareHashAndPassword([]byte(gmx.Config.Web.PasswordHash), []byte(password)) == nil
+ if usernameCorrect && passwordCorrect {
+ gmx.writeTokenCookie(w)
+ w.WriteHeader(http.StatusCreated)
+ } else {
+ w.WriteHeader(http.StatusForbidden)
+ }
+ }
+}
+
+func isUserFetch(header http.Header) bool {
+ return header.Get("Sec-Fetch-Site") == "none" &&
+ header.Get("Sec-Fetch-Mode") == "navigate" &&
+ header.Get("Sec-Fetch-Dest") == "document" &&
+ header.Get("Sec-Fetch-User") == "?1"
+}
+
+func (gmx *Gomuks) AuthMiddleware(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("Sec-Fetch-Site") != "same-origin" && !isUserFetch(r.Header) {
+ ErrInvalidHeader.WithMessage("Invalid Sec-Fetch-Site header").Write(w)
+ return
+ }
+ if r.URL.Path != "/auth" {
+ authCookie, err := r.Cookie("gomuks_auth")
+ if err != nil {
+ ErrMissingCookie.Write(w)
+ return
+ } else if !gmx.validateAuth(authCookie.Value) {
+ ErrInvalidCookie.Write(w)
+ return
+ }
+ }
+ next.ServeHTTP(w, r)
+ })
+}
diff --git a/web/src/main.tsx b/web/src/main.tsx
index 173a9d9..2fbee8a 100644
--- a/web/src/main.tsx
+++ b/web/src/main.tsx
@@ -18,8 +18,16 @@ import { createRoot } from "react-dom/client"
import App from "./App.tsx"
import "./index.css"
-createRoot(document.getElementById("root")!).render(
-
-
- ,
-)
+fetch("/_gomuks/auth", { method: "POST" }).then(resp => {
+ if (resp.ok) {
+ createRoot(document.getElementById("root")!).render(
+
+
+ ,
+ )
+ } else {
+ window.alert("Authentication failed: " + resp.statusText)
+ }
+}, err => {
+ window.alert("Authentication failed: " + err)
+})
diff --git a/websocket.go b/websocket.go
index bc5037b..f768f57 100644
--- a/websocket.go
+++ b/websocket.go
@@ -27,7 +27,6 @@ import (
"github.com/coder/websocket"
"github.com/rs/zerolog"
-
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/hicli"
"maunium.net/go/mautrix/hicli/database"
@@ -49,6 +48,10 @@ func writeCmd(ctx context.Context, conn *websocket.Conn, cmd *hicli.JSONCommand)
const StatusEventsStuck = 4001
func (gmx *Gomuks) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("Sec-Fetch-Mode") != "websocket" {
+ ErrInvalidHeader.WithMessage("Invalid Sec-Fetch-Dest header").Write(w)
+ return
+ }
var conn *websocket.Conn
log := zerolog.Ctx(r.Context())
recoverPanic := func(context string) bool {