commit c34d2b4849f4bf916041077847dc05d5e6d758b0 Author: Nelis Date: Sat Mar 28 10:34:20 2026 +0000 Initial server init setup with Ansible playbook Automated server provisioning with Pangolin reverse proxy, Forgejo git server with SSH passthrough, and OpenCode dev environment. Includes server hardening (UFW, fail2ban, SSH lockdown), Docker, Rust, Python/uv, and unattended security upgrades. Co-Authored-By: Claude Opus 4.6 diff --git a/init.sh b/init.sh new file mode 100755 index 0000000..4ad5e8c --- /dev/null +++ b/init.sh @@ -0,0 +1,42 @@ +#!/bin/bash +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +echo "=== Server Init ===" +echo "" + +read -p "Username [ubuntu]: " OLD_USER +OLD_USER="${OLD_USER:-ubuntu}" +read -p "Host: " HOST +read -p "SSH public key path [~/.ssh/id_ed25519.pub]: " PUBKEY_PATH +PUBKEY_PATH="${PUBKEY_PATH:-$HOME/.ssh/id_ed25519.pub}" +read -p "New username: " NEW_USER + +SSH_TARGET="${OLD_USER}@pangolin.${HOST}" + +echo "" +echo "Set a password for '$NEW_USER' (used for sudo):" +read -s -p "Password: " USER_PASSWORD +echo +read -s -p "Confirm: " USER_PASSWORD_CONFIRM +echo + +if [ "$USER_PASSWORD" != "$USER_PASSWORD_CONFIRM" ]; then + echo "Error: Passwords do not match" + exit 1 +fi + +if [ ! -f "$PUBKEY_PATH" ]; then + echo "Error: Public key file not found: $PUBKEY_PATH" + exit 1 +fi + +PUBKEY=$(cat "$PUBKEY_PATH") + +export SCRIPT_DIR HOST OLD_USER NEW_USER SSH_TARGET PUBKEY USER_PASSWORD + +source "$SCRIPT_DIR/scripts/01_create_user.sh" +source "$SCRIPT_DIR/scripts/02_remove_old_user.sh" +source "$SCRIPT_DIR/scripts/03_run_playbook.sh" +source "$SCRIPT_DIR/scripts/04_show_setup_info.sh" diff --git a/playbook.yml b/playbook.yml new file mode 100644 index 0000000..4ceab28 --- /dev/null +++ b/playbook.yml @@ -0,0 +1,404 @@ +--- +- name: Server Initialization Playbook + hosts: localhost + connection: local + become: yes + vars: + base_domain: + pangolin_domain: "pangolin.{{ base_domain }}" + forgejo_domain: "forgejo.{{ base_domain }}" + opencode_domain: "opencode.{{ base_domain }}" + acme_email: "admin@{{ base_domain }}" + docker_hosting_dir: "/home/{{ login_user }}/docker_hosting" + resources_dir: "{{ playbook_dir }}/resources" + login_user: "{{ ansible_env.SUDO_USER | default(ansible_user_id) }}" + deb_architecture: "{{ 'amd64' if ansible_architecture == 'x86_64' else 'arm64' if ansible_architecture == 'aarch64' else ansible_architecture }}" + + tasks: + - name: Update apt cache + ansible.builtin.apt: + update_cache: yes + cache_valid_time: 3600 + + - name: Install prerequisites for Docker repository + ansible.builtin.apt: + name: + - ca-certificates + - curl + - gnupg + - lsb-release + state: present + + - name: Install common dev dependencies + ansible.builtin.apt: + name: + - git + - vim + - wget + - htop + - tmux + - unzip + - zip + - build-essential + - gcc + - make + - pkg-config + - libssl-dev + - libffi-dev + - python3 + - python3-pip + - python3-venv + - nodejs + - npm + - jq + state: present + + # --- Server hardening --- + + - name: Install security packages + ansible.builtin.apt: + name: + - ufw + - fail2ban + - unattended-upgrades + - apt-listchanges + state: present + + - name: Disable root SSH login + ansible.builtin.lineinfile: + path: /etc/ssh/sshd_config + regexp: '^#?PermitRootLogin' + line: 'PermitRootLogin no' + validate: 'sshd -t -f %s' + notify: restart sshd + + - name: Disable SSH password authentication + ansible.builtin.lineinfile: + path: /etc/ssh/sshd_config + regexp: '^#?PasswordAuthentication' + line: 'PasswordAuthentication no' + validate: 'sshd -t -f %s' + notify: restart sshd + + - name: Disable SSH challenge-response authentication + ansible.builtin.lineinfile: + path: /etc/ssh/sshd_config + regexp: '^#?KbdInteractiveAuthentication' + line: 'KbdInteractiveAuthentication no' + validate: 'sshd -t -f %s' + notify: restart sshd + + - name: Set SSH MaxAuthTries + ansible.builtin.lineinfile: + path: /etc/ssh/sshd_config + regexp: '^#?MaxAuthTries' + line: 'MaxAuthTries 3' + validate: 'sshd -t -f %s' + notify: restart sshd + + - name: UFW - Allow forwarding for Docker + ansible.builtin.lineinfile: + path: /etc/default/ufw + regexp: '^DEFAULT_FORWARD_POLICY=' + line: 'DEFAULT_FORWARD_POLICY="ACCEPT"' + + - name: UFW - Set default deny incoming + community.general.ufw: + direction: incoming + default: deny + + - name: UFW - Set default allow outgoing + community.general.ufw: + direction: outgoing + default: allow + + - name: UFW - Allow SSH + community.general.ufw: + rule: allow + port: '22' + proto: tcp + + - name: UFW - Allow HTTP + community.general.ufw: + rule: allow + port: '80' + proto: tcp + + - name: UFW - Allow HTTPS + community.general.ufw: + rule: allow + port: '443' + proto: tcp + + - name: UFW - Allow WireGuard + community.general.ufw: + rule: allow + port: '51820' + proto: udp + + - name: UFW - Allow WireGuard clients + community.general.ufw: + rule: allow + port: '21820' + proto: udp + + - name: UFW - Allow Docker to reach opencode on host + community.general.ufw: + rule: allow + from_ip: 172.18.0.0/16 + to_ip: any + port: '3000' + proto: tcp + + - name: UFW - Enable firewall + community.general.ufw: + state: enabled + + - name: Configure fail2ban for SSH + ansible.builtin.copy: + dest: /etc/fail2ban/jail.local + mode: '0644' + content: | + [DEFAULT] + bantime = 1h + findtime = 10m + maxretry = 5 + + [sshd] + enabled = true + port = ssh + filter = sshd + logpath = /var/log/auth.log + + - name: Start and enable fail2ban + ansible.builtin.service: + name: fail2ban + state: started + enabled: yes + + - name: Enable unattended security upgrades + ansible.builtin.copy: + dest: /etc/apt/apt.conf.d/20auto-upgrades + mode: '0644' + content: | + APT::Periodic::Update-Package-Lists "1"; + APT::Periodic::Unattended-Upgrade "1"; + APT::Periodic::AutocleanInterval "7"; + + - name: Create keyrings directory + ansible.builtin.file: + path: /etc/apt/keyrings + state: directory + mode: '0755' + + - name: Add Docker GPG key + ansible.builtin.shell: + cmd: curl -fsSL https://download.docker.com/linux/{{ ansible_distribution | lower }}/gpg -o /etc/apt/keyrings/docker.asc + args: + creates: /etc/apt/keyrings/docker.asc + + - name: Add Docker repository + ansible.builtin.apt_repository: + repo: "deb [arch={{ deb_architecture }} signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/{{ ansible_distribution | lower }} {{ ansible_distribution_release }} stable" + state: present + + - name: Install Docker + ansible.builtin.apt: + name: + - docker-ce + - docker-ce-cli + - containerd.io + - docker-compose-plugin + state: present + update_cache: yes + + - name: Start and enable Docker + ansible.builtin.service: + name: docker + state: started + enabled: yes + + - name: Add user to docker group + ansible.builtin.user: + name: "{{ login_user }}" + groups: docker + append: yes + + - name: Install Rust via rustup for login user + ansible.builtin.shell: + cmd: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + args: + creates: "/home/{{ login_user }}/.cargo/bin/rustup" + become_user: "{{ login_user }}" + + - name: Install uv (Python package manager) + ansible.builtin.shell: + cmd: curl -LsSf https://astral.sh/uv/install.sh | sh + args: + creates: "/home/{{ login_user }}/.local/bin/uv" + become_user: "{{ login_user }}" + + # --- Forgejo SSH passthrough --- + + - name: Create forgejo-shell directory + ansible.builtin.file: + path: /etc/forgejo/bin + state: directory + mode: '0755' + + - name: Create forgejo-shell script + ansible.builtin.copy: + dest: /etc/forgejo/bin/forgejo-shell + mode: '0755' + content: | + #!/bin/sh + /usr/bin/docker exec -i --env SSH_ORIGINAL_COMMAND="$SSH_ORIGINAL_COMMAND" forgejo sh "$@" + + - name: Create git user for Forgejo + ansible.builtin.user: + name: git + system: yes + shell: /etc/forgejo/bin/forgejo-shell + home: /home/git + create_home: yes + register: git_user + + - name: Set git user UID/GID facts + ansible.builtin.set_fact: + git_uid: "{{ git_user.uid }}" + git_gid: "{{ git_user.group }}" + + - name: Add git user to docker group + ansible.builtin.user: + name: git + groups: docker + append: yes + + - name: Configure SSH passthrough for git user + ansible.builtin.copy: + dest: /etc/ssh/sshd_config.d/forgejo-ssh-passthrough.conf + mode: '0644' + content: | + Match User git + AuthorizedKeysCommandUser git + AuthorizedKeysCommand /usr/bin/docker exec -i forgejo /usr/local/bin/gitea keys -c /etc/gitea/app.ini -e git -u %u -t %t -k %k + notify: restart sshd + + - name: Generate Pangolin secret + ansible.builtin.shell: + cmd: openssl rand -base64 48 + register: pangolin_secret_result + changed_when: false + + - name: Set Pangolin secret fact + ansible.builtin.set_fact: + pangolin_secret: "{{ pangolin_secret_result.stdout }}" + + # --- Directory structure --- + + - name: Create docker_hosting directory structure + ansible.builtin.file: + path: "{{ docker_hosting_dir }}/{{ item }}" + state: directory + owner: "{{ login_user }}" + group: "{{ login_user }}" + mode: '0755' + loop: + - docker_data/pangolin + - docker_data/pangolin/db + - docker_data/pangolin/letsencrypt + - docker_data/pangolin/logs + - docker_data/pangolin/traefik + - docker_data/pangolin/traefik/logs + + - name: Create Forgejo data directory + ansible.builtin.file: + path: "{{ docker_hosting_dir }}/docker_data/forgejo/data" + state: directory + owner: git + group: git + mode: '0755' + + # --- Pangolin config files --- + + - name: Copy Pangolin config.yml + ansible.builtin.template: + src: "{{ resources_dir }}/pangolin/config.yml.j2" + dest: "{{ docker_hosting_dir }}/docker_data/pangolin/config.yml" + owner: "{{ login_user }}" + group: "{{ login_user }}" + mode: '0600' + force: no + + - name: Copy Traefik static config + ansible.builtin.template: + src: "{{ resources_dir }}/pangolin/traefik_config.yml.j2" + dest: "{{ docker_hosting_dir }}/docker_data/pangolin/traefik/traefik_config.yml" + owner: "{{ login_user }}" + group: "{{ login_user }}" + mode: '0644' + + - name: Copy Traefik dynamic config + ansible.builtin.template: + src: "{{ resources_dir }}/pangolin/dynamic_config.yml.j2" + dest: "{{ docker_hosting_dir }}/docker_data/pangolin/traefik/dynamic_config.yml" + owner: "{{ login_user }}" + group: "{{ login_user }}" + mode: '0644' + + - name: Copy compose.yml + ansible.builtin.template: + src: "{{ resources_dir }}/compose.yml.j2" + dest: "{{ docker_hosting_dir }}/compose.yml" + owner: "{{ login_user }}" + group: "{{ login_user }}" + mode: '0644' + + # --- OpenCode --- + + - name: Install opencode + ansible.builtin.shell: + cmd: curl -fsSL https://opencode.ai/install | bash + args: + creates: /home/{{ login_user }}/.opencode/bin/opencode + become_user: "{{ login_user }}" + + - name: Create opencode systemd service + ansible.builtin.copy: + dest: /etc/systemd/system/opencode.service + mode: '0644' + content: | + [Unit] + Description=OpenCode Server + After=network.target + + [Service] + Type=simple + User={{ login_user }} + WorkingDirectory=/home/{{ login_user }} + ExecStart=/home/{{ login_user }}/.opencode/bin/opencode serve --port 3000 --hostname 0.0.0.0 + Restart=on-failure + RestartSec=5 + + [Install] + WantedBy=multi-user.target + + - name: Start and enable opencode service + ansible.builtin.systemd: + name: opencode + state: started + enabled: yes + daemon_reload: yes + + # --- Start services --- + + - name: Start all services via docker compose + ansible.builtin.shell: + cmd: docker compose up -d + chdir: "{{ docker_hosting_dir }}" + + handlers: + - name: restart sshd + ansible.builtin.service: + name: ssh + state: restarted diff --git a/resources/compose.yml.j2 b/resources/compose.yml.j2 new file mode 100644 index 0000000..bf12562 --- /dev/null +++ b/resources/compose.yml.j2 @@ -0,0 +1,74 @@ +name: pangolin + +services: + pangolin: + image: docker.io/fosrl/pangolin:latest + container_name: pangolin + restart: unless-stopped + volumes: + - ./docker_data/pangolin:/app/config + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3001/api/v1/"] + interval: "10s" + timeout: "10s" + retries: 15 + + gerbil: + image: docker.io/fosrl/gerbil:latest + container_name: gerbil + restart: unless-stopped + depends_on: + pangolin: + condition: service_healthy + command: + - --reachableAt=http://gerbil:3004 + - --generateAndSaveKeyTo=/var/config/key + - --remoteConfig=http://pangolin:3001/api/v1/ + volumes: + - ./docker_data/pangolin:/var/config + cap_add: + - NET_ADMIN + - SYS_MODULE + extra_hosts: + - "host.docker.internal:host-gateway" + ports: + - "51820:51820/udp" + - "21820:21820/udp" + - "443:443" + - "80:80" + + traefik: + image: docker.io/traefik:v3.6 + container_name: traefik + restart: unless-stopped + network_mode: service:gerbil + depends_on: + pangolin: + condition: service_healthy + command: + - --configFile=/etc/traefik/traefik_config.yml + volumes: + - ./docker_data/pangolin/traefik:/etc/traefik:ro + - ./docker_data/pangolin/letsencrypt:/letsencrypt + - ./docker_data/pangolin/traefik/logs:/var/log/traefik + + forgejo: + image: codeberg.org/forgejo/forgejo:7 + container_name: forgejo + restart: unless-stopped + volumes: + - ./docker_data/forgejo/data:/data + environment: + - USER_UID={{ git_uid }} + - USER_GID={{ git_gid }} + - FORGEJO__service__DISABLE_REGISTRATION=true + - FORGEJO__server__ROOT_URL=https://{{ forgejo_domain }} + - FORGEJO__server__HTTP_PORT=3001 + - FORGEJO__server__DISABLE_SSH=false + - FORGEJO__server__START_SSH_SERVER=false + - FORGEJO__server__SSH_PORT=22 + +networks: + default: + driver: bridge + name: pangolin diff --git a/resources/pangolin/config.yml.j2 b/resources/pangolin/config.yml.j2 new file mode 100644 index 0000000..71545e1 --- /dev/null +++ b/resources/pangolin/config.yml.j2 @@ -0,0 +1,48 @@ +app: + dashboard_url: "https://{{ pangolin_domain }}" + log_level: "info" + save_logs: true + +server: + external_port: 3000 + internal_port: 3001 + next_port: 3002 + internal_hostname: "pangolin" + secret: "{{ pangolin_secret }}" + dashboard_session_length_hours: 720 + resource_session_length_hours: 720 + cors: + origins: + - "https://{{ pangolin_domain }}" + methods: ["GET", "POST", "PUT", "DELETE", "PATCH"] + allowed_headers: ["X-CSRF-Token", "Content-Type"] + credentials: false + +domains: + domain1: + base_domain: "{{ base_domain }}" + cert_resolver: "letsencrypt" + +traefik: + http_entrypoint: "web" + https_entrypoint: "websecure" + cert_resolver: "letsencrypt" + +gerbil: + base_endpoint: "{{ pangolin_domain }}" + start_port: 51820 + clients_start_port: 21820 + +rate_limits: + global: + window_minutes: 1 + max_requests: 100 + auth: + window_minutes: 1 + max_requests: 10 + +flags: + require_email_verification: false + disable_signup_without_invite: true + disable_user_create_org: false + allow_raw_resources: true diff --git a/resources/pangolin/dynamic_config.yml.j2 b/resources/pangolin/dynamic_config.yml.j2 new file mode 100644 index 0000000..ddb0c5b --- /dev/null +++ b/resources/pangolin/dynamic_config.yml.j2 @@ -0,0 +1,92 @@ +http: + middlewares: + badger: + plugin: + badger: + disableForwardAuth: true + redirect-to-https: + redirectScheme: + scheme: https + + routers: + # --- Pangolin dashboard --- + main-app-router-redirect: + rule: "Host(`{{ pangolin_domain }}`)" + service: next-service + entryPoints: + - web + middlewares: + - redirect-to-https + - badger + + next-router: + rule: "Host(`{{ pangolin_domain }}`) && !PathPrefix(`/api/v1`)" + service: next-service + entryPoints: + - websecure + middlewares: + - badger + tls: + certResolver: letsencrypt + + api-router: + rule: "Host(`{{ pangolin_domain }}`) && PathPrefix(`/api/v1`)" + service: api-service + entryPoints: + - websecure + middlewares: + - badger + tls: + certResolver: letsencrypt + + ws-router: + rule: "Host(`{{ pangolin_domain }}`)" + service: api-service + entryPoints: + - websecure + middlewares: + - badger + tls: + certResolver: letsencrypt + + # --- Forgejo (public, no auth) --- + forgejo-redirect: + rule: "Host(`{{ forgejo_domain }}`)" + service: forgejo-service + entryPoints: + - web + middlewares: + - redirect-to-https + + forgejo-router: + rule: "Host(`{{ forgejo_domain }}`)" + service: forgejo-service + entryPoints: + - websecure + tls: + certResolver: letsencrypt + + services: + next-service: + loadBalancer: + servers: + - url: "http://pangolin:3002" + + api-service: + loadBalancer: + servers: + - url: "http://pangolin:3000" + + forgejo-service: + loadBalancer: + servers: + - url: "http://forgejo:3001" + +tcp: + serversTransports: + pp-transport-v1: + proxyProtocol: + version: 1 + pp-transport-v2: + proxyProtocol: + version: 2 diff --git a/resources/pangolin/traefik_config.yml.j2 b/resources/pangolin/traefik_config.yml.j2 new file mode 100644 index 0000000..c8d09bf --- /dev/null +++ b/resources/pangolin/traefik_config.yml.j2 @@ -0,0 +1,54 @@ +api: + insecure: true + dashboard: true + +providers: + http: + endpoint: "http://pangolin:3001/api/v1/traefik-config" + pollInterval: "5s" + file: + filename: "/etc/traefik/dynamic_config.yml" + +experimental: + plugins: + badger: + moduleName: "github.com/fosrl/badger" + version: "v1.3.1" + +log: + level: "INFO" + format: "common" + maxSize: 100 + maxBackups: 3 + maxAge: 3 + compress: true + +certificatesResolvers: + letsencrypt: + acme: + httpChallenge: + entryPoint: web + email: "{{ acme_email }}" + storage: "/letsencrypt/acme.json" + caServer: "https://acme-v02.api.letsencrypt.org/directory" + +entryPoints: + web: + address: ":80" + websecure: + address: ":443" + transport: + respondingTimeouts: + readTimeout: "30m" + http: + tls: + certResolver: "letsencrypt" + encodedCharacters: + allowEncodedSlash: true + allowEncodedQuestionMark: true + +serversTransport: + insecureSkipVerify: true + +ping: + entryPoint: "web" diff --git a/scripts/01_create_user.sh b/scripts/01_create_user.sh new file mode 100755 index 0000000..d7e4800 --- /dev/null +++ b/scripts/01_create_user.sh @@ -0,0 +1,24 @@ +#!/bin/bash +set -e + +echo "=== Phase 1: Creating user '$NEW_USER' on $SSH_TARGET ===" + +PUBKEY_B64=$(echo "$PUBKEY" | base64 -w 0) +PASS_B64=$(echo "$USER_PASSWORD" | base64 -w 0) + +ssh -o StrictHostKeyChecking=accept-new "$SSH_TARGET" bash -s -- "$NEW_USER" "$PUBKEY_B64" "$PASS_B64" <<'REMOTE' +set -e +NEW_USER="$1" +PUBKEY=$(echo "$2" | base64 -d) +USER_PASSWORD=$(echo "$3" | base64 -d) + +sudo adduser --disabled-password --gecos "" "$NEW_USER" +echo "$NEW_USER:$USER_PASSWORD" | sudo chpasswd +sudo usermod -aG sudo "$NEW_USER" + +sudo mkdir -p /home/$NEW_USER/.ssh +echo "$PUBKEY" | sudo tee /home/$NEW_USER/.ssh/authorized_keys > /dev/null +sudo chmod 700 /home/$NEW_USER/.ssh +sudo chmod 600 /home/$NEW_USER/.ssh/authorized_keys +sudo chown -R $NEW_USER:$NEW_USER /home/$NEW_USER/.ssh +REMOTE diff --git a/scripts/02_remove_old_user.sh b/scripts/02_remove_old_user.sh new file mode 100755 index 0000000..72045b4 --- /dev/null +++ b/scripts/02_remove_old_user.sh @@ -0,0 +1,7 @@ +#!/bin/bash +set -e + +echo "=== Phase 2: Removing default user '$OLD_USER' ===" + +ssh -t -o StrictHostKeyChecking=accept-new "$NEW_USER@pangolin.$HOST" \ + "sudo pkill -u $OLD_USER 2>/dev/null || true; sleep 1; sudo deluser --remove-home $OLD_USER 2>/dev/null || sudo userdel -r $OLD_USER; echo 'Removed user: $OLD_USER'" diff --git a/scripts/03_run_playbook.sh b/scripts/03_run_playbook.sh new file mode 100755 index 0000000..0d9baf0 --- /dev/null +++ b/scripts/03_run_playbook.sh @@ -0,0 +1,9 @@ +#!/bin/bash +set -e + +echo "=== Phase 3: Copying files and running playbook ===" + +rsync -az --exclude '.git' "$SCRIPT_DIR/" "$NEW_USER@pangolin.$HOST:~/server_init/" + +ssh -t "$NEW_USER@pangolin.$HOST" \ + "bash -c 'sudo locale-gen en_US.UTF-8 && if ! command -v ansible &>/dev/null; then echo \"Installing Ansible...\"; sudo apt-get update && sudo apt-get install -y ansible-core python3-pip; fi && echo \"Running Ansible playbook...\" && cd ~/server_init && LC_ALL=en_US.UTF-8 ansible-playbook -i localhost, -c local playbook.yml -e base_domain=\"$HOST\" --ask-become-pass'" diff --git a/scripts/04_show_setup_info.sh b/scripts/04_show_setup_info.sh new file mode 100755 index 0000000..cb2e69c --- /dev/null +++ b/scripts/04_show_setup_info.sh @@ -0,0 +1,27 @@ +#!/bin/bash +set -e + +echo "" +echo "=== Waiting for Pangolin setup token ===" + +SETUP_TOKEN=$(ssh "$NEW_USER@pangolin.$HOST" 'timeout 120 bash -c "until docker logs pangolin 2>&1 | grep -oP \"Token: \K.*\"; do sleep 2; done"') + +echo "" +echo "===========================================" +echo " Server setup complete!" +echo "===========================================" +echo "" +echo " SSH: ssh $NEW_USER@pangolin.$HOST" +echo "" +echo " Pangolin setup token: $SETUP_TOKEN" +echo "" +echo " Next steps:" +echo " 1. Go to https://pangolin.$HOST/auth/initial-setup" +echo " and enter the setup token above to create your admin account." +echo "" +echo " 2. In the Pangolin dashboard, add a local site, then create" +echo " a public resource for opencode:" +echo " - Target: host.docker.internal" +echo " - Port: 3000" +echo " - Domain: opencode.$HOST" +echo "==========================================="