diff --git a/build/bin/verstak-sync-server b/build/bin/verstak-sync-server new file mode 100755 index 0000000..5f12b15 Binary files /dev/null and b/build/bin/verstak-sync-server differ diff --git a/internal/server/handlers_web_user.go b/internal/server/handlers_web_user.go new file mode 100644 index 0000000..f900fcc --- /dev/null +++ b/internal/server/handlers_web_user.go @@ -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 = "" + t(locale, "userDashboard.noDevices") + "" + } else { + for _, d := range devices { + ls := d.LastSeen + if ls == "" { + ls = "—" + } + created := d.CreatedAt + if len(created) > 10 { + created = created[:10] + } + status := "" + t(locale, "userDashboard.active") + "" + revokeBtn := fmt.Sprintf(``, d.ID, t(locale, "userDashboard.revoke")) + if d.RevokedAt != "" { + status = "" + t(locale, "userDashboard.revoked") + "" + revokeBtn = "" + } + deviceRows += fmt.Sprintf(` + %s + %s + %s + %s + %s %s + `, 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) +} diff --git a/internal/server/schema.go b/internal/server/schema.go index 7a74f3e..8d8a6dc 100644 --- a/internal/server/schema.go +++ b/internal/server/schema.go @@ -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_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 TABLE IF NOT EXISTS server_smtp_config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); ` diff --git a/internal/server/server.go b/internal/server/server.go index 3ba25e1..3cd306a 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -162,6 +162,10 @@ func (s *Server) SetupRoutes() { s.routes() } +func (s *Server) locale() string { + return "ru" +} + func (s *Server) Close() error { return s.db.Close() } diff --git a/internal/server/smtp.go b/internal/server/smtp.go new file mode 100644 index 0000000..122d307 --- /dev/null +++ b/internal/server/smtp.go @@ -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) +} diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100644 index 0000000..5a7dc97 --- /dev/null +++ b/scripts/install.sh @@ -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" diff --git a/verstak-server.service b/verstak-server.service new file mode 100644 index 0000000..26927be --- /dev/null +++ b/verstak-server.service @@ -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