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 node_modules
config.toml config.toml
config.toml.*
libredesk.bin libredesk.bin
uploads/* uploads
.env .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)") "path to one or more config files (will be merged in order)")
f.Bool("version", false, "show current version of the build") f.Bool("version", false, "show current version of the build")
f.Bool("install", false, "setup database") 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") f.Bool("set-system-user-password", false, "set password for the system user")
if err := f.Parse(os.Args[1:]); err != nil { if err := f.Parse(os.Args[1:]); err != nil {

View File

@@ -4,23 +4,38 @@ import (
"context" "context"
"fmt" "fmt"
"log" "log"
"os"
"strings" "strings"
"time"
"github.com/abhinavxd/libredesk/internal/colorlog"
"github.com/abhinavxd/libredesk/internal/user" "github.com/abhinavxd/libredesk/internal/user"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/knadh/stuffbin" "github.com/knadh/stuffbin"
"github.com/lib/pq" "github.com/lib/pq"
) )
// install checks if the schema is already installed, prompts for confirmation, and installs the schema if needed. // 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 { // idempotent install skips the installation if the database schema is already installed.
installed, err := checkSchema(db) func install(ctx context.Context, db *sqlx.DB, fs stuffbin.FileSystem, idempotentInstall, prompt bool) error {
schemaInstalled, err := checkSchema(db)
if err != nil { 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")) // Make sure the system user password is strong enough.
fmt.Print("Continue (y/n)? ") 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 var ok string
fmt.Scanf("%s", &ok) fmt.Scanf("%s", &ok)
if !strings.EqualFold(ok, "y") { 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. // Install schema.
if err := installSchema(db, fs); err != nil { if err := installSchema(db, fs); err != nil {
log.Fatalf("error installing schema: %v", err) log.Fatalf("error installing schema: %v", err)
} }
log.Println("Schema installed successfully") log.Println("database schema installed successfully")
// Create system user. // 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) log.Fatalf("error creating system user: %v", err)
} }
return nil return nil

View File

@@ -114,7 +114,7 @@ func main() {
// Installer. // Installer.
if ko.Bool("install") { if ko.Bool("install") {
install(ctx, db, fs) install(ctx, db, fs, ko.Bool("idempotent-install"), !ko.Bool("yes"))
os.Exit(0) os.Exit(0)
} }
@@ -130,7 +130,13 @@ func main() {
log.Fatalf("error checking db schema: %v", err) log.Fatalf("error checking db schema: %v", err)
} }
if !installed { 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) os.Exit(0)
} }

View File

@@ -12,13 +12,13 @@ write_timeout = "5s"
max_body_size = 500000000 max_body_size = 500000000
keepalive_timeout = "10s" keepalive_timeout = "10s"
# File upload provider. # File upload provider to use.
[upload] [upload]
provider = "fs" provider = "fs"
# Filesytem provider. # Filesytem provider.
[upload.fs] [upload.fs]
upload_path = '/home/ubuntu/uploads' upload_path = 'uploads'
# S3 provider. # S3 provider.
[upload.s3] [upload.s3]
@@ -32,6 +32,7 @@ expiry = "6h"
# Postgres. # Postgres.
[db] [db]
# If using docker compose, use the service name as the host.
host = "127.0.0.1" host = "127.0.0.1"
port = 5432 port = 5432
user = "postgres" user = "postgres"
@@ -44,6 +45,7 @@ max_lifetime = "300s"
# Redis. # Redis.
[redis] [redis]
# If using docker compose, use the service name as the host.
address = "127.0.0.1:6379" address = "127.0.0.1:6379"
password = "" password = ""
db = 0 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 return nil
} }
// ValidateSession validates the session and returns the user.
func (a *Auth) ValidateSession(r *fastglue.Request) (models.User, error) { func (a *Auth) ValidateSession(r *fastglue.Request) (models.User, error) {
a.mu.RLock() a.mu.RLock()
defer a.mu.RUnlock() 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 package colorlog
import "log" 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 // 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) { func (m *Manager) Run(ctx context.Context, incomingQWorkers, outgoingQWorkers, scanInterval time.Duration) {
dbScanner := time.NewTicker(scanInterval) dbScanner := time.NewTicker(scanInterval)
defer dbScanner.Stop() defer dbScanner.Stop()

View File

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