chore(release): configure deployment architecture, environment security, and release signing

This commit is contained in:
Vidhya 2026-10-15 21:18:21 +05:30
parent e5d3fdcaa8
commit 921ddc9ac9
13 changed files with 469 additions and 14 deletions

21
.gitignore vendored
View File

@ -1,3 +1,24 @@
__pycache__/
*.pyc
.DS_Store
# Environment and secrets
.env
.env.*
*.key
*.pem
*.p12
*.jks
*.keystore
key.properties
# Database dumps and backup archives
*.dump
*.sql
*.tar.gz
# IDE and build artifacts
.idea/
.vscode/
build/
.dart_tool/

110
deploy/backup_restore.sh Executable file
View File

@ -0,0 +1,110 @@
#!/usr/bin/env bash
# ==============================================================================
# Metatron Student ERP - Production Backup & Recovery Script
# Usage:
# ./backup_restore.sh backup [DEST_DIR]
# ./backup_restore.sh restore <DB_DUMP_FILE> <FILESTORE_ARCHIVE> <TARGET_DB>
# ./backup_restore.sh verify <DB_DUMP_FILE> <FILESTORE_ARCHIVE>
# ==============================================================================
set -euo pipefail
DB_CONTAINER="${DB_CONTAINER:-metatron_academic_db}"
ODOO_CONTAINER="${ODOO_CONTAINER:-metatron_academic}"
DB_USER="${POSTGRES_USER:-odoo}"
DB_NAME="${POSTGRES_DB:-metatron_aca_erp}"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
ACTION="${1:-help}"
case "$ACTION" in
backup)
BACKUP_DIR="${2:-./backups}"
mkdir -p "$BACKUP_DIR"
DB_FILE="$BACKUP_DIR/${DB_NAME}_${TIMESTAMP}.dump"
FS_FILE="$BACKUP_DIR/filestore_${DB_NAME}_${TIMESTAMP}.tar.gz"
echo "[+] Starting PostgreSQL database backup for '$DB_NAME'..."
docker exec -t "$DB_CONTAINER" pg_dump -U "$DB_USER" -Fc "$DB_NAME" > "$DB_FILE"
echo "[✓] Database backup created: $DB_FILE ($(du -h "$DB_FILE" | cut -f1))"
echo "[+] Starting Odoo filestore archive for '$DB_NAME'..."
docker exec "$ODOO_CONTAINER" tar -czf - -C /var/lib/odoo/filestore "$DB_NAME" > "$FS_FILE"
echo "[✓] Filestore archive created: $FS_FILE ($(du -h "$FS_FILE" | cut -f1))"
echo "[+] Calculating SHA256 checksums..."
if command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$DB_FILE" "$FS_FILE" > "$BACKUP_DIR/checksums_${TIMESTAMP}.sha256"
elif command -v sha256sum >/dev/null 2>&1; then
sha256sum "$DB_FILE" "$FS_FILE" > "$BACKUP_DIR/checksums_${TIMESTAMP}.sha256"
fi
echo "=================================================================="
echo "BACKUP SUCCESSFUL: $TIMESTAMP"
echo "Database: $DB_FILE"
echo "Filestore: $FS_FILE"
echo "=================================================================="
;;
verify)
DB_FILE="${2:-}"
FS_FILE="${3:-}"
if [[ -z "$DB_FILE" || -z "$FS_FILE" ]]; then
echo "[-] Error: Provide both database dump and filestore archive to verify."
echo " Usage: $0 verify <DB_DUMP_FILE> <FILESTORE_ARCHIVE>"
exit 1
fi
echo "[+] Verifying database archive integrity..."
# pg_restore -l lists table of contents without restoring
docker exec -i "$DB_CONTAINER" pg_restore -l < "$DB_FILE" > /dev/null
echo "[✓] Database dump archive is valid."
echo "[+] Verifying filestore tar.gz integrity..."
tar -tzf "$FS_FILE" > /dev/null
echo "[✓] Filestore tar.gz archive is valid."
echo "All backup archives verified intact."
;;
restore)
DB_FILE="${2:-}"
FS_FILE="${3:-}"
TARGET_DB="${4:-${DB_NAME}_restored}"
if [[ -z "$DB_FILE" || -z "$FS_FILE" ]]; then
echo "[-] Error: Provide DB dump file, filestore archive, and target database name."
echo " Usage: $0 restore <DB_DUMP_FILE> <FILESTORE_ARCHIVE> [TARGET_DB]"
exit 1
fi
echo "[!] WARNING: You are about to restore to database '$TARGET_DB'."
read -p "Are you sure you want to continue? (y/N): " CONFIRM
if [[ "$CONFIRM" != "y" && "$CONFIRM" != "Y" ]]; then
echo "Restore aborted by user."
exit 0
fi
echo "[+] Creating database '$TARGET_DB' if not exists..."
docker exec -i "$DB_CONTAINER" psql -U "$DB_USER" -c "CREATE DATABASE \"$TARGET_DB\" OWNER \"$DB_USER\";" 2>/dev/null || true
echo "[+] Restoring PostgreSQL database from $DB_FILE..."
docker exec -i "$DB_CONTAINER" pg_restore -U "$DB_USER" -d "$TARGET_DB" --clean --if-exists < "$DB_FILE" || true
echo "[✓] Database restore completed."
echo "[+] Restoring filestore to container..."
docker exec -i "$ODOO_CONTAINER" mkdir -p /var/lib/odoo/filestore/"$TARGET_DB"
docker exec -i "$ODOO_CONTAINER" tar -xzf - -C /var/lib/odoo/filestore/"$TARGET_DB" --strip-components=1 < "$FS_FILE"
echo "[✓] Filestore restored to /var/lib/odoo/filestore/$TARGET_DB."
;;
*)
echo "Metatron Student ERP Backup & Recovery Utility"
echo "Usage:"
echo " $0 backup [DEST_DIR] - Take full DB and filestore backup"
echo " $0 verify <DB_DUMP_FILE> <FILESTORE_ARCHIVE> - Test backup archive integrity"
echo " $0 restore <DB_DUMP_FILE> <FS_ARCHIVE> <TARGET_DB> - Restore to target database"
exit 1
;;
esac

