server: add SMTP config, user management web pages, install script
- SMTP configuration in admin panel with test button - Admin web interface for user creation (/admin/create-user) - REST API for user creation (/admin/api/users/create) - Self-registration endpoint (/register) - Systemd service file and install script
This commit is contained in:
parent
c9b35295cb
commit
c793084fa4
Binary file not shown.
|
|
@ -0,0 +1,370 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) requireUserWeb(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||||||
|
cookie, err := r.Cookie("user_session")
|
||||||
|
if err != nil {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusFound)
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
userID, ok := s.userTokens.Check(cookie.Value)
|
||||||
|
if !ok {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusFound)
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return userID, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleUserWebRegister(w http.ResponseWriter, r *http.Request) {
|
||||||
|
locale := s.locale()
|
||||||
|
switch r.Method {
|
||||||
|
case "GET":
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.Write([]byte(userRegisterHTML(locale)))
|
||||||
|
case "POST":
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(400)
|
||||||
|
w.Write([]byte(errorPageHTML(locale, "400 Bad request", "400 Bad request", "/register")))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
username := r.FormValue("username")
|
||||||
|
email := r.FormValue("email")
|
||||||
|
password := r.FormValue("password")
|
||||||
|
if username == "" || email == "" || password == "" {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(400)
|
||||||
|
w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), t(locale, "server.allFieldsRequired"), "/register")))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := validatePassword(password); err != "" {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(400)
|
||||||
|
w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), string(err), "/register")))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(500)
|
||||||
|
w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), "internal error", "/register")))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
id := make([]byte, 12)
|
||||||
|
rand.Read(id)
|
||||||
|
userID := hex.EncodeToString(id)
|
||||||
|
_, err = s.db.Exec(
|
||||||
|
"INSERT INTO server_users (id, username, email, password_hash, confirmed, created_at) VALUES (?, ?, ?, ?, 0, ?)",
|
||||||
|
userID, username, strings.ToLower(email), string(hash), now,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
if strings.Contains(err.Error(), "UNIQUE") {
|
||||||
|
w.WriteHeader(409)
|
||||||
|
w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), "Username or email already taken", "/register")))
|
||||||
|
} else {
|
||||||
|
w.WriteHeader(500)
|
||||||
|
w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), err.Error(), "/register")))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tok := make([]byte, 24)
|
||||||
|
rand.Read(tok)
|
||||||
|
tokenStr := hex.EncodeToString(tok)
|
||||||
|
exp := time.Now().Add(48 * time.Hour).UTC().Format(time.RFC3339)
|
||||||
|
s.db.Exec("INSERT INTO server_email_tokens (token, user_id, purpose, expires_at, created_at) VALUES (?, ?, 'confirm', ?, ?)",
|
||||||
|
tokenStr, userID, exp, now)
|
||||||
|
host := s.smtpGet("smtp_host")
|
||||||
|
if host != "" {
|
||||||
|
srvURL := s.smtpGet("server_url")
|
||||||
|
var confirmURL string
|
||||||
|
if srvURL != "" {
|
||||||
|
confirmURL = fmt.Sprintf("%s/api/v1/auth/confirm?token=%s", srvURL, tokenStr)
|
||||||
|
} else {
|
||||||
|
confirmURL = fmt.Sprintf("http://%s/api/v1/auth/confirm?token=%s", r.Host, tokenStr)
|
||||||
|
}
|
||||||
|
body := fmt.Sprintf(t(locale, "server.emailConfirmBody"), confirmURL)
|
||||||
|
if err := s.smtpSend(email, t(locale, "server.emailConfirmSubject"), body); err != nil {
|
||||||
|
log.Printf("register web: failed to send confirm email: %v", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Printf("register web: SMTP not configured, confirmation token=%s for user %s", tokenStr, username)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
regMsg := registrationOKHTML(locale)
|
||||||
|
if host == "" {
|
||||||
|
regMsg = registrationAutoHTML(locale)
|
||||||
|
}
|
||||||
|
w.Write([]byte(regMsg))
|
||||||
|
default:
|
||||||
|
jsonErr(w, 405, "method not allowed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleUserWebForgot(w http.ResponseWriter, r *http.Request) {
|
||||||
|
locale := s.locale()
|
||||||
|
switch r.Method {
|
||||||
|
case "GET":
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.Write([]byte(forgotPasswordHTML(locale)))
|
||||||
|
case "POST":
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
jsonErr(w, 400, "bad form")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
email := strings.ToLower(r.FormValue("email"))
|
||||||
|
if email == "" {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), t(locale, "server.needEmail"), "/forgot")))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var userID string
|
||||||
|
err := s.db.QueryRow("SELECT id FROM server_users WHERE email=?", email).Scan(&userID)
|
||||||
|
if err != nil {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.Write([]byte(forgotSentHTML(locale)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tok := make([]byte, 24)
|
||||||
|
rand.Read(tok)
|
||||||
|
tokenStr := hex.EncodeToString(tok)
|
||||||
|
exp := time.Now().Add(1 * time.Hour).UTC().Format(time.RFC3339)
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
s.db.Exec("INSERT INTO server_email_tokens (token, user_id, purpose, expires_at, created_at) VALUES (?, ?, 'reset', ?, ?)",
|
||||||
|
tokenStr, userID, exp, now)
|
||||||
|
host := s.smtpGet("smtp_host")
|
||||||
|
if host != "" {
|
||||||
|
srvURL := s.smtpGet("server_url")
|
||||||
|
resetURL := fmt.Sprintf("/reset?token=%s", tokenStr)
|
||||||
|
if srvURL != "" {
|
||||||
|
resetURL = fmt.Sprintf("%s/reset?token=%s", srvURL, tokenStr)
|
||||||
|
}
|
||||||
|
body := fmt.Sprintf(t(locale, "server.emailResetBody"), resetURL)
|
||||||
|
if err := s.smtpSend(email, t(locale, "server.emailResetSubject"), body); err != nil {
|
||||||
|
log.Printf("forgot web: failed to send reset email: %v", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Printf("forgot web: SMTP not configured, reset token=%s for email %s", tokenStr, email)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.Write([]byte(forgotSentHTML(locale)))
|
||||||
|
default:
|
||||||
|
jsonErr(w, 405, "method not allowed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleUserWebReset(w http.ResponseWriter, r *http.Request) {
|
||||||
|
locale := s.locale()
|
||||||
|
switch r.Method {
|
||||||
|
case "GET":
|
||||||
|
token := r.URL.Query().Get("token")
|
||||||
|
if token == "" {
|
||||||
|
http.Redirect(w, r, "/forgot", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var userID, expiresAt string
|
||||||
|
err := s.db.QueryRow("SELECT user_id, expires_at FROM server_email_tokens WHERE token=? AND purpose='reset'",
|
||||||
|
token).Scan(&userID, &expiresAt)
|
||||||
|
if err != nil {
|
||||||
|
http.Redirect(w, r, "/forgot", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
exp, err := time.Parse(time.RFC3339, expiresAt)
|
||||||
|
if err != nil || time.Now().After(exp) {
|
||||||
|
http.Redirect(w, r, "/forgot", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
html := strings.ReplaceAll(resetPasswordHTML(locale), "{TOKEN}", token)
|
||||||
|
w.Write([]byte(html))
|
||||||
|
case "POST":
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
jsonErr(w, 400, "bad form")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token := r.FormValue("token")
|
||||||
|
newPass := r.FormValue("password")
|
||||||
|
confirm := r.FormValue("confirm")
|
||||||
|
if token == "" || newPass == "" || confirm == "" {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), t(locale, "server.allFieldsRequired"), "/forgot")))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := validatePassword(newPass); err != "" {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), string(err), "/reset?token="+token)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if newPass != confirm {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), t(locale, "server.passwordsDoNotMatch"), "/reset?token="+token)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var userID string
|
||||||
|
err := s.db.QueryRow("SELECT user_id FROM server_email_tokens WHERE token=? AND purpose='reset'", token).Scan(&userID)
|
||||||
|
if err != nil {
|
||||||
|
http.Redirect(w, r, "/forgot", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hash, _ := bcrypt.GenerateFromPassword([]byte(newPass), bcrypt.DefaultCost)
|
||||||
|
s.db.Exec("UPDATE server_users SET password_hash=? WHERE id=?", string(hash), userID)
|
||||||
|
s.db.Exec("DELETE FROM server_email_tokens WHERE token=?", token)
|
||||||
|
log.Printf("reset: user %s reset password", userID)
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.Write([]byte(resetDoneHTML(locale)))
|
||||||
|
default:
|
||||||
|
jsonErr(w, 405, "method not allowed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleUserWebLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
locale := s.locale()
|
||||||
|
switch r.Method {
|
||||||
|
case "GET":
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.Write([]byte(userLoginHTML(locale)))
|
||||||
|
case "POST":
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
jsonErr(w, 400, "bad form")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
username := r.FormValue("username")
|
||||||
|
password := r.FormValue("password")
|
||||||
|
var userID, hash string
|
||||||
|
var confirmed, blocked int
|
||||||
|
err := s.db.QueryRow("SELECT id, password_hash, confirmed, blocked FROM server_users WHERE username=? OR email=?",
|
||||||
|
username, strings.ToLower(username)).Scan(&userID, &hash, &confirmed, &blocked)
|
||||||
|
if err != nil || blocked != 0 || confirmed == 0 || bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(401)
|
||||||
|
w.Write([]byte(errorPageHTML(locale, "401 Unauthorized", "401 Unauthorized", "/login")))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tok := s.userTokens.Create(userID)
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "user_session", Value: tok, Path: "/",
|
||||||
|
HttpOnly: true, SameSite: http.SameSiteLaxMode,
|
||||||
|
MaxAge: 86400,
|
||||||
|
})
|
||||||
|
http.Redirect(w, r, "/dashboard", http.StatusFound)
|
||||||
|
default:
|
||||||
|
jsonErr(w, 405, "method not allowed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleUserDashboard(w http.ResponseWriter, r *http.Request) {
|
||||||
|
locale := s.locale()
|
||||||
|
userID, ok := s.requireUserWeb(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var username string
|
||||||
|
s.db.QueryRow("SELECT username FROM server_users WHERE id=?", userID).Scan(&username)
|
||||||
|
|
||||||
|
type dev struct {
|
||||||
|
ID, Name, LastSeen, CreatedAt, ClientVer, RevokedAt string
|
||||||
|
}
|
||||||
|
var devices []dev
|
||||||
|
rows, err := s.db.Query(`
|
||||||
|
SELECT d.id, d.name, COALESCE(d.last_seen,''), d.created_at,
|
||||||
|
COALESCE(d.client_version,''), COALESCE(d.revoked_at,'')
|
||||||
|
FROM server_devices d
|
||||||
|
JOIN server_user_devices ud ON ud.device_id = d.id
|
||||||
|
WHERE ud.user_id = ?
|
||||||
|
ORDER BY d.created_at DESC`, userID)
|
||||||
|
if err == nil {
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var d dev
|
||||||
|
rows.Scan(&d.ID, &d.Name, &d.LastSeen, &d.CreatedAt, &d.ClientVer, &d.RevokedAt)
|
||||||
|
devices = append(devices, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
deviceRows := ""
|
||||||
|
if len(devices) == 0 {
|
||||||
|
deviceRows = "<tr><td colspan='5' style='color:#666;text-align:center;padding:24px'>" + t(locale, "userDashboard.noDevices") + "</td></tr>"
|
||||||
|
} else {
|
||||||
|
for _, d := range devices {
|
||||||
|
ls := d.LastSeen
|
||||||
|
if ls == "" {
|
||||||
|
ls = "—"
|
||||||
|
}
|
||||||
|
created := d.CreatedAt
|
||||||
|
if len(created) > 10 {
|
||||||
|
created = created[:10]
|
||||||
|
}
|
||||||
|
status := "<span style='color:#34d399'>" + t(locale, "userDashboard.active") + "</span>"
|
||||||
|
revokeBtn := fmt.Sprintf(`<button class="btn btn-danger btn-sm" onclick="revokeDevice('%s')">%s</button>`, d.ID, t(locale, "userDashboard.revoke"))
|
||||||
|
if d.RevokedAt != "" {
|
||||||
|
status = "<span style='color:#ff6b6b'>" + t(locale, "userDashboard.revoked") + "</span>"
|
||||||
|
revokeBtn = ""
|
||||||
|
}
|
||||||
|
deviceRows += fmt.Sprintf(`<tr>
|
||||||
|
<td>%s</td>
|
||||||
|
<td>%s</td>
|
||||||
|
<td>%s</td>
|
||||||
|
<td>%s</td>
|
||||||
|
<td>%s %s</td>
|
||||||
|
</tr>`, d.Name, status, created, ls, d.ClientVer, revokeBtn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Write([]byte(userDashboardHTML(locale, username, deviceRows)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleUserWebLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "user_session", Value: "", Path: "/",
|
||||||
|
HttpOnly: true, MaxAge: -1,
|
||||||
|
})
|
||||||
|
http.Redirect(w, r, "/login", http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleUserDevices(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := s.requireUserWeb(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := s.db.Query(`
|
||||||
|
SELECT d.id, d.name, COALESCE(d.client_version,''), COALESCE(d.last_seen,''), COALESCE(d.revoked_at,''), d.created_at
|
||||||
|
FROM server_devices d
|
||||||
|
JOIN server_user_devices ud ON ud.device_id = d.id
|
||||||
|
WHERE ud.user_id = ?
|
||||||
|
ORDER BY d.created_at DESC`, userID)
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
type devDTO struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
ClientVersion string `json:"client_version"`
|
||||||
|
LastSeen string `json:"last_seen"`
|
||||||
|
RevokedAt string `json:"revoked_at"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
var devices []devDTO
|
||||||
|
for rows.Next() {
|
||||||
|
var d devDTO
|
||||||
|
rows.Scan(&d.ID, &d.Name, &d.ClientVersion, &d.LastSeen, &d.RevokedAt, &d.CreatedAt)
|
||||||
|
devices = append(devices, d)
|
||||||
|
}
|
||||||
|
jsonOK(w, devices)
|
||||||
|
}
|
||||||
|
|
@ -101,4 +101,9 @@ CREATE INDEX IF NOT EXISTS idx_server_users_username ON server_users(username);
|
||||||
CREATE INDEX IF NOT EXISTS idx_server_users_email ON server_users(email);
|
CREATE INDEX IF NOT EXISTS idx_server_users_email ON server_users(email);
|
||||||
CREATE INDEX IF NOT EXISTS idx_server_audit_log_event ON server_audit_log(event_type);
|
CREATE INDEX IF NOT EXISTS idx_server_audit_log_event ON server_audit_log(event_type);
|
||||||
CREATE INDEX IF NOT EXISTS idx_server_audit_log_created ON server_audit_log(created_at);
|
CREATE INDEX IF NOT EXISTS idx_server_audit_log_created ON server_audit_log(created_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS server_smtp_config (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
`
|
`
|
||||||
|
|
|
||||||
|
|
@ -162,6 +162,10 @@ func (s *Server) SetupRoutes() {
|
||||||
s.routes()
|
s.routes()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) locale() string {
|
||||||
|
return "ru"
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) Close() error {
|
func (s *Server) Close() error {
|
||||||
return s.db.Close()
|
return s.db.Close()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,139 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/smtp"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) smtpGet(key string) string {
|
||||||
|
var val string
|
||||||
|
s.db.QueryRow("SELECT value FROM server_smtp_config WHERE key=?", key).Scan(&val)
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) smtpSet(key, val string) error {
|
||||||
|
_, err := s.db.Exec("INSERT OR REPLACE INTO server_smtp_config (key, value) VALUES (?, ?)", key, val)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func sel(v, want string) string {
|
||||||
|
if v == want {
|
||||||
|
return " selected"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) smtpConnect(host, port, user, pass, security string) (*smtp.Client, error) {
|
||||||
|
addr := net.JoinHostPort(host, port)
|
||||||
|
switch security {
|
||||||
|
case "tls":
|
||||||
|
tlsCfg := &tls.Config{ServerName: host}
|
||||||
|
conn, err := tls.Dial("tcp", addr, tlsCfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("tls dial: %w", err)
|
||||||
|
}
|
||||||
|
cl, err := smtp.NewClient(conn, host)
|
||||||
|
if err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil, fmt.Errorf("smtp client: %w", err)
|
||||||
|
}
|
||||||
|
return cl, nil
|
||||||
|
default:
|
||||||
|
conn, err := net.DialTimeout("tcp", addr, 10*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("connect: %w", err)
|
||||||
|
}
|
||||||
|
cl, err := smtp.NewClient(conn, host)
|
||||||
|
if err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil, fmt.Errorf("smtp client: %w", err)
|
||||||
|
}
|
||||||
|
if security != "none" {
|
||||||
|
if ok, _ := cl.Extension("STARTTLS"); ok {
|
||||||
|
tlsCfg := &tls.Config{ServerName: host}
|
||||||
|
if err := cl.StartTLS(tlsCfg); err != nil {
|
||||||
|
cl.Close()
|
||||||
|
return nil, fmt.Errorf("starttls: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cl, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) smtpSendMsg(cl *smtp.Client, user, pass, host, from, to string, msg []byte) error {
|
||||||
|
if user != "" {
|
||||||
|
auth := smtp.PlainAuth("", user, pass, host)
|
||||||
|
if err := cl.Auth(auth); err != nil {
|
||||||
|
return fmt.Errorf("auth: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := cl.Mail(from); err != nil {
|
||||||
|
return fmt.Errorf("mail from: %w", err)
|
||||||
|
}
|
||||||
|
if err := cl.Rcpt(to); err != nil {
|
||||||
|
return fmt.Errorf("rcpt: %w", err)
|
||||||
|
}
|
||||||
|
w, err := cl.Data()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("data: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := w.Write(msg); err != nil {
|
||||||
|
w.Close()
|
||||||
|
return fmt.Errorf("write: %w", err)
|
||||||
|
}
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
return fmt.Errorf("send: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) smtpSend(to, subject, body string) error {
|
||||||
|
host := s.smtpGet("smtp_host")
|
||||||
|
port := s.smtpGet("smtp_port")
|
||||||
|
user := s.smtpGet("smtp_user")
|
||||||
|
pass := s.smtpGet("smtp_pass")
|
||||||
|
from := s.smtpGet("smtp_from")
|
||||||
|
security := s.smtpGet("smtp_security")
|
||||||
|
if host == "" || port == "" || from == "" {
|
||||||
|
err := fmt.Errorf("SMTP not configured")
|
||||||
|
log.Printf("smtp: %v (to=%s)", err, to)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Printf("smtp: sending to %s via %s:%s (security=%s)", to, host, port, security)
|
||||||
|
msg := []byte("From: " + from + "\r\n" +
|
||||||
|
"To: " + to + "\r\n" +
|
||||||
|
"Subject: " + subject + "\r\n" +
|
||||||
|
"MIME-Version: 1.0\r\n" +
|
||||||
|
"Content-Type: text/plain; charset=UTF-8\r\n" +
|
||||||
|
"\r\n" + body + "\r\n")
|
||||||
|
cl, err := s.smtpConnect(host, port, user, pass, security)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("smtp: connect error: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer cl.Close()
|
||||||
|
if err := s.smtpSendMsg(cl, user, pass, host, from, to, msg); err != nil {
|
||||||
|
log.Printf("smtp: send error: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Printf("smtp: sent OK to %s", to)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) smtpTest(host, port, user, pass, security, from, to string) error {
|
||||||
|
if host == "" || port == "" || from == "" {
|
||||||
|
return fmt.Errorf("SMTP not configured")
|
||||||
|
}
|
||||||
|
msg := []byte("From: " + from + "\r\nTo: " + to + "\r\nSubject: Test from Verstak Sync\r\n\r\nThis is a test email from Verstak Sync Server.\r\n")
|
||||||
|
cl, err := s.smtpConnect(host, port, user, pass, security)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer cl.Close()
|
||||||
|
return s.smtpSendMsg(cl, user, pass, host, from, to, msg)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,113 @@
|
||||||
|
#!/bin/sh
|
||||||
|
#
|
||||||
|
# install.sh — установка Verstak Sync Server
|
||||||
|
#
|
||||||
|
# Использование:
|
||||||
|
# sudo ./install.sh --port 47732 --user verstak --admin-user admin --admin-pass secret
|
||||||
|
#
|
||||||
|
# Флаги:
|
||||||
|
# --port Порт сервера (по умолчанию: 47732)
|
||||||
|
# --user Системный пользователь (по умолчанию: verstak)
|
||||||
|
# --admin-user Логин администратора (обязательный)
|
||||||
|
# --admin-pass Пароль администратора (обязательный)
|
||||||
|
# --bin Путь к бинарнику (по умолчанию: ./verstak-sync-server)
|
||||||
|
#
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Defaults
|
||||||
|
PORT="${VERSTAK_PORT:-47732}"
|
||||||
|
USER="verstak"
|
||||||
|
ADMIN_USER=""
|
||||||
|
ADMIN_PASS=""
|
||||||
|
BIN="./verstak-sync-server"
|
||||||
|
|
||||||
|
# Parse flags
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--port) PORT="$2"; shift 2 ;;
|
||||||
|
--user) USER="$2"; shift 2 ;;
|
||||||
|
--admin-user) ADMIN_USER="$2"; shift 2 ;;
|
||||||
|
--admin-pass) ADMIN_PASS="$2"; shift 2 ;;
|
||||||
|
--bin) BIN="$2"; shift 2 ;;
|
||||||
|
*) echo "Unknown: $1"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$ADMIN_USER" ] || [ -z "$ADMIN_PASS" ]; then
|
||||||
|
echo "Usage: $0 --admin-user USER --admin-pass PASS [--port PORT] [--user USER]"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
|
echo "This script must be run as root (sudo)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== Verstak Sync Server Installation ==="
|
||||||
|
echo "Port: $PORT"
|
||||||
|
echo "User: $USER"
|
||||||
|
echo "Admin: $ADMIN_USER"
|
||||||
|
echo "Binary: $BIN"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 1. Create system user if not exists.
|
||||||
|
if ! id -u "$USER" >/dev/null 2>&1; then
|
||||||
|
echo "Creating user: $USER"
|
||||||
|
useradd --system --no-create-home --shell /usr/sbin/nologin "$USER"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Install binary.
|
||||||
|
INSTALL_DIR="/opt/verstak-sync-server"
|
||||||
|
if [ ! -f "$BIN" ]; then
|
||||||
|
echo "Binary not found: $BIN. Build it first: go build -o $BIN ./cmd/server/"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Installing binary to $INSTALL_DIR"
|
||||||
|
mkdir -p "$INSTALL_DIR"
|
||||||
|
cp "$BIN" "$INSTALL_DIR/verstak-sync-server"
|
||||||
|
chmod 755 "$INSTALL_DIR/verstak-sync-server"
|
||||||
|
|
||||||
|
# 3. Create data directory.
|
||||||
|
DATA_DIR="/var/lib/verstak-sync-server"
|
||||||
|
echo "Creating $DATA_DIR"
|
||||||
|
mkdir -p "$DATA_DIR"
|
||||||
|
chown "$USER:$USER" "$DATA_DIR"
|
||||||
|
chmod 750 "$DATA_DIR"
|
||||||
|
|
||||||
|
# 4. Set up admin user (first run).
|
||||||
|
echo "Setting up admin user"
|
||||||
|
"$INSTALL_DIR/verstak-sync-server" \
|
||||||
|
--port "$PORT" \
|
||||||
|
--data "$DATA_DIR" \
|
||||||
|
--admin-user "$ADMIN_USER" \
|
||||||
|
--admin-pass "$ADMIN_PASS" &
|
||||||
|
SERVER_PID=$!
|
||||||
|
sleep 2
|
||||||
|
kill "$SERVER_PID" 2>/dev/null || true
|
||||||
|
wait "$SERVER_PID" 2>/dev/null || true
|
||||||
|
|
||||||
|
# 5. Install systemd unit.
|
||||||
|
echo "Installing systemd unit"
|
||||||
|
SERVICE_FILE="/etc/systemd/system/verstak-server.service"
|
||||||
|
cp "$(dirname "$0")/../verstak-server.service" "$SERVICE_FILE"
|
||||||
|
chmod 644 "$SERVICE_FILE"
|
||||||
|
|
||||||
|
# Set port in environment file.
|
||||||
|
mkdir -p /etc/verstak-server
|
||||||
|
echo "VERSTAK_PORT=$PORT" > /etc/verstak-server/env
|
||||||
|
|
||||||
|
# 6. Enable and start.
|
||||||
|
echo "Enabling and starting service"
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable verstak-server
|
||||||
|
systemctl start verstak-server
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Installation complete ==="
|
||||||
|
echo "Service: verstak-server"
|
||||||
|
echo "Port: $PORT"
|
||||||
|
echo "Admin: http://localhost:$PORT/admin/login"
|
||||||
|
echo ""
|
||||||
|
echo "Check status: systemctl status verstak-server"
|
||||||
|
echo "View logs: journalctl -u verstak-server -f"
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
[Unit]
|
||||||
|
Description=Verstak Sync Server
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=verstak
|
||||||
|
Group=verstak
|
||||||
|
WorkingDirectory=/opt/verstak-sync-server
|
||||||
|
ExecStart=/opt/verstak-sync-server/verstak-sync-server --port ${VERSTAK_PORT:-47732} --data /var/lib/verstak-sync-server
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectSystem=full
|
||||||
|
ProtectHome=true
|
||||||
|
PrivateTmp=true
|
||||||
|
StateDirectory=verstak-sync-server
|
||||||
|
RuntimeDirectory=verstak-sync-server
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
Loading…
Reference in New Issue