Add ALIAS_ALLOWED_DOMAINS env var (comma-separated) that restricts which domains users may create aliases under. Alias creation is disabled when the env var is not set. Prevents users from creating aliases with arbitrary domains (e.g. for phishing/spoofing). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
242 lines
6.2 KiB
Go
242 lines
6.2 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"io/ioutil"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/signal"
|
|
"path"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
)
|
|
|
|
var myPublicURL = "https://ldap.nemunai.re"
|
|
|
|
// dockerRegistrySecret is required for X-Special-Auth anonymous access.
|
|
// If empty, the feature is disabled.
|
|
var dockerRegistrySecret string
|
|
|
|
// allowedAliasDomains is the allowlist of domains users may create aliases under.
|
|
// If empty, alias creation is disabled.
|
|
var allowedAliasDomains []string
|
|
|
|
var myLDAP = LDAP{
|
|
Host: "localhost",
|
|
Port: 389,
|
|
BaseDN: "dc=example,dc=com",
|
|
MailPort: 587,
|
|
MailFrom: "noreply@nemunai.re",
|
|
}
|
|
|
|
type ResponseWriterPrefix struct {
|
|
real http.ResponseWriter
|
|
prefix string
|
|
}
|
|
|
|
func (r ResponseWriterPrefix) Header() http.Header {
|
|
return r.real.Header()
|
|
}
|
|
|
|
func (r ResponseWriterPrefix) WriteHeader(s int) {
|
|
if v, exists := r.real.Header()["Location"]; exists {
|
|
r.real.Header().Set("Location", r.prefix+v[0])
|
|
}
|
|
r.real.WriteHeader(s)
|
|
}
|
|
|
|
func (r ResponseWriterPrefix) Write(z []byte) (int, error) {
|
|
return r.real.Write(z)
|
|
}
|
|
|
|
func StripPrefix(prefix string, h http.Handler) http.Handler {
|
|
if prefix == "" {
|
|
return h
|
|
}
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if prefix != "/" && r.URL.Path == "/" {
|
|
http.Redirect(w, r, prefix+"/", http.StatusFound)
|
|
} else if p := strings.TrimPrefix(r.URL.Path, prefix); len(p) < len(r.URL.Path) {
|
|
r2 := new(http.Request)
|
|
*r2 = *r
|
|
r2.URL = new(url.URL)
|
|
*r2.URL = *r.URL
|
|
r2.URL.Path = p
|
|
h.ServeHTTP(ResponseWriterPrefix{w, prefix}, r2)
|
|
} else {
|
|
h.ServeHTTP(w, r)
|
|
}
|
|
})
|
|
}
|
|
|
|
func main() {
|
|
var bind = flag.String("bind", "127.0.0.1:8080", "Bind port/socket")
|
|
var baseURL = flag.String("baseurl", "/", "URL prepended to each URL")
|
|
var configfile = flag.String("config", "", "path to the configuration file")
|
|
var publicURL = flag.String("public-url", myPublicURL, "Public base URL used in password reset emails")
|
|
flag.Parse()
|
|
|
|
myPublicURL = *publicURL
|
|
|
|
// Sanitize options
|
|
log.Println("Checking paths...")
|
|
if *baseURL != "/" {
|
|
tmp := path.Clean(*baseURL)
|
|
baseURL = &tmp
|
|
} else {
|
|
tmp := ""
|
|
baseURL = &tmp
|
|
}
|
|
|
|
// Load config file
|
|
if configfile != nil && *configfile != "" {
|
|
if fd, err := os.Open(*configfile); err != nil {
|
|
log.Fatal(err)
|
|
} else if cnt, err := ioutil.ReadAll(fd); err != nil {
|
|
log.Fatal(err)
|
|
} else if err := json.Unmarshal(cnt, &myLDAP); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// Read configuration from environment
|
|
if val, ok := os.LookupEnv("LDAP_HOST"); ok {
|
|
myLDAP.Host = val
|
|
}
|
|
if val, ok := os.LookupEnv("LDAP_PORT"); ok {
|
|
if port, err := strconv.Atoi(val); err == nil {
|
|
myLDAP.Port = port
|
|
} else {
|
|
log.Println("Invalid value for LDAP_PORT:", val)
|
|
}
|
|
}
|
|
if val, ok := os.LookupEnv("LDAP_STARTTLS"); ok {
|
|
myLDAP.Starttls = val == "1" || val == "on" || val == "true"
|
|
}
|
|
if val, ok := os.LookupEnv("LDAP_SSL"); ok {
|
|
myLDAP.Ssl = val == "1" || val == "on" || val == "true"
|
|
}
|
|
if val, ok := os.LookupEnv("LDAP_BASEDN"); ok {
|
|
myLDAP.BaseDN = val
|
|
}
|
|
if val, ok := os.LookupEnv("LDAP_SERVICEDN"); ok {
|
|
myLDAP.ServiceDN = val
|
|
}
|
|
if val, ok := os.LookupEnv("LDAP_SERVICE_PASSWORD_FILE"); ok {
|
|
if fd, err := os.Open(val); err != nil {
|
|
log.Fatal(err)
|
|
} else if cnt, err := ioutil.ReadAll(fd); err != nil {
|
|
log.Fatal(err)
|
|
} else {
|
|
myLDAP.ServicePassword = string(cnt)
|
|
}
|
|
} else if val, ok := os.LookupEnv("LDAP_SERVICE_PASSWORD"); ok {
|
|
myLDAP.ServicePassword = val
|
|
}
|
|
|
|
if val, ok := os.LookupEnv("SMTP_HOST"); ok {
|
|
myLDAP.MailHost = val
|
|
}
|
|
if val, ok := os.LookupEnv("SMTP_PORT"); ok {
|
|
if port, err := strconv.Atoi(val); err == nil {
|
|
myLDAP.MailPort = port
|
|
} else {
|
|
log.Println("Invalid value for SMTP_PORT:", val)
|
|
}
|
|
}
|
|
if val, ok := os.LookupEnv("SMTP_USER"); ok {
|
|
myLDAP.MailUser = val
|
|
}
|
|
if val, ok := os.LookupEnv("SMTP_PASSWORD_FILE"); ok {
|
|
if fd, err := os.Open(val); err != nil {
|
|
log.Fatal(err)
|
|
} else if cnt, err := os.ReadFile(val); err != nil {
|
|
fd.Close()
|
|
log.Fatal(err)
|
|
} else {
|
|
fd.Close()
|
|
myLDAP.MailPassword = string(cnt)
|
|
}
|
|
} else if val, ok := os.LookupEnv("SMTP_PASSWORD"); ok {
|
|
myLDAP.MailPassword = val
|
|
}
|
|
if val, ok := os.LookupEnv("SMTP_FROM"); ok {
|
|
myLDAP.MailFrom = val
|
|
}
|
|
if val, ok := os.LookupEnv("PUBLIC_URL"); ok {
|
|
myPublicURL = val
|
|
}
|
|
if val, ok := os.LookupEnv("DOCKER_REGISTRY_SECRET"); ok {
|
|
dockerRegistrySecret = val
|
|
}
|
|
if val, ok := os.LookupEnv("ALIAS_ALLOWED_DOMAINS"); ok && val != "" {
|
|
allowedAliasDomains = strings.Split(val, ",")
|
|
}
|
|
|
|
if flag.NArg() > 0 {
|
|
switch flag.Arg(0) {
|
|
case "generate-lost-password-link":
|
|
if flag.NArg() != 2 {
|
|
log.Fatal("Need a second argument: email of the user to reset")
|
|
}
|
|
|
|
login := flag.Arg(1)
|
|
|
|
conn, err := myLDAP.Connect()
|
|
if err != nil || conn == nil {
|
|
log.Fatalf("Unable to connect to LDAP: %s", err.Error())
|
|
}
|
|
|
|
token, dn, err := lostPasswordToken(conn, login)
|
|
if err != nil {
|
|
log.Fatal(err.Error())
|
|
}
|
|
|
|
fmt.Printf("Reset link for %s: %s/reset?l=%s&t=%s", dn, myPublicURL, login, token)
|
|
return
|
|
case "serve":
|
|
case "server":
|
|
break
|
|
default:
|
|
log.Fatalf("%q is not a valid command", flag.Arg(0))
|
|
}
|
|
}
|
|
|
|
// Prepare graceful shutdown
|
|
interrupt := make(chan os.Signal, 1)
|
|
signal.Notify(interrupt, os.Interrupt, syscall.SIGTERM)
|
|
|
|
// Register handlers
|
|
http.HandleFunc(fmt.Sprintf("%s/{$}", *baseURL), changePassword)
|
|
http.HandleFunc(fmt.Sprintf("POST %s/api/v1/aliases", *baseURL), addyAliasAPI)
|
|
http.HandleFunc(fmt.Sprintf("DELETE %s/api/v1/aliases/{alias}", *baseURL), addyAliasAPIDelete)
|
|
http.HandleFunc(fmt.Sprintf("%s/auth", *baseURL), httpBasicAuth)
|
|
http.HandleFunc(fmt.Sprintf("%s/login", *baseURL), tryLogin)
|
|
http.HandleFunc(fmt.Sprintf("%s/change", *baseURL), changePassword)
|
|
http.HandleFunc(fmt.Sprintf("%s/reset", *baseURL), resetPassword)
|
|
http.HandleFunc(fmt.Sprintf("%s/lost", *baseURL), lostPassword)
|
|
|
|
srv := &http.Server{
|
|
Addr: *bind,
|
|
Handler: securityHeaders(http.DefaultServeMux),
|
|
}
|
|
|
|
// Serve content
|
|
go func() {
|
|
log.Fatal(srv.ListenAndServe())
|
|
}()
|
|
log.Println(fmt.Sprintf("Ready, listening on %s", *bind))
|
|
|
|
// Wait shutdown signal
|
|
<-interrupt
|
|
|
|
log.Print("The service is shutting down...")
|
|
srv.Shutdown(context.Background())
|
|
log.Println("done")
|
|
}
|