View File

@ -0,0 +1,87 @@
services:
db:
image: postgres:15-alpine
container_name: metatron_erp_db_prod
restart: always
environment:
POSTGRES_DB: ${POSTGRES_DB:-metatron_aca_erp}
POSTGRES_USER: ${POSTGRES_USER:-odoo}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- prod_pgdata:/var/lib/postgresql/data
networks:
- internal_network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-odoo} -d ${POSTGRES_DB:-metatron_aca_erp}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "5"
odoo:
image: odoo:19.0
container_name: metatron_erp_app_prod
restart: always
depends_on:
db:
condition: service_healthy
environment:
HOST: db
PORT: 5432
USER: ${POSTGRES_USER:-odoo}
PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- prod_odoo_data:/var/lib/odoo
- ../addons:/mnt/extra-addons:ro
- ./odoo.prod.conf:/etc/odoo/odoo.conf:ro
command: odoo -c /etc/odoo/odoo.conf
networks:
- internal_network
- proxy_network
healthcheck:
test: ["CMD-SHELL", "curl -f http://127.0.0.1:8069/web/health || exit 1"]
interval: 15s
timeout: 10s
retries: 5
start_period: 30s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "5"
nginx:
image: nginx:alpine
container_name: metatron_erp_nginx_prod
restart: always
depends_on:
odoo:
condition: service_healthy
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/metatron_erp.conf:/etc/nginx/conf.d/default.conf:ro
- ./certs:/etc/nginx/certs:ro
networks:
- proxy_network
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "5"
networks:
internal_network:
internal: true
proxy_network:
driver: bridge
volumes:
prod_pgdata:
prod_odoo_data:

View File

