1113 lines
35 KiB
Markdown
1113 lines
35 KiB
Markdown
# Container Bootstrap Plan — Automating GB Container Deployment
|
|
|
|
## Overview
|
|
|
|
This document describes how to improve `installer.rs` to automate the deployment of General Bots containers on Incus. The goal is to replicate what was done manually during the pragmatismo migration from LXD to Incus.
|
|
|
|
---
|
|
|
|
# rust incus install base
|
|
sudo apt install -y curl gnupg
|
|
|
|
|
|
sudo mkdir -p /etc/apt/keyrings
|
|
|
|
|
|
sudo curl -fsSL https://pkgs.zabbly.com/key.asc -o /etc/apt/keyrings/zabbly.asc
|
|
|
|
|
|
sudo sh -c 'cat > /etc/apt/sources.list.d/zabbly-incus-stable.sources << EOF
|
|
Enabled: yes
|
|
Types: deb
|
|
URIs: https://pkgs.zabbly.com/incus/stable
|
|
Suites: bookworm
|
|
Components: main
|
|
Architectures: $(dpkg --print-architecture)
|
|
Signed-By: /etc/apt/keyrings/zabbly.asc
|
|
EOF'
|
|
|
|
|
|
sudo apt update
|
|
|
|
|
|
sudo apt install incus
|
|
|
|
|
|
## What Was Done Manually (Reference Implementation)
|
|
|
|
### Migration Summary (pragmatismo tenant)
|
|
| Item | Detail |
|
|
|------|--------|
|
|
| Source | LXD 5.21 @ 82.29.59.188 |
|
|
| Destination | Incus 6.x @ 63.141.255.9 |
|
|
| Method | `incus copy --instance-only lxd-source:<name>` |
|
|
| Data transfer | tar.gz → push to containers |
|
|
| Containers | 10 (dns, email, webmail, alm, drive, tables, system, proxy, alm-ci, table-editor) |
|
|
| Total data | ~44 GB |
|
|
|
|
---
|
|
|
|
## Container Architecture (Reference)
|
|
|
|
### Container Types & Services
|
|
|
|
| Container | Purpose | Ports | Service Binary | Service User |
|
|
|-----------|---------|-------|---------------|-------------|
|
|
| **dns** | CoreDNS | 53 | `/opt/gbo/bin/coredns` | root |
|
|
| **email** | Stalwart mail | 25,143,465,587,993,995,110,4190 | `/opt/gbo/bin/stalwart` | root |
|
|
| **webmail** | Roundcube/PHP | 80,443 | Apache (`/usr/sbin/apache2`) | www-data |
|
|
| **alm** | Forgejo ALM | 4747 | `/opt/gbo/bin/forgejo` | gbuser |
|
|
| **drive** | MinIO S3 | 9000,9001 | `/opt/gbo/bin/minio` | root |
|
|
| **tables** | PostgreSQL | 5432 | system-installed | root |
|
|
| **system** | botserver + stack | 5858, 8200, 6379, 6333, 9100 | `/opt/gbo/bin/botserver` | gbuser |
|
|
| **proxy** | Caddy | 80, 443 | `/usr/bin/caddy` | gbuser |
|
|
| **alm-ci** | Forgejo runner | none | `/opt/gbo/bin/forgejo-runner` | root |
|
|
| **table-editor** | NocoDB | 8080 | system-installed | root |
|
|
|
|
**RULE: ALL services run as gbuser where possible, ALL data under /opt/gbo, Service name = container name (e.g., proxy-caddy.service)**
|
|
|
|
### Network Layout
|
|
```
|
|
Host (63.141.255.9)
|
|
├── Incus bridge (10.107.115.x)
|
|
│ ├── dns (10.107.115.155)
|
|
│ ├── email (10.107.115.200)
|
|
│ ├── webmail (10.107.115.87)
|
|
│ ├── alm (10.107.115.4)
|
|
│ ├── drive (10.107.115.114)
|
|
│ ├── tables (10.107.115.33)
|
|
│ ├── system (10.107.115.229)
|
|
│ ├── proxy (10.107.115.189)
|
|
│ ├── alm-ci (10.107.115.190)
|
|
│ └── table-editor (10.107.115.73)
|
|
└── iptables NAT → external ports
|
|
```
|
|
|
|
---
|
|
|
|
## Key Paths (Must Match Production)
|
|
|
|
Inside each container:
|
|
```
|
|
/opt/gbo/
|
|
├── bin/ # binaries (coredns, stalwart, forgejo, caddy, minio, postgres)
|
|
├── conf/ # service configs (Corefile, config.toml, app.ini)
|
|
├── data/ # app data (zone files, databases, repos)
|
|
└── logs/ # service logs
|
|
```
|
|
|
|
On host:
|
|
```
|
|
/opt/gbo/tenants/<tenant>/
|
|
├── dns/
|
|
│ ├── bin/
|
|
│ ├── conf/
|
|
│ ├── data/
|
|
│ └── logs/
|
|
├── email/
|
|
├── webmail/
|
|
├── alm/
|
|
├── drive/
|
|
├── tables/
|
|
├── system/
|
|
├── proxy/
|
|
├── alm-ci/
|
|
└── table-editor/
|
|
```
|
|
|
|
---
|
|
|
|
## Service Files (Templates)
|
|
|
|
**RULE: ALL services run as gbuser where possible, Service name = container name (e.g., dns.service, proxy-caddy.service)**
|
|
|
|
### dns.service (CoreDNS)
|
|
```ini
|
|
[Unit]
|
|
Description=CoreDNS
|
|
After=network.target
|
|
|
|
[Service]
|
|
User=root
|
|
WorkingDirectory=/opt/gbo
|
|
ExecStart=/opt/gbo/bin/coredns -conf /opt/gbo/conf/Corefile
|
|
Restart=always
|
|
RestartSec=5
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
```
|
|
|
|
### email.service (Stalwart)
|
|
```ini
|
|
[Unit]
|
|
Description=Stalwart Mail Server
|
|
After=network.target
|
|
|
|
[Service]
|
|
Type=simple
|
|
User=root
|
|
WorkingDirectory=/opt/gbo
|
|
ExecStart=/opt/gbo/bin/stalwart --config /opt/gbo/conf/config.toml
|
|
Restart=always
|
|
RestartSec=5
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
```
|
|
|
|
### proxy-caddy.service
|
|
```ini
|
|
[Unit]
|
|
Description=Caddy Reverse Proxy
|
|
After=network.target
|
|
|
|
[Service]
|
|
User=gbuser
|
|
Group=gbuser
|
|
WorkingDirectory=/opt/gbo
|
|
ExecStart=/usr/bin/caddy run --config /opt/gbo/conf/config --adapter caddyfile
|
|
Restart=always
|
|
RestartSec=5
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
```
|
|
|
|
### alm.service (Forgejo)
|
|
```ini
|
|
[Unit]
|
|
Description=Forgejo Git Server
|
|
After=network.target
|
|
|
|
[Service]
|
|
User=gbuser
|
|
Group=gbuser
|
|
WorkingDirectory=/opt/gbo
|
|
ExecStart=/opt/gbo/bin/forgejo web --config /opt/gbo/conf/app.ini
|
|
Restart=always
|
|
RestartSec=5
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
```
|
|
|
|
### drive-minio.service
|
|
```ini
|
|
[Unit]
|
|
Description=MinIO Object Storage
|
|
After=network-online.target
|
|
Wants=network-online.target
|
|
|
|
[Service]
|
|
User=gbuser
|
|
Group=gbuser
|
|
WorkingDirectory=/opt/gbo
|
|
ExecStart=/opt/gbo/bin/minio server --console-address :4646 /opt/gbo/data
|
|
Restart=always
|
|
RestartSec=10
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
```
|
|
|
|
### tables-postgresql.service
|
|
```ini
|
|
[Unit]
|
|
Description=PostgreSQL
|
|
After=network.target
|
|
|
|
[Service]
|
|
User=gbuser
|
|
Group=gbuser
|
|
WorkingDirectory=/opt/gbo
|
|
ExecStart=/opt/gbo/bin/postgres -D /opt/gbo/data -c config_file=/opt/gbo/conf/postgresql.conf
|
|
Restart=always
|
|
RestartSec=5
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
```
|
|
|
|
### webmail-apache.service
|
|
```ini
|
|
[Unit]
|
|
Description=Apache Webmail
|
|
After=network.target
|
|
|
|
[Service]
|
|
User=www-data
|
|
Group=www-data
|
|
WorkingDirectory=/var/www/html
|
|
ExecStart=/usr/sbin/apache2 -D FOREGROUND
|
|
Restart=always
|
|
RestartSec=5
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
```
|
|
|
|
---
|
|
|
|
## iptables NAT Rules (CRITICAL - Use ONLY iptables, NEVER socat or Incus proxy devices)
|
|
|
|
### Prerequisites
|
|
```bash
|
|
# Enable IP forwarding (persistent)
|
|
echo "net.ipv4.ip_forward = 1" | sudo tee /etc/sysctl.d/99-ipforward.conf
|
|
sudo sysctl -w net.ipv4.ip_forward=1
|
|
|
|
# Enable route_localnet for NAT to work with localhost
|
|
echo "net.ipv4.conf.all.route_localnet = 1" | sudo tee /etc/sysctl.d/99-localnet.conf
|
|
sudo sysctl -w net.ipv4.conf.all.route_localnet=1
|
|
```
|
|
|
|
### Required NAT Rules (Complete Set)
|
|
```bash
|
|
# ==================
|
|
# DNS (dns container)
|
|
# ==================
|
|
sudo iptables -t nat -A PREROUTING -p udp --dport 53 -j DNAT --to-destination 10.107.115.155:53
|
|
sudo iptables -t nat -A PREROUTING -p tcp --dport 53 -j DNAT --to-destination 10.107.115.155:53
|
|
sudo iptables -t nat -A OUTPUT -p udp --dport 53 -j DNAT --to-destination 10.107.115.155:53
|
|
sudo iptables -t nat -A OUTPUT -p tcp --dport 53 -j DNAT --to-destination 10.107.115.155:53
|
|
|
|
# ==================
|
|
# Tables (PostgreSQL) - External port 4445
|
|
# ==================
|
|
sudo iptables -t nat -A PREROUTING -p tcp --dport 4445 -j DNAT --to-destination 10.107.115.33:5432
|
|
sudo iptables -t nat -A OUTPUT -p tcp --dport 4445 -j DNAT --to-destination 10.107.115.33:5432
|
|
|
|
# ==================
|
|
# Proxy (Caddy) - 80, 443
|
|
# ==================
|
|
sudo iptables -t nat -A PREROUTING -p tcp --dport 80 -j DNAT --to-destination 10.107.115.189:80
|
|
sudo iptables -t nat -A PREROUTING -p tcp --dport 443 -j DNAT --to-destination 10.107.115.189:443
|
|
|
|
# ==================
|
|
# Email (email container) - Stalwart
|
|
# ==================
|
|
sudo iptables -t nat -A PREROUTING -p tcp --dport 25 -j DNAT --to-destination 10.107.115.200:25
|
|
sudo iptables -t nat -A PREROUTING -p tcp --dport 465 -j DNAT --to-destination 10.107.115.200:465
|
|
sudo iptables -t nat -A PREROUTING -p tcp --dport 587 -j DNAT --to-destination 10.107.115.200:587
|
|
sudo iptables -t nat -A PREROUTING -p tcp --dport 993 -j DNAT --to-destination 10.107.115.200:993
|
|
sudo iptables -t nat -A PREROUTING -p tcp --dport 995 -j DNAT --to-destination 10.107.115.200:995
|
|
sudo iptables -t nat -A PREROUTING -p tcp --dport 143 -j DNAT --to-destination 10.107.115.200:143
|
|
sudo iptables -t nat -A PREROUTING -p tcp --dport 110 -j DNAT --to-destination 10.107.115.200:110
|
|
sudo iptables -t nat -A PREROUTING -p tcp --dport 4190 -j DNAT --to-destination 10.107.115.200:4190
|
|
|
|
# ==================
|
|
# FORWARD rules (required for containers to receive traffic)
|
|
# ==================
|
|
sudo iptables -A FORWARD -p tcp -d 10.107.115.155 --dport 53 -j ACCEPT
|
|
sudo iptables -A FORWARD -p udp -d 10.107.115.155 --dport 53 -j ACCEPT
|
|
sudo iptables -A FORWARD -p tcp -d 10.107.115.33 --dport 5432 -j ACCEPT
|
|
sudo iptables -A FORWARD -p tcp -s 10.107.115.33 --sport 5432 -j ACCEPT
|
|
sudo iptables -A FORWARD -p tcp -d 10.107.115.189 --dport 80 -j ACCEPT
|
|
sudo iptables -A FORWARD -p tcp -d 10.107.115.189 --dport 443 -j ACCEPT
|
|
sudo iptables -A FORWARD -p tcp -s 10.107.115.189 -j ACCEPT
|
|
sudo iptables -A FORWARD -p tcp -d 10.107.115.200 -j ACCEPT
|
|
sudo iptables -A FORWARD -p tcp -s 10.107.115.200 -j ACCEPT
|
|
|
|
# ==================
|
|
# POSTROUTING MASQUERADE (for return traffic)
|
|
# ==================
|
|
sudo iptables -t nat -A POSTROUTING -p tcp -d 10.107.115.155 -j MASQUERADE
|
|
sudo iptables -t nat -A POSTROUTING -p udp -d 10.107.115.155 -j MASQUERADE
|
|
sudo iptables -t nat -A POSTROUTING -p tcp -d 10.107.115.33 -j MASQUERADE
|
|
sudo iptables -t nat -A POSTROUTING -p tcp -d 10.107.115.189 -j MASQUERADE
|
|
sudo iptables -t nat -A POSTROUTING -p tcp -d 10.107.115.200 -j MASQUERADE
|
|
|
|
# ==================
|
|
# INPUT rules (allow incoming)
|
|
# ==================
|
|
sudo iptables -A INPUT -p tcp --dport 80 -j ACCEPT
|
|
sudo iptables -A INPUT -p tcp --dport 443 -j ACCEPT
|
|
sudo iptables -A INPUT -p tcp --dport 4445 -j ACCEPT
|
|
sudo iptables -A INPUT -p tcp --dport 53 -j ACCEPT
|
|
sudo iptables -A INPUT -p udp --dport 53 -j ACCEPT
|
|
|
|
# ==================
|
|
# Save rules persistently
|
|
# ==================
|
|
sudo sh -c 'iptables-save > /etc/iptables/rules.v4'
|
|
```
|
|
|
|
### IMPORTANT RULES
|
|
|
|
1. **NEVER use socat** - It causes port conflicts and doesn't integrate with iptables NAT
|
|
2. **NEVER use Incus proxy devices** - They conflict with iptables NAT rules
|
|
3. **ALWAYS add OUTPUT rules** - PREROUTING only handles external traffic; local traffic needs OUTPUT
|
|
4. **ALWAYS add FORWARD rules** - Without them, traffic won't reach containers
|
|
5. **ALWAYS add POSTROUTING MASQUERADE** - Without it, return traffic won't work
|
|
6. **ALWAYS set route_localnet=1** - Required for localhost NAT to work
|
|
|
|
### Testing NAT
|
|
```bash
|
|
# Test from host
|
|
nc -zv 127.0.0.1 4445
|
|
# Should connect to PostgreSQL at 10.107.115.33:5432
|
|
|
|
# Test from external
|
|
nc -zv 63.141.255.9 4445
|
|
# Should connect to PostgreSQL at 10.107.115.33:5432
|
|
|
|
# Test DNS
|
|
dig @127.0.0.1 webmail.pragmatismo.com.br
|
|
# Should return 63.141.255.9
|
|
```
|
|
|
|
---
|
|
|
|
## CoreDNS Setup
|
|
|
|
### Corefile Template
|
|
```corefile
|
|
ddsites.com.br:53 {
|
|
file /opt/gbo/data/ddsites.com.br.zone
|
|
bind 0.0.0.0
|
|
reload 6h
|
|
acl {
|
|
allow type ANY net 10.0.0.0/8 127.0.0.0/8
|
|
allow type ANY net <HOST_IP>/32
|
|
allow type A net 0.0.0.0/0
|
|
allow type AAAA net 0.0.0.0/0
|
|
allow type MX net 0.0.0.0/0
|
|
allow type TXT net 0.0.0.0/0
|
|
allow type NS net 0.0.0.0/0
|
|
allow type SOA net 0.0.0.0/0
|
|
allow type SRV net 0.0.0.0/0
|
|
allow type CNAME net 0.0.0.0/0
|
|
allow type HTTPS net 0.0.0.0/0
|
|
allow type CAA net 0.0.0.0/0
|
|
block
|
|
}
|
|
cache
|
|
errors
|
|
}
|
|
|
|
pragmatismo.com.br:53 {
|
|
file /opt/gbo/data/pragmatismo.com.br.zone
|
|
bind 0.0.0.0
|
|
reload 6h
|
|
acl {
|
|
allow type ANY net 10.0.0.0/8 127.0.0.0/8
|
|
allow type ANY net <HOST_IP>/32
|
|
allow type A net 0.0.0.0/0
|
|
allow type AAAA net 0.0.0.0/0
|
|
allow type MX net 0.0.0.0/0
|
|
allow type TXT net 0.0.0.0/0
|
|
allow type NS net 0.0.0.0/0
|
|
allow type SOA net 0.0.0.0/0
|
|
allow type SRV net 0.0.0.0/0
|
|
allow type CNAME net 0.0.0.0/0
|
|
allow type HTTPS net 0.0.0.0/0
|
|
allow type CAA net 0.0.0.0/0
|
|
block
|
|
}
|
|
cache
|
|
errors
|
|
}
|
|
|
|
. {
|
|
forward . 8.8.8.8 1.1.1.1
|
|
cache
|
|
errors
|
|
log
|
|
}
|
|
```
|
|
|
|
### Zone File Template (pragmatismo.com.br)
|
|
```
|
|
$ORIGIN pragmatismo.com.br.
|
|
$TTL 3600
|
|
|
|
@ IN SOA ns1.ddsites.com.br. hostmaster.dmeans.info. (
|
|
2026032301 ; Serial (YYYYMMDDNN)
|
|
86400 ; Refresh
|
|
900 ; Retry
|
|
1209600 ; Expire
|
|
3600 ; Minimum TTL
|
|
)
|
|
|
|
@ IN CAA 0 issue "letsencrypt.org"
|
|
@ IN CAA 0 issuewild ";"
|
|
@ IN CAA 0 iodef "mailto:security@pragmatismo.com.br"
|
|
|
|
@ IN HTTPS 1 . alpn="h2,h3"
|
|
|
|
@ IN NS ns1.ddsites.com.br.
|
|
@ IN NS ns2.ddsites.com.br.
|
|
|
|
@ IN A <HOST_IP>
|
|
|
|
ns1 IN A <HOST_IP>
|
|
ns2 IN A <HOST_IP>
|
|
|
|
@ IN MX 10 mail.pragmatismo.com.br.
|
|
|
|
mail IN A <HOST_IP>
|
|
www IN A <HOST_IP>
|
|
webmail IN A <HOST_IP>
|
|
drive IN A <HOST_IP>
|
|
drive-api IN A <HOST_IP>
|
|
alm IN A <HOST_IP>
|
|
tables IN A <HOST_IP>
|
|
gb IN A <HOST_IP>
|
|
gb6 IN A <HOST_IP>
|
|
```
|
|
|
|
### Starting CoreDNS in Container
|
|
```bash
|
|
# CoreDNS won't start via systemd in Incus containers by default
|
|
# Use nohup to start it
|
|
incus exec dns -- bash -c 'mkdir -p /opt/gbo/logs && nohup /opt/gbo/bin/coredns -conf /opt/gbo/conf/Corefile > /opt/gbo/logs/coredns.log 2>&1 &'
|
|
```
|
|
|
|
### DNS Zone Records (CRITICAL - Use A records, NOT CNAMEs for internal services)
|
|
```
|
|
# WRONG - CNAME causes resolution issues
|
|
webmail IN CNAME mail
|
|
|
|
# CORRECT - Direct A record
|
|
webmail IN A <HOST_IP>
|
|
mail IN A <HOST_IP>
|
|
```
|
|
|
|
---
|
|
|
|
**ALWAYS remove socat and Incus proxy devices before configuring iptables NAT:**
|
|
|
|
```bash
|
|
# Remove socat
|
|
pkill -9 -f socat 2>/dev/null
|
|
rm -f /usr/bin/socat /usr/sbin/socat 2>/dev/null
|
|
|
|
# Remove all proxy devices from all containers
|
|
for c in $(incus list --format csv -c n); do
|
|
for d in $(incus config device list $c 2>/dev/null | grep -E 'proxy|port'); do
|
|
echo "Removing $d from $c"
|
|
incus config device remove $c $d 2>/dev/null
|
|
done
|
|
done
|
|
```
|
|
|
|
---
|
|
|
|
## installer.rs Improvements Required
|
|
|
|
### 1. New Module Structure
|
|
|
|
```
|
|
botserver/src/core/package_manager/
|
|
├── mod.rs
|
|
├── component.rs # ComponentConfig (existing)
|
|
├── installer.rs # PackageManager (existing)
|
|
├── container.rs # NEW: Container deployment logic
|
|
└── templates/ # NEW: Service file templates
|
|
├── dns.service
|
|
├── email.service
|
|
├── alm.service
|
|
├── minio.service
|
|
└── webmail.service
|
|
```
|
|
|
|
### 2. Container Settings in ComponentConfig
|
|
|
|
```rust
|
|
// Add to component.rs
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct NatRule {
|
|
pub port: u16,
|
|
pub protocol: String, // "tcp" or "udp"
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct ContainerSettings {
|
|
pub container_name: String,
|
|
pub ip: String,
|
|
pub user: String,
|
|
pub group: Option<String>,
|
|
pub working_dir: Option<String>,
|
|
pub service_template: String,
|
|
pub nat_rules: Vec<NatRule>,
|
|
pub binary_path: String, // "/opt/gbo/bin/coredns"
|
|
pub config_path: String, // "/opt/gbo/conf/Corefile"
|
|
pub data_path: Option<String>, // "/opt/gbo/data"
|
|
pub exec_cmd_args: Vec<String>, // ["--config", "/opt/gbo/conf/Corefile"]
|
|
pub internal_ports: Vec<u16>, // Ports container listens on internally
|
|
pub external_port: Option<u16>, // External port (if different from internal)
|
|
}
|
|
```
|
|
|
|
### 3. Component Registration with Container Settings
|
|
|
|
```rust
|
|
fn register_dns(&mut self) {
|
|
self.components.insert(
|
|
"dns".to_string(),
|
|
ComponentConfig {
|
|
name: "dns".to_string(),
|
|
// ... existing fields ...
|
|
|
|
// NEW: Container settings
|
|
container: Some(ContainerSettings {
|
|
container_name: "dns".to_string(),
|
|
ip: "10.107.115.155".to_string(),
|
|
user: "root".to_string(),
|
|
group: None,
|
|
working_dir: None,
|
|
service_template: include_str!("templates/dns.service").to_string(),
|
|
nat_rules: vec![
|
|
NatRule { port: 53, protocol: "tcp".to_string() },
|
|
NatRule { port: 53, protocol: "udp".to_string() },
|
|
],
|
|
binary_path: "/opt/gbo/bin/coredns".to_string(),
|
|
config_path: "/opt/gbo/conf/Corefile".to_string(),
|
|
data_path: Some("/opt/gbo/data".to_string()),
|
|
exec_cmd_args: vec!["-conf".to_string(), "/opt/gbo/conf/Corefile".to_string()],
|
|
internal_ports: vec![53],
|
|
external_port: Some(53),
|
|
}),
|
|
},
|
|
);
|
|
}
|
|
|
|
fn register_tables(&mut self) {
|
|
// PostgreSQL with external port 4445
|
|
self.components.insert(
|
|
"tables".to_string(),
|
|
ComponentConfig {
|
|
name: "tables".to_string(),
|
|
container: Some(ContainerSettings {
|
|
container_name: "tables".to_string(),
|
|
ip: "10.107.115.33".to_string(),
|
|
user: "root".to_string(),
|
|
nat_rules: vec![
|
|
NatRule { port: 4445, protocol: "tcp".to_string() },
|
|
],
|
|
internal_ports: vec![5432],
|
|
external_port: Some(4445),
|
|
// ...
|
|
}),
|
|
},
|
|
);
|
|
}
|
|
```
|
|
|
|
### 4. Container Deployment Methods
|
|
|
|
```rust
|
|
// Add to installer.rs
|
|
|
|
impl PackageManager {
|
|
|
|
/// Bootstrap a container with all its services and NAT rules
|
|
pub async fn bootstrap_container(
|
|
&self,
|
|
container_name: &str,
|
|
source_lxd: Option<&str>,
|
|
) -> Result<()> {
|
|
info!("Bootstrapping container: {}", container_name);
|
|
|
|
// 0. CLEANUP - Remove any existing socat or proxy devices
|
|
self.cleanup_existing(container_name).await?;
|
|
|
|
// 1. Copy from source LXD if migrating
|
|
if let Some(source_remote) = source_lxd {
|
|
self.copy_container(source_remote, container_name).await?;
|
|
}
|
|
|
|
// 2. Ensure network is configured
|
|
self.ensure_network(container_name).await?;
|
|
|
|
// 3. Sync data from host to container
|
|
self.sync_data_to_container(container_name).await?;
|
|
|
|
// 4. Fix permissions
|
|
self.fix_permissions(container_name).await?;
|
|
|
|
// 5. Install and start service
|
|
self.install_systemd_service(container_name).await?;
|
|
|
|
// 6. Configure NAT rules on host (ONLY iptables, never socat)
|
|
self.configure_iptables_nat(container_name).await?;
|
|
|
|
// 7. Reload DNS if dns container
|
|
if container_name == "dns" {
|
|
self.reload_dns_zones().await?;
|
|
}
|
|
|
|
info!("Container {} bootstrapped successfully", container_name);
|
|
Ok(())
|
|
}
|
|
|
|
/// Cleanup existing socat and proxy devices
|
|
async fn cleanup_existing(&self, container: &str) -> Result<()> {
|
|
// Remove socat processes
|
|
SafeCommand::new("pkill")
|
|
.and_then(|c| c.arg("-9"))
|
|
.and_then(|c| c.arg("-f"))
|
|
.and_then(|c| c.arg("socat"))
|
|
.execute()?;
|
|
|
|
// Remove proxy devices from container
|
|
let output = SafeCommand::new("incus")
|
|
.and_then(|c| c.arg("config"))
|
|
.and_then(|c| c.arg("device"))
|
|
.and_then(|c| c.arg("list"))
|
|
.and_then(|c| c.arg(container))
|
|
.and_then(|cmd| cmd.execute_with_output())?;
|
|
|
|
let output_str = String::from_utf8_lossy(&output.stdout);
|
|
for line in output_str.lines() {
|
|
if line.contains("proxy") || line.contains("port") {
|
|
let parts: Vec<&str> = line.split_whitespace().collect();
|
|
if let Some(name) = parts.first() {
|
|
SafeCommand::new("incus")
|
|
.and_then(|c| c.arg("config"))
|
|
.and_then(|c| c.arg("device"))
|
|
.and_then(|c| c.arg("remove"))
|
|
.and_then(|c| c.arg(container))
|
|
.and_then(|c| c.arg(name))
|
|
.execute()?;
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Copy container from LXD source
|
|
async fn copy_container(&self, source_remote: &str, name: &str) -> Result<()> {
|
|
info!("Copying container {} from {}", name, source_remote);
|
|
|
|
SafeCommand::new("incus")
|
|
.and_then(|c| c.arg("copy"))
|
|
.and_then(|c| c.arg("--instance-only"))
|
|
.and_then(|c| c.arg(format!("{}:{}", source_remote, name)))
|
|
.and_then(|c| c.arg(name))
|
|
.and_then(|cmd| cmd.execute())
|
|
.context("Failed to copy container")?;
|
|
|
|
SafeCommand::new("incus")
|
|
.and_then(|c| c.arg("start"))
|
|
.and_then(|c| c.arg(name))
|
|
.and_then(|cmd| cmd.execute())
|
|
.context("Failed to start container")?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Add eth0 network to container
|
|
async fn ensure_network(&self, container: &str) -> Result<()> {
|
|
let output = SafeCommand::new("incus")
|
|
.and_then(|c| c.arg("config"))
|
|
.and_then(|c| c.arg("device"))
|
|
.and_then(|c| c.arg("list"))
|
|
.and_then(|c| c.arg(container))
|
|
.and_then(|cmd| cmd.execute_with_output())?;
|
|
|
|
let output_str = String::from_utf8_lossy(&output.stdout);
|
|
if !output_str.contains("eth0") {
|
|
SafeCommand::new("incus")
|
|
.and_then(|c| c.arg("config"))
|
|
.and_then(|c| c.arg("device"))
|
|
.and_then(|c| c.arg("add"))
|
|
.and_then(|c| c.arg(container))
|
|
.and_then(|c| c.arg("eth0"))
|
|
.and_then(|c| c.arg("nic"))
|
|
.and_then(|c| c.arg("name=eth0"))
|
|
.and_then(|c| c.arg("network=PROD-GBO"))
|
|
.and_then(|cmd| cmd.execute())?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Sync data from host to container
|
|
async fn sync_data_to_container(&self, container: &str) -> Result<()> {
|
|
let source_path = format!(
|
|
"/opt/gbo/tenants/{}/{}/",
|
|
self.tenant, container
|
|
);
|
|
|
|
if Path::new(&source_path).exists() {
|
|
info!("Syncing data for {}", container);
|
|
|
|
SafeCommand::new("incus")
|
|
.and_then(|c| c.arg("exec"))
|
|
.and_then(|c| c.arg(container))
|
|
.and_then(|c| c.arg("--"))
|
|
.and_then(|c| c.arg("mkdir"))
|
|
.and_then(|c| c.arg("-p"))
|
|
.and_then(|c| c.arg("/opt/gbo"))
|
|
.and_then(|cmd| cmd.execute())?;
|
|
|
|
SafeCommand::new("incus")
|
|
.and_then(|c| c.arg("file"))
|
|
.and_then(|c| c.arg("push"))
|
|
.and_then(|c| c.arg("--recursive"))
|
|
.and_then(|c| c.arg(format!("{}.", source_path)))
|
|
.and_then(|c| c.arg(format!("{}:/opt/gbo/", container)))
|
|
.and_then(|cmd| cmd.execute())?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Fix file permissions based on container user
|
|
async fn fix_permissions(&self, container: &str) -> Result<()> {
|
|
let settings = self.get_container_settings(container)?;
|
|
|
|
if let Some(user) = &settings.user {
|
|
let chown_cmd = if let Some(group) = &settings.group {
|
|
format!("chown -R {}:{} /opt/gbo/", user, group)
|
|
} else {
|
|
format!("chown -R {}:{} /opt/gbo/", user, user)
|
|
};
|
|
|
|
SafeCommand::new("incus")
|
|
.and_then(|c| c.arg("exec"))
|
|
.and_then(|c| c.arg(container))
|
|
.and_then(|c| c.arg("--"))
|
|
.and_then(|c| c.arg("sh"))
|
|
.and_then(|c| c.arg("-c"))
|
|
.and_then(|c| c.arg(&chown_cmd))
|
|
.and_then(|cmd| cmd.execute())?;
|
|
}
|
|
|
|
// Make binaries executable
|
|
SafeCommand::new("incus")
|
|
.and_then(|c| c.arg("exec"))
|
|
.and_then(|c| c.arg(container))
|
|
.and_then(|c| c.arg("--"))
|
|
.and_then(|c| c.arg("chmod"))
|
|
.and_then(|c| c.arg("+x"))
|
|
.and_then(|c| c.arg(format!("{}/bin/*", self.base_path.display())))
|
|
.and_then(|cmd| cmd.execute())?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Install systemd service file and start
|
|
async fn install_systemd_service(&self, container: &str) -> Result<()> {
|
|
let settings = self.get_container_settings(container)?;
|
|
|
|
let service_name = format!("{}.service", container);
|
|
let temp_path = format!("/tmp/{}", service_name);
|
|
|
|
std::fs::write(&temp_path, &settings.service_template)
|
|
.context("Failed to write service template")?;
|
|
|
|
SafeCommand::new("incus")
|
|
.and_then(|c| c.arg("file"))
|
|
.and_then(|c| c.arg("push"))
|
|
.and_then(|c| c.arg(&temp_path))
|
|
.and_then(|c| c.arg(format!("{}:/etc/systemd/system/{}", container, service_name)))
|
|
.and_then(|cmd| cmd.execute())?;
|
|
|
|
for cmd_args in [
|
|
["daemon-reload"],
|
|
&["enable", &service_name],
|
|
&["start", &service_name],
|
|
] {
|
|
let mut cmd = SafeCommand::new("incus")
|
|
.and_then(|c| c.arg("exec"))
|
|
.and_then(|c| c.arg(container))
|
|
.and_then(|c| c.arg("--"))
|
|
.and_then(|c| c.arg("systemctl"));
|
|
|
|
for arg in cmd_args {
|
|
cmd = cmd.and_then(|c| c.arg(arg));
|
|
}
|
|
cmd.execute()?;
|
|
}
|
|
|
|
std::fs::remove_file(&temp_path).ok();
|
|
Ok(())
|
|
}
|
|
|
|
/// Configure iptables NAT rules on host - ONLY method allowed, NEVER socat
|
|
async fn configure_iptables_nat(&self, container: &str) -> Result<()> {
|
|
let settings = self.get_container_settings(container)?;
|
|
|
|
// Set route_localnet if not already set
|
|
SafeCommand::new("sudo")
|
|
.and_then(|c| c.arg("sysctl"))
|
|
.and_then(|c| c.arg("-w"))
|
|
.and_then(|c| c.arg("net.ipv4.conf.all.route_localnet=1"))
|
|
.execute()?;
|
|
|
|
for rule in &settings.nat_rules {
|
|
// PREROUTING rule - for external traffic
|
|
SafeCommand::new("sudo")
|
|
.and_then(|c| c.arg("iptables"))
|
|
.and_then(|c| c.arg("-t"))
|
|
.and_then(|c| c.arg("nat"))
|
|
.and_then(|c| c.arg("-A"))
|
|
.and_then(|c| c.arg("PREROUTING"))
|
|
.and_then(|c| c.arg("-p"))
|
|
.and_then(|c| c.arg(&rule.protocol))
|
|
.and_then(|c| c.arg("--dport"))
|
|
.and_then(|c| c.arg(rule.port.to_string()))
|
|
.and_then(|c| c.arg("-j"))
|
|
.and_then(|c| c.arg("DNAT"))
|
|
.and_then(|c| c.arg("--to-destination"))
|
|
.and_then(|c| c.arg(format!("{}:{}", settings.ip, rule.port)))
|
|
.and_then(|cmd| cmd.execute())?;
|
|
|
|
// OUTPUT rule - for local traffic (CRITICAL for NAT to work)
|
|
SafeCommand::new("sudo")
|
|
.and_then(|c| c.arg("iptables"))
|
|
.and_then(|c| c.arg("-t"))
|
|
.and_then(|c| c.arg("nat"))
|
|
.and_then(|c| c.arg("-A"))
|
|
.and_then(|c| c.arg("OUTPUT"))
|
|
.and_then(|c| c.arg("-p"))
|
|
.and_then(|c| c.arg(&rule.protocol))
|
|
.and_then(|c| c.arg("--dport"))
|
|
.and_then(|c| c.arg(rule.port.to_string()))
|
|
.and_then(|c| c.arg("-j"))
|
|
.and_then(|c| c.arg("DNAT"))
|
|
.and_then(|c| c.arg("--to-destination"))
|
|
.and_then(|c| c.arg(format!("{}:{}", settings.ip, rule.port)))
|
|
.and_then(|cmd| cmd.execute())?;
|
|
|
|
// FORWARD rules
|
|
SafeCommand::new("sudo")
|
|
.and_then(|c| c.arg("iptables"))
|
|
.and_then(|c| c.arg("-A"))
|
|
.and_then(|c| c.arg("FORWARD"))
|
|
.and_then(|c| c.arg("-p"))
|
|
.and_then(|c| c.arg(&rule.protocol))
|
|
.and_then(|c| c.arg("-d"))
|
|
.and_then(|c| c.arg(&settings.ip))
|
|
.and_then(|c| c.arg("--dport"))
|
|
.and_then(|c| c.arg(rule.port.to_string()))
|
|
.and_then(|c| c.arg("-j"))
|
|
.and_then(|c| c.arg("ACCEPT"))
|
|
.and_then(|cmd| cmd.execute())?;
|
|
}
|
|
|
|
// POSTROUTING MASQUERADE for return traffic
|
|
SafeCommand::new("sudo")
|
|
.and_then(|c| c.arg("iptables"))
|
|
.and_then(|c| c.arg("-t"))
|
|
.and_then(|c| c.arg("nat"))
|
|
.and_then(|c| c.arg("-A"))
|
|
.and_then(|c| c.arg("POSTROUTING"))
|
|
.and_then(|c| c.arg("-p"))
|
|
.and_then(|c| c.arg("tcp"))
|
|
.and_then(|c| c.arg("-d"))
|
|
.and_then(|c| c.arg(&settings.ip))
|
|
.and_then(|c| c.arg("-j"))
|
|
.and_then(|c| c.arg("MASQUERADE"))
|
|
.and_then(|cmd| cmd.execute())?;
|
|
|
|
// Save rules
|
|
SafeCommand::new("sudo")
|
|
.and_then(|c| c.arg("sh"))
|
|
.and_then(|c| c.arg("-c"))
|
|
.and_then(|c| c.arg("iptables-save > /etc/iptables/rules.v4"))
|
|
.and_then(|cmd| cmd.execute())?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Start CoreDNS (special case - doesn't work well with systemd in Incus)
|
|
async fn start_coredns(&self, container: &str) -> Result<()> {
|
|
SafeCommand::new("incus")
|
|
.and_then(|c| c.arg("exec"))
|
|
.and_then(|c| c.arg(container))
|
|
.and_then(|c| c.arg("--"))
|
|
.and_then(|c| c.arg("bash"))
|
|
.and_then(|c| c.arg("-c"))
|
|
.and_then(|c| c.arg("mkdir -p /opt/gbo/logs && nohup /opt/gbo/bin/coredns -conf /opt/gbo/conf/Corefile > /opt/gbo/logs/coredns.log 2>&1 &"))
|
|
.and_then(|cmd| cmd.execute())?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Reload DNS zones with new IPs
|
|
async fn reload_dns_zones(&self) -> Result<()> {
|
|
// Update zone files to point to new IP
|
|
SafeCommand::new("incus")
|
|
.and_then(|c| c.arg("exec"))
|
|
.and_then(|c| c.arg("dns"))
|
|
.and_then(|c| c.arg("--"))
|
|
.and_then(|c| c.arg("sh"))
|
|
.and_then(|c| c.arg("-c"))
|
|
.and_then(|c| c.arg("sed -i 's/OLD_IP/NEW_IP/g' /opt/gbo/data/*.zone"))
|
|
.and_then(|cmd| cmd.execute())?;
|
|
|
|
// Restart coredns
|
|
self.start_coredns("dns").await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Get container settings for a component
|
|
fn get_container_settings(&self, container: &str) -> Result<&ContainerSettings> {
|
|
self.components
|
|
.get(container)
|
|
.and_then(|c| c.container.as_ref())
|
|
.context("Container settings not found")
|
|
}
|
|
}
|
|
```
|
|
|
|
### 5. Binary Installation (For Fresh Containers)
|
|
|
|
```rust
|
|
/// Install binary to container from URL or fallback
|
|
async fn install_binary_to_container(
|
|
&self,
|
|
container: &str,
|
|
component: &str,
|
|
) -> Result<()> {
|
|
let config = self.components.get(component)
|
|
.context("Component not found")?;
|
|
|
|
let binary_name = config.binary_name.as_ref()
|
|
.context("No binary name")?;
|
|
|
|
let settings = config.container.as_ref()
|
|
.context("No container settings")?;
|
|
|
|
// Check if already exists
|
|
let check = SafeCommand::new("incus")
|
|
.and_then(|c| c.arg("exec"))
|
|
.and_then(|c| c.arg(container))
|
|
.and_then(|c| c.arg("--"))
|
|
.and_then(|c| c.arg("test"))
|
|
.and_then(|c| c.arg("-f"))
|
|
.and_then(|c| c.arg(&settings.binary_path))
|
|
.and_then(|cmd| cmd.execute());
|
|
|
|
if check.is_ok() {
|
|
info!("Binary {} already exists in {}", binary_name, container);
|
|
return Ok(());
|
|
}
|
|
|
|
// Download if URL available
|
|
if let Some(url) = &config.download_url {
|
|
self.download_and_push_binary(container, url, binary_name).await?;
|
|
}
|
|
|
|
// Make executable
|
|
SafeCommand::new("incus")
|
|
.and_then(|c| c.arg("exec"))
|
|
.and_then(|c| c.arg(container))
|
|
.and_then(|c| c.arg("--"))
|
|
.and_then(|c| c.arg("chmod"))
|
|
.and_then(|c| c.arg("+x"))
|
|
.and_then(|c| c.arg(&settings.binary_path))
|
|
.and_then(|cmd| cmd.execute())?;
|
|
|
|
Ok(())
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Full Bootstrap API
|
|
|
|
```rust
|
|
/// Bootstrap an entire tenant
|
|
pub async fn bootstrap_tenant(
|
|
state: &AppState,
|
|
tenant: &str,
|
|
containers: &[&str],
|
|
source_remote: Option<&str>,
|
|
) -> Result<()> {
|
|
let pm = PackageManager::new(InstallMode::Container, Some(tenant.to_string()))?;
|
|
|
|
for container in containers {
|
|
pm.bootstrap_container(container, source_remote).await?;
|
|
}
|
|
|
|
info!("Tenant {} bootstrapped successfully", tenant);
|
|
Ok(())
|
|
}
|
|
|
|
/// Bootstrap all pragmatismo containers
|
|
pub async fn bootstrap_pragmatismo(state: &AppState) -> Result<()> {
|
|
let containers = [
|
|
"dns", "email", "webmail", "alm", "drive",
|
|
"tables", "system", "proxy", "alm-ci", "table-editor"
|
|
];
|
|
|
|
bootstrap_tenant(state, "pragmatismo", &containers, Some("lxd-source")).await
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Command Line Usage
|
|
|
|
```bash
|
|
# Bootstrap single container
|
|
cargo run --bin bootstrap -- container dns --tenant pragmatismo
|
|
|
|
# Bootstrap all containers for a tenant
|
|
cargo run --bin bootstrap -- tenant pragmatismo --source lxd-source
|
|
|
|
# Only sync data (no copy from LXD)
|
|
cargo run --bin bootstrap -- sync-data dns --tenant pragmatismo
|
|
|
|
# Only configure NAT
|
|
cargo run --bin bootstrap -- configure-nat --container dns
|
|
|
|
# Only install service
|
|
cargo run --bin bootstrap -- install-service dns
|
|
|
|
# Clean up socat and proxy devices
|
|
cargo run --bin bootstrap -- cleanup --container dns
|
|
```
|
|
|
|
---
|
|
|
|
## Files to Create/Modify
|
|
|
|
### New Files
|
|
1. `botserver/src/core/package_manager/container.rs` - Container deployment logic
|
|
2. `botserver/src/core/package_manager/templates/dns.service`
|
|
3. `botserver/src/core/package_manager/templates/email.service`
|
|
4. `botserver/src/core/package_manager/templates/alm.service`
|
|
5. `botserver/src/core/package_manager/templates/minio.service`
|
|
6. `botserver/src/core/package_manager/templates/webmail.service`
|
|
7. `botserver/src/core/package_manager/templates/tables-postgresql.service`
|
|
|
|
### Modified Files
|
|
1. `botserver/src/core/package_manager/component.rs` - Add ContainerSettings
|
|
2. `botserver/src/core/package_manager/installer.rs` - Add container methods, update registrations
|
|
|
|
---
|
|
|
|
## Testing Checklist
|
|
|
|
After implementation, verify:
|
|
- [ ] `incus list` shows all containers running
|
|
- [ ] `nc -zv 127.0.0.1 4445` - PostgreSQL accessible
|
|
- [ ] `dig @127.0.0.1 webmail.pragmatismo.com.br` - Returns correct IP
|
|
- [ ] `curl https://webmail.pragmatismo.com.br` - Webmail accessible
|
|
- [ ] NAT rules work from external IP
|
|
- [ ] Zone files have correct A records (not CNAMEs)
|
|
- [ ] Services survive container restart
|
|
- [ ] `which socat` returns nothing on host
|
|
- [ ] No proxy devices in any container
|
|
|
|
---
|
|
|
|
## Known Issues Fixed
|
|
|
|
1. **socat conflicts with iptables** - NEVER use socat, use ONLY iptables NAT
|
|
2. **Incus proxy devices conflict with NAT** - Remove all proxy devices before setting up NAT
|
|
3. **PREROUTING doesn't handle local traffic** - Must add OUTPUT rules
|
|
4. **CoreDNS won't start via systemd in Incus** - Use nohup instead
|
|
5. **DNS CNAME resolution issues** - Use A records for internal services
|
|
6. **route_localnet needed for localhost NAT** - Set sysctl before NAT rules
|
|
7. **FORWARD chain blocks container traffic** - Must add FORWARD ACCEPT rules
|
|
8. **Return traffic fails without MASQUERADE** - Add POSTROUTING MASQUERADE rules
|
|
9. **Binary permissions** - chmod +x after push
|
|
10. **Apache SSL needs mod_ssl enabled** - Run `a2enmod ssl` before starting Apache
|