Files
ric930-fake-smtp/web_server.go
T

2626 lines
34 KiB
Go

package main
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"html/template"
"mime"
"mime/quotedprintable"
"net"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/kardianos/service"
)
const sessionCookieName = "ric930_session"
var errInvalidEmailID = errors.New(
"invalid email id",
)
type EmailFile struct {
Name string `json:"name"`
ID string `json:"id"`
Size int64 `json:"size"`
ModTime time.Time `json:"mod_time"`
From string `json:"from"`
To string `json:"to"`
Subject string `json:"subject"`
Date time.Time `json:"date"`
}
type DayEmails struct {
Date string `json:"date"`
Emails []EmailFile `json:"emails"`
}
type cachedStaticFile struct {
Data []byte
ContentType string
}
type webSession struct {
LastActivity time.Time
CSRFToken string
}
type sessionStore struct {
mu sync.Mutex
sessions map[string]*webSession
timeout time.Duration
}
type loginAttempt struct {
Failures int
WindowStart time.Time
BlockedUntil time.Time
LastSeen time.Time
}
type loginRateLimiter struct {
mu sync.Mutex
attempts map[string]*loginAttempt
maxAttempts int
window time.Duration
lockout time.Duration
}
type WebServer struct {
config *Config
smtpServer *SMTPServer
logger service.Logger
server *http.Server
template *template.Template
templateErr error
loginTemplate *template.Template
loginTemplateErr error
staticFiles map[string]cachedStaticFile
sessions *sessionStore
loginLimiter *loginRateLimiter
}
func newSessionStore(
timeout time.Duration,
) *sessionStore {
return &sessionStore{
sessions: make(
map[string]*webSession,
),
timeout: timeout,
}
}
func generateSecureToken() (
string,
error,
) {
data := make([]byte, 32)
if _, err := rand.Read(data); err != nil {
return "", fmt.Errorf(
"failed to generate secure token: %w",
err,
)
}
return base64.RawURLEncoding.
EncodeToString(data), nil
}
func (s *sessionStore) create() (
string,
error,
) {
sessionID, err := generateSecureToken()
if err != nil {
return "", err
}
csrfToken, err := generateSecureToken()
if err != nil {
return "", err
}
now := time.Now()
s.mu.Lock()
defer s.mu.Unlock()
for id, session := range s.sessions {
if now.Sub(session.LastActivity) >
s.timeout {
delete(
s.sessions,
id,
)
}
}
s.sessions[sessionID] = &webSession{
LastActivity: now,
CSRFToken: csrfToken,
}
return sessionID, nil
}
func (s *sessionStore) validate(
sessionID string,
) bool {
if sessionID == "" {
return false
}
now := time.Now()
s.mu.Lock()
defer s.mu.Unlock()
session, ok := s.sessions[sessionID]
if !ok {
return false
}
if now.Sub(session.LastActivity) >
s.timeout {
delete(
s.sessions,
sessionID,
)
return false
}
// Sliding inactivity timeout.
session.LastActivity = now
return true
}
func (s *sessionStore) csrfToken(
sessionID string,
) (string, bool) {
if sessionID == "" {
return "", false
}
s.mu.Lock()
defer s.mu.Unlock()
session, ok := s.sessions[sessionID]
if !ok {
return "", false
}
return session.CSRFToken, true
}
func (s *sessionStore) validateCSRF(
sessionID string,
token string,
) bool {
if sessionID == "" ||
token == "" {
return false
}
s.mu.Lock()
defer s.mu.Unlock()
session, ok := s.sessions[sessionID]
if !ok {
return false
}
return constantTimeStringEqual(
session.CSRFToken,
token,
)
}
func (s *sessionStore) delete(
sessionID string,
) {
if sessionID == "" {
return
}
s.mu.Lock()
defer s.mu.Unlock()
delete(
s.sessions,
sessionID,
)
}
func newLoginRateLimiter(
maxAttempts int,
window time.Duration,
lockout time.Duration,
) *loginRateLimiter {
return &loginRateLimiter{
attempts: make(
map[string]*loginAttempt,
),
maxAttempts: maxAttempts,
window: window,
lockout: lockout,
}
}
func (l *loginRateLimiter) allow(
key string,
) (bool, time.Duration) {
now := time.Now()
l.mu.Lock()
defer l.mu.Unlock()
for id, attempt := range l.attempts {
if attempt.BlockedUntil.After(now) {
continue
}
if now.Sub(attempt.LastSeen) >
l.window+l.lockout {
delete(
l.attempts,
id,
)
}
}
attempt, ok := l.attempts[key]
if !ok {
return true, 0
}
attempt.LastSeen = now
if attempt.BlockedUntil.After(now) {
return false,
time.Until(
attempt.BlockedUntil,
)
}
if now.Sub(attempt.WindowStart) >
l.window {
attempt.Failures = 0
attempt.WindowStart = now
attempt.BlockedUntil = time.Time{}
}
return true, 0
}
func (l *loginRateLimiter) failure(
key string,
) {
now := time.Now()
l.mu.Lock()
defer l.mu.Unlock()
attempt, ok := l.attempts[key]
if !ok {
attempt = &loginAttempt{
WindowStart: now,
}
l.attempts[key] = attempt
}
if now.Sub(attempt.WindowStart) >
l.window {
attempt.Failures = 0
attempt.WindowStart = now
attempt.BlockedUntil = time.Time{}
}
attempt.Failures++
attempt.LastSeen = now
if attempt.Failures >=
l.maxAttempts {
attempt.BlockedUntil =
now.Add(l.lockout)
}
}
func (l *loginRateLimiter) success(
key string,
) {
l.mu.Lock()
defer l.mu.Unlock()
delete(
l.attempts,
key,
)
}
func remoteIP(
remoteAddr string,
) string {
host, _, err := net.SplitHostPort(
remoteAddr,
)
if err == nil {
return host
}
return remoteAddr
}
func NewWebServer(
cfg *Config,
smtp *SMTPServer,
) *WebServer {
sessionTimeout :=
30 * time.Minute
if cfg.Web.Auth.SessionTimeout != "" {
if parsed, err :=
time.ParseDuration(
cfg.Web.Auth.SessionTimeout,
); err == nil &&
parsed > 0 {
sessionTimeout = parsed
}
}
loginWindow :=
5 * time.Minute
if cfg.Web.Auth.LoginWindow != "" {
if parsed, err :=
time.ParseDuration(
cfg.Web.Auth.LoginWindow,
); err == nil &&
parsed > 0 {
loginWindow = parsed
}
}
loginLockout :=
15 * time.Minute
if cfg.Web.Auth.LoginLockout != "" {
if parsed, err :=
time.ParseDuration(
cfg.Web.Auth.LoginLockout,
); err == nil &&
parsed > 0 {
loginLockout = parsed
}
}
maxAttempts :=
cfg.Web.Auth.LoginMaxAttempts
if maxAttempts <= 0 {
maxAttempts = 5
}
return &WebServer{
config: cfg,
smtpServer: smtp,
staticFiles: make(
map[string]cachedStaticFile,
),
sessions: newSessionStore(
sessionTimeout,
),
loginLimiter: newLoginRateLimiter(
maxAttempts,
loginWindow,
loginLockout,
),
}
}
func decodeRFC2047(
encoded string,
) string {
decoder := new(
mime.WordDecoder,
)
decoded, err :=
decoder.Decode(encoded)
if err == nil {
return decoded
}
if strings.Contains(
encoded,
"=?UTF-8?B?",
) {
parts := strings.Split(
encoded,
"?B?",
)
if len(parts) >= 2 {
b64part := strings.TrimSuffix(
parts[1],
"?=",
)
if decodedBytes, err :=
base64.StdEncoding.
DecodeString(
b64part,
); err == nil {
return string(
decodedBytes,
)
}
}
}
return encoded
}
func decodeQuotedPrintable(
data string,
) string {
reader :=
quotedprintable.NewReader(
strings.NewReader(data),
)
buf := new(bytes.Buffer)
_, _ = buf.ReadFrom(reader)
return buf.String()
}
func parseEmailHeaders(
content []byte,
) (
from string,
to string,
subject string,
date time.Time,
) {
from = "Unknown"
to = "Unknown"
subject = "No subject"
date = time.Now()
lines := strings.Split(
string(content),
"\n",
)
for _, line := range lines {
line = strings.TrimSpace(
line,
)
lowerLine :=
strings.ToLower(line)
switch {
case strings.HasPrefix(
lowerLine,
"from:",
):
raw := strings.TrimSpace(
line[5:],
)
raw = strings.Trim(
raw,
"\"<> ",
)
from = decodeRFC2047(raw)
if from == "" {
from = "Unknown"
}
case strings.HasPrefix(
lowerLine,
"to:",
):
raw := strings.TrimSpace(
line[3:],
)
raw = strings.Trim(
raw,
"\"<> ",
)
to = decodeRFC2047(raw)
if to == "" {
to = "Unknown"
}
case strings.HasPrefix(
lowerLine,
"subject:",
):
raw := strings.TrimSpace(
line[8:],
)
subject =
decodeRFC2047(raw)
if subject == "" {
subject = "No subject"
}
case strings.HasPrefix(
lowerLine,
"date:",
):
dateStr :=
strings.TrimSpace(
line[5:],
)
formats := []string{
time.RFC1123,
time.RFC1123Z,
time.RFC822,
time.RFC822Z,
"Mon, 2 Jan 2006 15:04:05 -0700",
"Mon, 2 Jan 2006 15:04:05 MST",
"2 Jan 2006 15:04:05 -0700",
}
for _, format := range formats {
parsedDate, err :=
time.Parse(
format,
dateStr,
)
if err == nil {
date = parsedDate
break
}
}
}
}
return
}
func extractMIMEPartBody(
part string,
) string {
if idx := strings.Index(
part,
"\r\n\r\n",
); idx != -1 {
return strings.TrimSuffix(
part[idx+4:],
"--",
)
}
if idx := strings.Index(
part,
"\n\n",
); idx != -1 {
return strings.TrimSuffix(
part[idx+2:],
"--",
)
}
return ""
}
func extractEmailBody(
content []byte,
) string {
str := string(content)
boundary := ""
lines := strings.Split(
str,
"\n",
)
for i, line := range lines {
lowerLine :=
strings.ToLower(line)
if !strings.HasPrefix(
lowerLine,
"content-type:",
) {
continue
}
if idx := strings.Index(
line,
"boundary=",
); idx != -1 {
boundary = strings.Trim(
line[idx+9:],
"\" \r\n",
)
break
}
if i+1 < len(lines) &&
strings.Contains(
strings.ToLower(
lines[i+1],
),
"boundary=",
) {
nextLine := lines[i+1]
if idx := strings.Index(
nextLine,
"boundary=",
); idx != -1 {
boundary =
strings.Trim(
nextLine[idx+9:],
"\" \r\n",
)
}
}
}
if boundary != "" {
parts := strings.Split(
str,
"--"+boundary,
)
// Сначала text/plain.
for _, part := range parts {
if !strings.Contains(
strings.ToLower(part),
"content-type: text/plain",
) {
continue
}
body :=
extractMIMEPartBody(
part,
)
if body == "" {
continue
}
if strings.Contains(
strings.ToLower(part),
"content-transfer-encoding: quoted-printable",
) {
body =
decodeQuotedPrintable(
body,
)
}
return strings.TrimSpace(
body,
)
}
// Затем text/html.
for _, part := range parts {
if !strings.Contains(
strings.ToLower(part),
"content-type: text/html",
) {
continue
}
body :=
extractMIMEPartBody(
part,
)
if body == "" {
continue
}
if strings.Contains(
strings.ToLower(part),
"content-transfer-encoding: quoted-printable",
) {
body =
decodeQuotedPrintable(
body,
)
}
return strings.TrimSpace(
body,
)
}
}
body :=
extractMIMEPartBody(str)
if body != "" {
if strings.Contains(
strings.ToLower(str),
"content-transfer-encoding: quoted-printable",
) {
body =
decodeQuotedPrintable(
body,
)
}
return strings.TrimSpace(
body,
)
}
return "No content"
}
func listEmails(
storageDir string,
) ([]DayEmails, error) {
days := make([]DayEmails, 0)
entries, err :=
os.ReadDir(storageDir)
if err != nil {
if os.IsNotExist(err) {
return days, nil
}
return nil, err
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
if _, err := time.Parse(
"2006-01-02",
entry.Name(),
); err != nil {
continue
}
dayPath := filepath.Join(
storageDir,
entry.Name(),
)
files, err :=
os.ReadDir(dayPath)
if err != nil {
continue
}
emails :=
make([]EmailFile, 0)
for _, file := range files {
if file.IsDir() {
continue
}
if file.Type()&
os.ModeSymlink != 0 {
continue
}
if !strings.EqualFold(
filepath.Ext(
file.Name(),
),
".eml",
) {
continue
}
filePath :=
filepath.Join(
dayPath,
file.Name(),
)
info, err :=
file.Info()
if err != nil {
continue
}
content, err :=
os.ReadFile(
filePath,
)
if err != nil {
continue
}
from,
to,
subject,
date :=
parseEmailHeaders(
content,
)
emailID :=
filepath.ToSlash(
filepath.Join(
entry.Name(),
file.Name(),
),
)
emails = append(
emails,
EmailFile{
Name: file.Name(),
ID: emailID,
Size: info.Size(),
ModTime: info.ModTime(),
From: from,
To: to,
Subject: subject,
Date: date,
},
)
}
sort.Slice(
emails,
func(i, j int) bool {
return emails[i].
ModTime.
After(
emails[j].
ModTime,
)
},
)
if len(emails) > 0 {
days = append(
days,
DayEmails{
Date: entry.Name(),
Emails: emails,
},
)
}
}
sort.Slice(
days,
func(i, j int) bool {
return days[i].Date >
days[j].Date
},
)
return days, nil
}
func resolveEmailPath(
storageDir string,
id string,
) (string, error) {
id = strings.TrimSpace(id)
if id == "" {
return "", fmt.Errorf(
"%w: empty id",
errInvalidEmailID,
)
}
if strings.Contains(
id,
"\\",
) {
return "", fmt.Errorf(
"%w: backslash is not allowed",
errInvalidEmailID,
)
}
parts := strings.Split(
id,
"/",
)
if len(parts) != 2 {
return "", fmt.Errorf(
"%w: invalid path structure",
errInvalidEmailID,
)
}
day := parts[0]
filename := parts[1]
if _, err := time.Parse(
"2006-01-02",
day,
); err != nil {
return "", fmt.Errorf(
"%w: invalid date directory",
errInvalidEmailID,
)
}
if filename == "" ||
filename == "." ||
filename == ".." ||
filepath.Base(filename) !=
filename {
return "", fmt.Errorf(
"%w: invalid filename",
errInvalidEmailID,
)
}
if !strings.EqualFold(
filepath.Ext(filename),
".eml",
) {
return "", fmt.Errorf(
"%w: only .eml is allowed",
errInvalidEmailID,
)
}
storageAbs, err :=
filepath.Abs(storageDir)
if err != nil {
return "", fmt.Errorf(
"cannot resolve storage directory: %w",
err,
)
}
storageResolved, err :=
filepath.EvalSymlinks(
storageAbs,
)
if err != nil {
return "", fmt.Errorf(
"cannot resolve storage directory: %w",
err,
)
}
candidate := filepath.Join(
storageAbs,
day,
filename,
)
candidateAbs, err :=
filepath.Abs(candidate)
if err != nil {
return "", fmt.Errorf(
"cannot resolve email path: %w",
err,
)
}
candidateResolved, err :=
filepath.EvalSymlinks(
candidateAbs,
)
if err != nil {
if os.IsNotExist(err) {
return "", os.ErrNotExist
}
return "", fmt.Errorf(
"cannot resolve email file: %w",
err,
)
}
rel, err := filepath.Rel(
storageResolved,
candidateResolved,
)
if err != nil {
return "", fmt.Errorf(
"cannot validate email path: %w",
err,
)
}
if rel == ".." ||
strings.HasPrefix(
rel,
".."+
string(
os.PathSeparator,
),
) ||
filepath.IsAbs(rel) {
return "", fmt.Errorf(
"%w: path escapes storage directory",
errInvalidEmailID,
)
}
info, err :=
os.Stat(
candidateResolved,
)
if err != nil {
if os.IsNotExist(err) {
return "", os.ErrNotExist
}
return "", fmt.Errorf(
"cannot access email: %w",
err,
)
}
if info.IsDir() {
return "", fmt.Errorf(
"%w: email points to directory",
errInvalidEmailID,
)
}
return candidateResolved, nil
}
func (w *WebServer) loadWebAssets() {
w.staticFiles =
make(
map[string]cachedStaticFile,
)
webDir :=
w.config.Web.Directory
// index.html
templatePath :=
filepath.Join(
webDir,
"index.html",
)
templateData, err :=
os.ReadFile(
templatePath,
)
if err != nil {
w.template = nil
w.templateErr =
fmt.Errorf(
"web template not found: %s: %w",
templatePath,
err,
)
w.logger.Errorf(
"%v",
w.templateErr,
)
} else {
tmpl, err :=
template.New(
"index.html",
).Parse(
string(
templateData,
),
)
if err != nil {
w.template = nil
w.templateErr =
fmt.Errorf(
"failed to parse web template %s: %w",
templatePath,
err,
)
w.logger.Errorf(
"%v",
w.templateErr,
)
} else {
w.template = tmpl
w.templateErr = nil
w.logger.Infof(
"Web template loaded: %s (%d bytes)",
templatePath,
len(templateData),
)
}
}
// login.html
loginTemplatePath :=
filepath.Join(
webDir,
"login.html",
)
loginTemplateData, err :=
os.ReadFile(
loginTemplatePath,
)
if err != nil {
w.loginTemplate = nil
w.loginTemplateErr =
fmt.Errorf(
"login template not found: %s: %w",
loginTemplatePath,
err,
)
w.logger.Errorf(
"%v",
w.loginTemplateErr,
)
} else {
tmpl, err :=
template.New(
"login.html",
).Parse(
string(
loginTemplateData,
),
)
if err != nil {
w.loginTemplate = nil
w.loginTemplateErr =
fmt.Errorf(
"failed to parse login template %s: %w",
loginTemplatePath,
err,
)
w.logger.Errorf(
"%v",
w.loginTemplateErr,
)
} else {
w.loginTemplate = tmpl
w.loginTemplateErr = nil
w.logger.Infof(
"Login template loaded: %s (%d bytes)",
loginTemplatePath,
len(
loginTemplateData,
),
)
}
}
assets := []struct {
Name string
ContentType string
}{
{
Name: "app.css",
ContentType: "text/css; charset=utf-8",
},
{
Name: "app.js",
ContentType: "application/javascript; charset=utf-8",
},
{
Name: "tailwind.js",
ContentType: "application/javascript; charset=utf-8",
},
}
for _, asset := range assets {
filePath :=
filepath.Join(
webDir,
asset.Name,
)
data, err :=
os.ReadFile(
filePath,
)
if err != nil {
w.logger.Warningf(
"Cannot load web asset %s: %v",
filePath,
err,
)
continue
}
urlPath :=
"/static/" +
asset.Name
w.staticFiles[urlPath] =
cachedStaticFile{
Data: data,
ContentType: asset.ContentType,
}
w.logger.Infof(
"Web asset loaded: %s (%d bytes)",
filePath,
len(data),
)
}
}
func (w *WebServer) handleStatic(
wr http.ResponseWriter,
r *http.Request,
) {
if r.Method != http.MethodGet &&
r.Method != http.MethodHead {
wr.Header().Set(
"Allow",
"GET, HEAD",
)
http.Error(
wr,
"Method not allowed",
http.StatusMethodNotAllowed,
)
return
}
file, ok := w.staticFiles[r.URL.Path]
if !ok {
http.NotFound(
wr,
r,
)
return
}
wr.Header().Set(
"Content-Type",
file.ContentType,
)
if r.Method == http.MethodHead {
return
}
if _, err := wr.Write(file.Data); err != nil {
w.logger.Errorf(
"Failed to send static file %s: %v",
r.URL.Path,
err,
)
}
}
func constantTimeStringEqual(
a string,
b string,
) bool {
aHash :=
sha256.Sum256(
[]byte(a),
)
bHash :=
sha256.Sum256(
[]byte(b),
)
return subtle.
ConstantTimeCompare(
aHash[:],
bHash[:],
) == 1
}
func (w *WebServer) setSessionCookie(
wr http.ResponseWriter,
sessionID string,
) {
http.SetCookie(
wr,
&http.Cookie{
Name: sessionCookieName,
Value: sessionID,
Path: "/",
HttpOnly: true,
Secure: w.config.Web.Auth.
CookieSecure,
SameSite: http.SameSiteStrictMode,
},
)
}
func (w *WebServer) clearSessionCookie(
wr http.ResponseWriter,
) {
http.SetCookie(
wr,
&http.Cookie{
Name: sessionCookieName,
Value: "",
Path: "/",
HttpOnly: true,
Secure: w.config.Web.Auth.
CookieSecure,
SameSite: http.SameSiteStrictMode,
MaxAge: -1,
Expires: time.Unix(1, 0),
},
)
}
type loginPageData struct {
Title string
Error string
}
func (w *WebServer) renderLogin(
wr http.ResponseWriter,
status int,
errorMessage string,
) {
if w.loginTemplate == nil {
message :=
"Login template is unavailable"
if w.loginTemplateErr != nil {
message =
w.loginTemplateErr.
Error()
}
http.Error(
wr,
message,
http.StatusInternalServerError,
)
return
}
wr.Header().Set(
"Content-Type",
"text/html; charset=utf-8",
)
wr.Header().Set(
"Cache-Control",
"no-store",
)
wr.WriteHeader(status)
if err :=
w.loginTemplate.Execute(
wr,
loginPageData{
Title: "Fake SMTP Server - Login",
Error: errorMessage,
},
); err != nil {
w.logger.Errorf(
"Login template execution error: %v",
err,
)
}
}
func (w *WebServer) handleLogin(
wr http.ResponseWriter,
r *http.Request,
) {
if !w.config.Web.Auth.Enabled {
http.Redirect(
wr,
r,
"/",
http.StatusSeeOther,
)
return
}
switch r.Method {
case http.MethodGet:
if cookie, err :=
r.Cookie(
sessionCookieName,
); err == nil {
if w.sessions.validate(
cookie.Value,
) {
http.Redirect(
wr,
r,
"/",
http.StatusSeeOther,
)
return
}
w.clearSessionCookie(
wr,
)
}
w.renderLogin(
wr,
http.StatusOK,
"",
)
case http.MethodPost:
clientIP :=
remoteIP(
r.RemoteAddr,
)
allowed, retryAfter :=
w.loginLimiter.allow(
clientIP,
)
if !allowed {
seconds := int64(
(retryAfter + time.Second - 1) / time.Second,
)
if seconds < 1 {
seconds = 1
}
wr.Header().Set(
"Retry-After",
strconv.FormatInt(
seconds,
10,
),
)
w.logger.Warningf(
"Web login temporarily blocked for %s",
clientIP,
)
w.renderLogin(
wr,
http.StatusTooManyRequests,
"Too many failed login attempts. Try again later.",
)
return
}
r.Body =
http.MaxBytesReader(
wr,
r.Body,
16*1024,
)
if err :=
r.ParseForm(); err != nil {
w.renderLogin(
wr,
http.StatusBadRequest,
"Invalid request",
)
return
}
username :=
r.FormValue(
"username",
)
password :=
r.FormValue(
"password",
)
validUsername :=
constantTimeStringEqual(
username,
w.config.Web.Auth.
Username,
)
validPassword :=
constantTimeStringEqual(
password,
w.config.Web.Auth.
Password,
)
if !validUsername ||
!validPassword {
w.loginLimiter.failure(
clientIP,
)
w.logger.Warningf(
"Failed web login from %s",
clientIP,
)
w.renderLogin(
wr,
http.StatusUnauthorized,
"Invalid username or password",
)
return
}
w.loginLimiter.success(
clientIP,
)
// Защита от session fixation:
// старую сессию удаляем.
if oldCookie, err :=
r.Cookie(
sessionCookieName,
); err == nil {
w.sessions.delete(
oldCookie.Value,
)
}
sessionID, err :=
w.sessions.create()
if err != nil {
w.logger.Errorf(
"Failed to create session: %v",
err,
)
http.Error(
wr,
"Failed to create session",
http.StatusInternalServerError,
)
return
}
w.setSessionCookie(
wr,
sessionID,
)
w.logger.Infof(
"Web login successful from %s",
clientIP,
)
http.Redirect(
wr,
r,
"/",
http.StatusSeeOther,
)
default:
wr.Header().Set(
"Allow",
"GET, POST",
)
http.Error(
wr,
"Method not allowed",
http.StatusMethodNotAllowed,
)
}
}
func (w *WebServer) handleLogout(
wr http.ResponseWriter,
r *http.Request,
) {
if r.Method !=
http.MethodPost {
wr.Header().Set(
"Allow",
http.MethodPost,
)
http.Error(
wr,
"Method not allowed",
http.StatusMethodNotAllowed,
)
return
}
r.Body =
http.MaxBytesReader(
wr,
r.Body,
8*1024,
)
if err :=
r.ParseForm(); err != nil {
http.Error(
wr,
"Invalid request",
http.StatusBadRequest,
)
return
}
cookie, err :=
r.Cookie(
sessionCookieName,
)
if err != nil {
w.clearSessionCookie(
wr,
)
http.Redirect(
wr,
r,
"/login",
http.StatusSeeOther,
)
return
}
csrfToken :=
r.FormValue(
"csrf_token",
)
if !w.sessions.validateCSRF(
cookie.Value,
csrfToken,
) {
w.logger.Warningf(
"Rejected logout with invalid CSRF token from %s",
r.RemoteAddr,
)
http.Error(
wr,
"Invalid CSRF token",
http.StatusForbidden,
)
return
}
w.sessions.delete(
cookie.Value,
)
w.clearSessionCookie(
wr,
)
http.Redirect(
wr,
r,
"/login",
http.StatusSeeOther,
)
}
func (w *WebServer) sessionAuth(
next http.Handler,
) http.Handler {
return http.HandlerFunc(
func(
wr http.ResponseWriter,
r *http.Request,
) {
if !w.config.Web.Auth.Enabled {
next.ServeHTTP(
wr,
r,
)
return
}
// Login должен быть публичным.
if r.URL.Path ==
"/login" {
next.ServeHTTP(
wr,
r,
)
return
}
// Статика нужна странице login.
if strings.HasPrefix(
r.URL.Path,
"/static/",
) {
next.ServeHTTP(
wr,
r,
)
return
}
cookie, err :=
r.Cookie(
sessionCookieName,
)
if err == nil &&
w.sessions.validate(
cookie.Value,
) {
next.ServeHTTP(
wr,
r,
)
return
}
w.clearSessionCookie(
wr,
)
if strings.HasPrefix(
r.URL.Path,
"/api/",
) {
http.Error(
wr,
"Session expired",
http.StatusUnauthorized,
)
return
}
http.Redirect(
wr,
r,
"/login",
http.StatusSeeOther,
)
},
)
}
func (w *WebServer) readEmailByID(
id string,
) (
[]byte,
string,
error,
) {
filePath, err :=
resolveEmailPath(
w.config.SMTP.StorageDir,
id,
)
if err != nil {
return nil, "", err
}
content, err :=
os.ReadFile(
filePath,
)
if err != nil {
return nil, "", err
}
return content,
filePath,
nil
}
func (w *WebServer) handleEmailError(
wr http.ResponseWriter,
r *http.Request,
id string,
err error,
) {
if errors.Is(
err,
os.ErrNotExist,
) {
http.NotFound(
wr,
r,
)
return
}
if errors.Is(
err,
errInvalidEmailID,
) {
w.logger.Warningf(
"Rejected email id %q from %s: %v",
id,
r.RemoteAddr,
err,
)
http.Error(
wr,
"Invalid email id",
http.StatusBadRequest,
)
return
}
w.logger.Errorf(
"Error accessing email %q: %v",
id,
err,
)
http.Error(
wr,
"Error reading email",
http.StatusInternalServerError,
)
}
func (w *WebServer) handleIndex(
wr http.ResponseWriter,
r *http.Request,
) {
if r.URL.Path != "/" {
http.NotFound(
wr,
r,
)
return
}
if r.Method != http.MethodGet &&
r.Method != http.MethodHead {
wr.Header().Set(
"Allow",
"GET, HEAD",
)
http.Error(
wr,
"Method not allowed",
http.StatusMethodNotAllowed,
)
return
}
if w.template == nil {
message :=
"Web template is unavailable"
if w.templateErr != nil {
message =
w.templateErr.Error()
}
http.Error(
wr,
message,
http.StatusInternalServerError,
)
return
}
days, err :=
listEmails(
w.config.SMTP.StorageDir,
)
if err != nil {
w.logger.Errorf(
"Error listing emails: %v",
err,
)
http.Error(
wr,
"Error listing emails",
http.StatusInternalServerError,
)
return
}
csrfToken := ""
if w.config.Web.Auth.Enabled {
if cookie, err :=
r.Cookie(
sessionCookieName,
); err == nil {
if token, ok :=
w.sessions.csrfToken(
cookie.Value,
); ok {
csrfToken = token
}
}
}
data := struct {
Days []DayEmails
Title string
CSRFToken string
}{
Days: days,
Title: "Fake SMTP Server - Emails",
CSRFToken: csrfToken,
}
wr.Header().Set(
"Content-Type",
"text/html; charset=utf-8",
)
wr.Header().Set(
"Cache-Control",
"no-store",
)
if r.Method ==
http.MethodHead {
return
}
if err :=
w.template.Execute(
wr,
data,
); err != nil {
w.logger.Errorf(
"Template execution error: %v",
err,
)
}
}
func (w *WebServer) handleEmailsAPI(
wr http.ResponseWriter,
r *http.Request,
) {
if r.Method !=
http.MethodGet {
wr.Header().Set(
"Allow",
http.MethodGet,
)
http.Error(
wr,
"Method not allowed",
http.StatusMethodNotAllowed,
)
return
}
days, err :=
listEmails(
w.config.SMTP.StorageDir,
)
if err != nil {
w.logger.Errorf(
"Error listing emails: %v",
err,
)
wr.Header().Set(
"Content-Type",
"application/json; charset=utf-8",
)
wr.WriteHeader(
http.StatusInternalServerError,
)
_ = json.NewEncoder(
wr,
).Encode(
map[string]string{
"error": "Error listing emails",
},
)
return
}
wr.Header().Set(
"Content-Type",
"application/json; charset=utf-8",
)
if err :=
json.NewEncoder(
wr,
).Encode(
days,
); err != nil {
w.logger.Errorf(
"Failed to encode emails JSON: %v",
err,
)
}
}
func (w *WebServer) handleEmailAPI(
wr http.ResponseWriter,
r *http.Request,
) {
if r.Method !=
http.MethodGet {
wr.Header().Set(
"Allow",
http.MethodGet,
)
http.Error(
wr,
"Method not allowed",
http.StatusMethodNotAllowed,
)
return
}
id :=
r.URL.Query().
Get("id")
if id == "" {
http.Error(
wr,
"Missing id parameter",
http.StatusBadRequest,
)
return
}
content, _, err :=
w.readEmailByID(id)
if err != nil {
w.handleEmailError(
wr,
r,
id,
err,
)
return
}
body :=
extractEmailBody(
content,
)
wr.Header().Set(
"Content-Type",
"text/html; charset=utf-8",
)
if _, err :=
wr.Write(
[]byte(body),
); err != nil {
w.logger.Errorf(
"Failed to send email %q: %v",
id,
err,
)
}
}
func (w *WebServer) handleViewAPI(
wr http.ResponseWriter,
r *http.Request,
) {
if r.Method !=
http.MethodGet {
wr.Header().Set(
"Allow",
http.MethodGet,
)
http.Error(
wr,
"Method not allowed",
http.StatusMethodNotAllowed,
)
return
}
id :=
r.URL.Query().
Get("id")
if id == "" {
http.Error(
wr,
"Missing id parameter",
http.StatusBadRequest,
)
return
}
content, _, err :=
w.readEmailByID(id)
if err != nil {
w.handleEmailError(
wr,
r,
id,
err,
)
return
}
body :=
extractEmailBody(
content,
)
wr.Header().Set(
"Content-Type",
"text/html; charset=utf-8",
)
if _, err :=
wr.Write(
[]byte(body),
); err != nil {
w.logger.Errorf(
"Failed to send email view %q: %v",
id,
err,
)
}
}
func (w *WebServer) handleDownloadAPI(
wr http.ResponseWriter,
r *http.Request,
) {
if r.Method !=
http.MethodGet {
wr.Header().Set(
"Allow",
http.MethodGet,
)
http.Error(
wr,
"Method not allowed",
http.StatusMethodNotAllowed,
)
return
}
id :=
r.URL.Query().
Get("id")
if id == "" {
http.Error(
wr,
"Missing id parameter",
http.StatusBadRequest,
)
return
}
content,
filePath,
err :=
w.readEmailByID(id)
if err != nil {
w.handleEmailError(
wr,
r,
id,
err,
)
return
}
filename :=
filepath.Base(
filePath,
)
disposition :=
mime.FormatMediaType(
"attachment",
map[string]string{
"filename": filename,
},
)
wr.Header().Set(
"Content-Type",
"message/rfc822",
)
wr.Header().Set(
"Content-Disposition",
disposition,
)
wr.Header().Set(
"Content-Length",
strconv.Itoa(
len(content),
),
)
if _, err :=
wr.Write(content); err != nil {
w.logger.Errorf(
"Failed to download email %q: %v",
id,
err,
)
}
}
func (w *WebServer) Start() error {
if !w.config.Web.Enabled {
w.logger.Info(
"Web server disabled in config",
)
return nil
}
// HTML/CSS/JS читаются один раз при запуске.
w.loadWebAssets()
mux := http.NewServeMux()
mux.HandleFunc(
"/static/",
w.handleStatic,
)
mux.HandleFunc(
"/login",
w.handleLogin,
)
mux.HandleFunc(
"/logout",
w.handleLogout,
)
mux.HandleFunc(
"/api/emails",
w.handleEmailsAPI,
)
mux.HandleFunc(
"/api/email",
w.handleEmailAPI,
)
mux.HandleFunc(
"/api/view",
w.handleViewAPI,
)
mux.HandleFunc(
"/api/download",
w.handleDownloadAPI,
)
mux.HandleFunc(
"/",
w.handleIndex,
)
addr := fmt.Sprintf(
":%d",
w.config.Web.Port,
)
w.server =
&http.Server{
Addr: addr,
Handler: w.sessionAuth(mux),
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
ReadHeaderTimeout: 5 * time.Second,
}
// net.Listen выполняется синхронно,
// поэтому занятый порт сразу вернёт ошибку из Start().
listener, err :=
net.Listen(
"tcp",
addr,
)
if err != nil {
w.server = nil
return fmt.Errorf(
"failed to listen on %s: %w",
addr,
err,
)
}
w.logger.Infof(
"Web server listening on http://0.0.0.0:%d",
w.config.Web.Port,
)
w.logger.Infof(
"Web directory: %s",
w.config.Web.Directory,
)
w.logger.Infof(
"Web Auth enabled: %v",
w.config.Web.Auth.Enabled,
)
if w.config.Web.Auth.Enabled {
w.logger.Infof(
"Web session inactivity timeout: %s",
w.config.Web.Auth.SessionTimeout,
)
w.logger.Infof(
"Web session cookie Secure: %v",
w.config.Web.Auth.CookieSecure,
)
w.logger.Infof(
"Web login rate limit: %d attempts / %s, lockout %s",
w.config.Web.Auth.LoginMaxAttempts,
w.config.Web.Auth.LoginWindow,
w.config.Web.Auth.LoginLockout,
)
}
go func() {
err :=
w.server.Serve(
listener,
)
if err != nil &&
!errors.Is(
err,
http.ErrServerClosed,
) {
w.logger.Errorf(
"Web server error: %v",
err,
)
}
}()
return nil
}
func (w *WebServer) Stop() error {
if w.server == nil {
return nil
}
w.logger.Info(
"Stopping web server...",
)
ctx, cancel :=
context.WithTimeout(
context.Background(),
5*time.Second,
)
defer cancel()
err :=
w.server.Shutdown(ctx)
w.server = nil
if err != nil {
return fmt.Errorf(
"failed to stop web server: %w",
err,
)
}
return nil
}