@ -0,0 +1,93 @@
# Upstream servers
upstream odoo_backend {
server odoo:8069;
}
upstream odoo_chat {
server odoo:8072;
}
# Redirect all HTTP traffic to HTTPS
server {
listen 80;
server_name erp.yourdomain.edu;
# ACME-challenge for Let's Encrypt certificates
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
# HTTPS server
server {
listen 443 ssl http2;
server_name erp.yourdomain.edu;
# SSL certificate paths
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;
# Modern TLS configuration
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384';
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
# Security Headers
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Request body limits for mobile document and certificate uploads
client_max_body_size 25M;
# Buffer sizes
proxy_read_timeout 720s;
proxy_connect_timeout 720s;
proxy_send_timeout 720s;
# Log format
access_log /var/log/nginx/odoo.access.log;
error_log /var/log/nginx/odoo.error.log;
# Longpolling / WebSocket requests
location /websocket {
proxy_pass http://odoo_chat;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Main Odoo application and Mobile API endpoints
location / {
proxy_pass http://odoo_backend;
proxy_redirect off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Gzip compression for API payloads and static files
gzip on;
gzip_types text/css text/less text/plain text/xml application/xml application/json application/javascript;
}
# Cache static files
location ~* /web/static/ {
proxy_cache_valid 200 60m;
proxy_buffering on;
expires 864000;
proxy_pass http://odoo_backend;
}
}

35
deploy/odoo.prod.conf Normal file
View File

@ -0,0 +1,35 @@
[options]
; Database server settings
db_host = db
db_port = 5432
db_user = odoo
db_name = metatron_aca_erp
; Security: Disable Database Manager interface in production
list_db = False
; Master Password for administrative database actions
admin_passwd = CHANGE_ME_STRONG_ODOO_MASTER_PASSWORD
; Addons paths
addons_path = /usr/lib/python3/dist-packages/odoo/addons,/mnt/extra-addons
; Multi-processing and concurrency for production
workers = 5
max_cron_threads = 2
; Reverse proxy support (trusts X-Forwarded-For and X-Forwarded-Proto)
proxy_mode = True
; Time and memory limits
limit_time_cpu = 120
limit_time_real = 240
limit_memory_hard = 2684354560
limit_memory_soft = 2147483648
limit_request = 8192
; Logging configuration
log_level = info
log_handler = :INFO,odoo.addons.education_mobile_api:INFO
logfile = False
log_db = False

View File

@ -1,3 +1,12 @@
import java.util.Properties
import java.io.FileInputStream
val keystoreProperties = Properties()
val keystorePropertiesFile = rootProject.file("key.properties")
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
}
plugins {
id("com.android.application")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
@ -14,30 +23,37 @@ android {
targetCompatibility = JavaVersion.VERSION_17
}
signingConfigs {
create("release") {
keyAlias = keystoreProperties.getProperty("keyAlias")
keyPassword = keystoreProperties.getProperty("keyPassword")
storeFile = keystoreProperties.getProperty("storeFile")?.let { file(it) }
storePassword = keystoreProperties.getProperty("storePassword")
}
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
// Application ID matches reverse-domain package identifier
applicationId = "in.metatroncube.metatron_student_erp"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
// Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION
// is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions)
// You can force using the value of versionCode by specifying the `-P force-version-code-ignoring-abi=true`
// flag during build.
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
val releaseConfig = signingConfigs.getByName("release")
signingConfig = if (keystorePropertiesFile.exists() && releaseConfig.storeFile?.exists() == true) {
releaseConfig
} else {
signingConfigs.getByName("debug")
}
}
}
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17

View File

@ -2,9 +2,10 @@
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<application
android:label="metatron_student_erp"
android:label="Metatron Student ERP"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
android:icon="@mipmap/ic_launcher"
android:networkSecurityConfig="@xml/network_security_config">
<activity
android:name=".MainActivity"
android:exported="true"

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- Base configuration: Enforce secure HTTPS connections by default -->
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
<!-- Debug/Development and Local Staging Overrides -->
<debug-overrides>
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</debug-overrides>
<!-- Allow cleartext HTTP only for local emulator and localhost loopbacks during development -->
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">10.0.2.2</domain>
<domain includeSubdomains="true">localhost</domain>
<domain includeSubdomains="true">127.0.0.1</domain>
</domain-config>
</network-security-config>

View File

@ -0,0 +1,10 @@
# Metatron Student ERP - Android Release Keystore Configuration Template
# DO NOT COMMIT key.properties WITH REAL SECRETS TO SOURCE CONTROL
# Copy this file to key.properties and fill in your actual production keystore details:
# cp key.properties.example key.properties
# Ensure key.properties and *.keystore/*.jks files are in .gitignore (already configured).
storePassword=<YOUR_KEYSTORE_PASSWORD>
keyPassword=<YOUR_KEY_PASSWORD>
keyAlias=<YOUR_KEY_ALIAS>
storeFile=<PATH_TO_YOUR_KEYSTORE_FILE_E_G_upload_keystore_jks>

View File

@ -7,7 +7,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Metatron Student Erp</string>
<string>Metatron Student ERP</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>

View File

@ -34,6 +34,16 @@ class ApiClient {
// Dynamic base URL check in case it was updated
options.baseUrl = ApiConfig.baseUrl;
if (options.baseUrl.isEmpty) {
return handler.reject(
DioException(
requestOptions: options,
type: DioExceptionType.cancel,
error: 'API Server URL is not configured. Please configure server URL in Settings.',
),
);
}
// Attach session cookie if available
_sessionCookie ??= await _storage.getSessionCookie();
if (_sessionCookie != null && _sessionCookie!.isNotEmpty) {
@ -175,6 +185,11 @@ class ApiClient {
return ApiException.fromCode('SERVER_ERROR', statusCode: statusCode);
}
if (error.error != null && error.error.toString().contains('API Server URL is not configured')) {
return ApiException.fromCode('NOT_CONFIGURED', customMessage: error.error.toString());
}
if (error.type == DioExceptionType.connectionTimeout ||
error.type == DioExceptionType.receiveTimeout ||
error.type == DioExceptionType.sendTimeout ||

View File

@ -1,24 +1,63 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
enum AppEnvironment {
development,
staging,
production;
static AppEnvironment get current {
const env = String.fromEnvironment('ENVIRONMENT', defaultValue: '');
switch (env.toLowerCase()) {
case 'production':
case 'prod':
return AppEnvironment.production;
case 'staging':
case 'stage':
return AppEnvironment.staging;
case 'development':
case 'dev':
return AppEnvironment.development;
default:
return kReleaseMode ? AppEnvironment.production : AppEnvironment.development;
}
}
}
class ApiConfig {
static const String _envBaseUrl = String.fromEnvironment('API_BASE_URL', defaultValue: '');
static String _customBaseUrl = '';
static AppEnvironment get environment => AppEnvironment.current;
static bool get isProduction => environment == AppEnvironment.production;
static bool get isStaging => environment == AppEnvironment.staging;
static bool get isDevelopment => environment == AppEnvironment.development;
/// Returns the active API base URL.
/// Priority:
/// 1. Custom runtime configured URL (e.g. from Settings screen for physical devices)
/// 2. Compile-time --dart-define=API_BASE_URL=...
/// 3. Platform default: 10.0.2.2 on Android Emulator, localhost on iOS/macOS/Web
/// 3. In development/testing: platform default (10.0.2.2 on Android Emulator, localhost on iOS/macOS/Web)
/// 4. In production: returns empty if unconfigured (no hardcoded localhost fallback in production builds)
static String get baseUrl {
if (_customBaseUrl.isNotEmpty) {
return _customBaseUrl;
}
if (_envBaseUrl.isNotEmpty) {
if (isProduction && !_envBaseUrl.startsWith('https://')) {
debugPrint('WARNING: Production API_BASE_URL should use HTTPS: $_envBaseUrl');
}
return _envBaseUrl;
}
// In production without compile-time or runtime override, do not default to localhost
if (isProduction) {
return '';
}
if (kIsWeb) {
return 'http://localhost:10008';
}
@ -47,3 +86,4 @@ class ApiConfig {
static const Duration connectTimeout = Duration(seconds: 15);
static const Duration receiveTimeout = Duration(seconds: 15);
}

View File

@ -18,6 +18,9 @@ class ApiException implements Exception {
case 'AUTH_REQUIRED':
message = 'Your session has expired. Please login again.';
break;
case 'NOT_CONFIGURED':
message = 'API Server URL is not configured. Please configure server URL in Settings.';
break;
case 'STUDENT_NOT_LINKED':
message = 'No student profile is linked to this account.';
break;