feat: dockerize libredesk

- feat: new flag for idempotent installation `--idempotent-install`
- feat: new flag to skip yes/no prompt `--yes`
- feat: new flag for upgrades `--upgrade`
- refactor: update doc strings and sample toml file.
- chore: update .gitignore.
This commit is contained in:
Abhinav Raut
2025-02-22 23:32:47 +05:30
parent 730740094f
commit 5361bcb24f
11 changed files with 185 additions and 50 deletions

4
.gitignore vendored
View File

@@ -1,5 +1,7 @@
node_modules
config.toml
config.toml.*
libredesk.bin
uploads/*
uploads
.env
dist/

17
Dockerfile Normal file
View File

@@ -0,0 +1,17 @@
# Use the latest version of Alpine Linux as the base image
FROM alpine:latest
# Install necessary packages
RUN apk --no-cache add ca-certificates
# Set the working directory to /libredesk
WORKDIR /libredesk
# Copy the libredesk binary to the working directory
COPY libredesk.bin .
# Expose port 9000 for the application
EXPOSE 9000
# Set the default command to run the libredesk binary
CMD ["./libredesk.bin"]

View File

@@ -98,6 +98,9 @@ func initFlags() {
"path to one or more config files (will be merged in order)")
f.Bool("version", false, "show current version of the build")
f.Bool("install", false, "setup database")
f.Bool("idempotent-install", false, "run idempotent installation, i.e., skip installion if schema is already installed useful for the first time setup")
f.Bool("yes", false, "skip confirmation prompt")
f.Bool("upgrade", false, "upgrade the database schema")
f.Bool("set-system-user-password", false, "set password for the system user")
if err := f.Parse(os.Args[1:]); err != nil {

View File

@@ -4,23 +4,38 @@ import (
"context"
"fmt"
"log"
"os"
"strings"
"time"
"github.com/abhinavxd/libredesk/internal/colorlog"
"github.com/abhinavxd/libredesk/internal/user"
"github.com/jmoiron/sqlx"
"github.com/knadh/stuffbin"
"github.com/lib/pq"
)
// install checks if the schema is already installed, prompts for confirmation, and installs the schema if needed.
func install(ctx context.Context, db *sqlx.DB, fs stuffbin.FileSystem) error {
installed, err := checkSchema(db)
// Install checks if the schema is already installed, prompts for confirmation, and installs the schema if needed.
// idempotent install skips the installation if the database schema is already installed.
func install(ctx context.Context, db *sqlx.DB, fs stuffbin.FileSystem, idempotentInstall, prompt bool) error {
schemaInstalled, err := checkSchema(db)
if err != nil {
log.Fatalf("error checking db schema: %v", err)
log.Fatalf("error checking existing db schema: %v", err)
}
if installed {
fmt.Printf("\033[31m** WARNING: This will wipe your entire database - '%s' **\033[0m\n", ko.String("db.database"))
fmt.Print("Continue (y/n)? ")
// Make sure the system user password is strong enough.
password := strings.TrimSpace(os.Getenv("LIBREDESK_SYSTEM_USER_PASSWORD"))
if password != "" && !user.IsStrongSystemUserPassword(password) && !schemaInstalled {
log.Fatalf("system user password is not strong, %s", user.SystemUserPasswordHint)
}
if !idempotentInstall {
log.Println("running first time setup...")
colorlog.Red(fmt.Sprintf("WARNING: This will wipe your entire database - '%s'", ko.String("db.database")))
}
if prompt {
log.Print("Continue (y/n)? ")
var ok string
fmt.Scanf("%s", &ok)
if !strings.EqualFold(ok, "y") {
@@ -28,15 +43,26 @@ func install(ctx context.Context, db *sqlx.DB, fs stuffbin.FileSystem) error {
}
}
if idempotentInstall {
if schemaInstalled {
log.Println("skipping installation as schema is already installed")
os.Exit(0)
}
} else {
time.Sleep(5 * time.Second)
}
log.Println("installing database schema...")
// Install schema.
if err := installSchema(db, fs); err != nil {
log.Fatalf("error installing schema: %v", err)
}
log.Println("Schema installed successfully")
log.Println("database schema installed successfully")
// Create system user.
if err := user.CreateSystemUser(ctx, db); err != nil {
if err := user.CreateSystemUser(ctx, password, db); err != nil {
log.Fatalf("error creating system user: %v", err)
}
return nil

View File

@@ -114,7 +114,7 @@ func main() {
// Installer.
if ko.Bool("install") {
install(ctx, db, fs)
install(ctx, db, fs, ko.Bool("idempotent-install"), !ko.Bool("yes"))
os.Exit(0)
}
@@ -130,7 +130,13 @@ func main() {
log.Fatalf("error checking db schema: %v", err)
}
if !installed {
log.Println("Database tables are missing. Use the `--install` flag to set up the database schema.")
log.Println("database tables are missing. Use the `--install` flag to set up the database schema.")
os.Exit(0)
}
// Upgrade.
if ko.Bool("upgrade") {
log.Println("no upgrades available")
os.Exit(0)
}

View File

@@ -12,13 +12,13 @@ write_timeout = "5s"
max_body_size = 500000000
keepalive_timeout = "10s"
# File upload provider.
# File upload provider to use.
[upload]
provider = "fs"
# Filesytem provider.
[upload.fs]
upload_path = '/home/ubuntu/uploads'
upload_path = 'uploads'
# S3 provider.
[upload.s3]
@@ -32,6 +32,7 @@ expiry = "6h"
# Postgres.
[db]
# If using docker compose, use the service name as the host.
host = "127.0.0.1"
port = 5432
user = "postgres"
@@ -44,6 +45,7 @@ max_lifetime = "300s"
# Redis.
[redis]
# If using docker compose, use the service name as the host.
address = "127.0.0.1:6379"
password = ""
db = 0

62
docker-compose.yml Normal file
View File

@@ -0,0 +1,62 @@
services:
# Libredesk app
app:
image: libredesk:latest
container_name: libredesk_app
restart: unless-stopped
ports:
- "9000:9000"
environment:
# If the password is set during first docker-compose up, the system user password will be set to this value.
# You can always set system user password later by running `docker exec -it libredesk_app ./libredesk.bin --set-system-user-password`.
LIBREDESK_SYSTEM_USER_PASSWORD: ${LIBREDESK_SYSTEM_USER_PASSWORD:-}
networks:
- libredesk
depends_on:
- db
- redis
volumes:
- ./uploads:/libredesk/uploads:rw
- ./config.toml:/libredesk/config.toml
command: [sh, -c, "./libredesk.bin --install --idempotent-install --yes --config /libredesk/config.toml && ./libredesk.bin --upgrade --yes --config /libredesk/config.toml && ./libredesk.bin --config /libredesk/config.toml"]
# PostgreSQL database
db:
image: postgres:17-alpine
container_name: libredesk_db
restart: unless-stopped
networks:
- libredesk
ports:
- "5432:5432"
environment:
# Set these environment variables to configure the database, defaults to libredesk.
POSTGRES_USER: ${POSTGRES_USER:-libredesk}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-libredesk}
POSTGRES_DB: ${POSTGRES_DB:-libredesk}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U libredesk"]
interval: 10s
timeout: 5s
retries: 6
volumes:
- postgres-data:/var/lib/postgresql/data
# Redis
redis:
image: redis:7-alpine
container_name: libredesk_redis
restart: unless-stopped
ports:
- "6379:6379"
networks:
- libredesk
volumes:
- redis-data:/data
networks:
libredesk:
volumes:
postgres-data:
redis-data:

View File

@@ -289,6 +289,7 @@ func (a *Auth) SetCSRFCookie(r *fastglue.Request) error {
return nil
}
// ValidateSession validates the session and returns the user.
func (a *Auth) ValidateSession(r *fastglue.Request) (models.User, error) {
a.mu.RLock()
defer a.mu.RUnlock()

View File

@@ -1,4 +1,4 @@
// package colorlog provides logging in color.
// package colorlog provides ANSI color logging for the terminal.
package colorlog
import "log"

View File

@@ -54,7 +54,7 @@ const (
)
// Run starts a pool of worker goroutines to handle message dispatching via inbox's channel and processes incoming messages. It scans for
// pending outgoing messages at the specified read interval and pushes them to the outgoing queue.
// pending outgoing messages at the specified read interval and pushes them to the outgoing queue to be sent.
func (m *Manager) Run(ctx context.Context, incomingQWorkers, outgoingQWorkers, scanInterval time.Duration) {
dbScanner := time.NewTicker(scanInterval)
defer dbScanner.Stop()

View File

@@ -11,6 +11,8 @@ import (
"regexp"
"strings"
"log"
"github.com/abhinavxd/libredesk/internal/dbutil"
"github.com/abhinavxd/libredesk/internal/envelope"
rmodels "github.com/abhinavxd/libredesk/internal/role/models"
@@ -24,6 +26,14 @@ import (
"golang.org/x/crypto/bcrypt"
)
const (
systemUserEmail = "System"
minSystemUserPassword = 8
maxSystemUserPassword = 50
UserTypeAgent = "agent"
UserTypeContact = "contact"
)
var (
//go:embed queries.sql
efs embed.FS
@@ -31,14 +41,8 @@ var (
// ErrPasswordTooLong is returned when the password passed to
// GenerateFromPassword is too long (i.e. > 72 bytes).
ErrPasswordTooLong = errors.New("password length exceeds 72 bytes")
)
const (
SystemUserEmail = "System"
MinSystemUserPasswordLen = 8
MaxSystemUserPasswordLen = 50
UserTypeAgent = "agent"
UserTypeContact = "contact"
SystemUserPasswordHint = fmt.Sprintf("Password must be %d-%d characters long and contain at least one uppercase letter and one number", minSystemUserPassword, maxSystemUserPassword)
)
// Manager handles user-related operations.
@@ -179,7 +183,7 @@ func (u *Manager) GetByEmail(email string) (models.User, error) {
// GetSystemUser retrieves the system user.
func (u *Manager) GetSystemUser() (models.User, error) {
return u.GetByEmail(SystemUserEmail)
return u.GetByEmail(systemUserEmail)
}
// UpdateAvatar updates the user avatar.
@@ -200,7 +204,7 @@ func (u *Manager) Update(id int, user models.User) error {
if user.NewPassword != "" {
if !u.isStrongPassword(user.NewPassword) {
return envelope.NewError(envelope.InputError, "Entered password is not strong please make sure the password has min 8, max 50 characters, at least 1 uppercase letter, 1 number", nil)
return envelope.NewError(envelope.InputError, SystemUserPasswordHint, nil)
}
hashedPassword, err = bcrypt.GenerateFromPassword([]byte(user.NewPassword), bcrypt.DefaultCost)
if err != nil {
@@ -265,7 +269,7 @@ func (u *Manager) SetResetPasswordToken(id int) (string, error) {
// ResetPassword sets a new password for a user.
func (u *Manager) ResetPassword(token, password string) error {
if !u.isStrongPassword(password) {
return envelope.NewError(envelope.InputError, "Entered password is not strong please make sure the password has min 8, max 50 characters, at least 1 uppercase letter, 1 number", nil)
return envelope.NewError(envelope.InputError, "Password is not strong enough, " + SystemUserPasswordHint, nil)
}
// Hash password.
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
@@ -311,7 +315,7 @@ func (u *Manager) generatePassword() ([]byte, error) {
// isStrongPassword checks if the password meets the required strength.
func (u *Manager) isStrongPassword(password string) bool {
if len(password) < MinSystemUserPasswordLen || len(password) > MaxSystemUserPasswordLen {
if len(password) < minSystemUserPassword || len(password) > maxSystemUserPassword {
return false
}
hasUppercase := regexp.MustCompile(`[A-Z]`).MatchString(password)
@@ -331,16 +335,29 @@ func ChangeSystemUserPassword(ctx context.Context, db *sqlx.DB) error {
if err := updateSystemUserPassword(db, hashedPassword); err != nil {
return fmt.Errorf("error updating system user password: %v", err)
}
fmt.Println("System user password updated successfully.")
fmt.Println("password updated successfully.")
return nil
}
// CreateSystemUser inserts a default system user into the users table with the prompted password.
func CreateSystemUser(ctx context.Context, db *sqlx.DB) error {
hashedPassword, err := promptAndHashPassword(ctx)
// CreateSystemUser creates a system user with the provided password or a random one.
func CreateSystemUser(ctx context.Context, password string, db *sqlx.DB) error {
var err error
// Set random password if not provided.
if password == "" {
password, err = stringutil.RandomAlphanumeric(32)
if err != nil {
return err
return fmt.Errorf("failed to generate system used password: %v", err)
}
} else {
log.Print("using provided password for system user")
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("failed to hash system user password: %v", err)
}
_, err = db.Exec(`
WITH sys_user AS (
INSERT INTO users (email, type, first_name, last_name, password)
@@ -351,14 +368,24 @@ func CreateSystemUser(ctx context.Context, db *sqlx.DB) error {
SELECT sys_user.id, roles.id
FROM sys_user, roles
WHERE roles.name = $6`,
SystemUserEmail, UserTypeAgent, "System", "", hashedPassword, rmodels.RoleAdmin)
systemUserEmail, UserTypeAgent, "System", "", hashedPassword, rmodels.RoleAdmin)
if err != nil {
return fmt.Errorf("failed to create system user: %v", err)
}
fmt.Println("System user created successfully")
log.Print("system user created successfully")
return nil
}
// IsStrongSystemUserPassword checks if the password meets the required strength for system user.
func IsStrongSystemUserPassword(password string) bool {
if len(password) < minSystemUserPassword || len(password) > maxSystemUserPassword {
return false
}
hasUppercase := regexp.MustCompile(`[A-Z]`).MatchString(password)
hasNumber := regexp.MustCompile(`[0-9]`).MatchString(password)
return hasUppercase && hasNumber
}
// promptAndHashPassword handles password input and validation, and returns the hashed password.
func promptAndHashPassword(ctx context.Context) ([]byte, error) {
for {
@@ -366,15 +393,14 @@ func promptAndHashPassword(ctx context.Context) ([]byte, error) {
case <-ctx.Done():
return nil, ctx.Err()
default:
fmt.Print("Please set System admin password (min 8, max 50 characters, at least 1 uppercase letter, 1 number): ")
fmt.Printf("Please set System user password (%s): ", SystemUserPasswordHint)
buffer := make([]byte, 256)
n, err := os.Stdin.Read(buffer)
if err != nil {
return nil, fmt.Errorf("error reading input: %v", err)
}
password := strings.TrimSpace(string(buffer[:n]))
if isStrongSystemUserPassword(password) {
if IsStrongSystemUserPassword(password) {
// Hash the password using bcrypt.
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
@@ -389,19 +415,9 @@ func promptAndHashPassword(ctx context.Context) ([]byte, error) {
// updateSystemUserPassword updates the password of the system user in the database.
func updateSystemUserPassword(db *sqlx.DB, hashedPassword []byte) error {
_, err := db.Exec(`UPDATE users SET password = $1 WHERE email = $2`, hashedPassword, SystemUserEmail)
_, err := db.Exec(`UPDATE users SET password = $1 WHERE email = $2`, hashedPassword, systemUserEmail)
if err != nil {
return fmt.Errorf("failed to update system user password: %v", err)
}
return nil
}
// isStrongSystemUserPassword checks if the password meets the required strength for system user.
func isStrongSystemUserPassword(password string) bool {
if len(password) < MinSystemUserPasswordLen || len(password) > MaxSystemUserPasswordLen {
return false
}
hasUppercase := regexp.MustCompile(`[A-Z]`).MatchString(password)
hasNumber := regexp.MustCompile(`[0-9]`).MatchString(password)
return hasUppercase && hasNumber
}