У меня есть локальная сеть 192.168.0.0/24. В этой сети работает хост с двумя сетевыми картами, одна из которых (а) enp6s0 имеет адрес 192.168.0.162/24, — через эту катру осуществляется связь с internet и настройка по SSH. Другая сетевая карта (б) enp7s0 пока не задействована. На этом хосте (192.168.0.162/24) и (незадействованная сетевая катра) работает docker. В этом докере надо создать образ для создания контейнера с OpenWRT, чтобы у него было два интерфейса, один на сеть 192.168.0.0/24 (WAN для Openwrt) с статическим адресом 192.168.0.200, другой на незадействованную сетевую карту хоста (LAN для Openwrt) c статическим адресом 192.168.200.1/24. В контейнере с OpenWRT надо Предоставь мне Dockerfile и docker-compose.yml. При этом у меня должна быть возможность доступа к web интерфейсу Openwrt как по LAN так и по WAN интерфейсу.

Ниже — рабочий вариант на базе двух macvlan-сетей Docker:

  • WAN OpenWRT: интерфейс контейнера подключается к физической сети enp6s0, статический IP 192.168.0.200/24.
  • LAN OpenWRT: интерфейс контейнера подключается к неиспользуемой физической карте enp7s0, статический IP 192.168.200.1/24.
  • LuCI/Web будет доступна:
  • из LAN: http://192.168.200.1 и https://192.168.200.1
  • из WAN-сети 192.168.0.0/24: http://192.168.0.200 и https://192.168.0.200

Дальше предполагаю:

  • ваш роутер/шлюз в сети 192.168.0.0/24 имеет адрес 192.168.0.1;
  • адрес 192.168.0.200 свободен и не будет выдан DHCP-сервером;
  • enp7s0 физически подключена к отдельному LAN-сегменту: коммутатор, точка доступа, другой PC и т.п.;
  • используется Docker Compose v2 и современный Docker с BuildKit.

1. Подготовка хоста

Перед запуском желательно поднять enp7s0 и убедиться, что на ней нет адреса и DHCP-клиента хоста.

sudo ip link set enp6s0 up
sudo ip link set enp7s0 up

# Если на enp7s0 вдруг есть адрес, уберите его.
# Не трогайте enp6s0, через неё у вас интернет/SSH.
sudo ip addr flush dev enp7s0

Опционально, иногда полезно для macvlan:

sudo ip link set enp6s0 promisc on
sudo ip link set enp7s0 promisc on

Если у вас NetworkManager/systemd-networkd управляет enp7s0, лучше перевести её в unmanaged или убрать с неё DHCP/статический адрес. Иначе хост может мешать LAN-сегменту OpenWRT.


2. Dockerfile

Создайте отдельную директорию, например:

mkdir -p ~/openwrt-docker
cd ~/openwrt-docker

Сохраните файл Dockerfile:

# syntax=docker/dockerfile:1.8

ARG OPENWRT_TAG=23.05.4
FROM openwrt/rootfs:${OPENWRT_TAG}

ARG ROOT_PASSWORD=ChangeMe_Strong
ENV TZ=UTC

RUN set -eux; \
    opkg update; \
    opkg install --force-overwrite --force-space \
        luci-ssl \
        uhttpd \
        dnsmasq \
        firewall4 \
        dropbear \
        ip-full \
        ca-bundle \
        openssl-util; \
    rm -rf /var/opkg-lists; \
    echo "root:${ROOT_PASSWORD}" | chpasswd; \
    for s in boot network firewall dnsmasq dropbear uhttpd rpcd sysctl urandom_system; do \
        if [ -x "/etc/init.d/$s" ]; then "/etc/init.d/$s" enable || true; fi; \
    done

COPY <<'EOF' /entrypoint.sh
#!/bin/sh
set -e

: "${HOSTNAME:=openwrt}"
: "${WAN_MAC:=02:42:c0:a8:00:c8}"
: "${LAN_MAC:=02:42:c0:a8:c8:01}"
: "${WAN_IP:=192.168.0.200}"
: "${WAN_NETMASK:=255.255.255.0}"
: "${WAN_GATEWAY:=192.168.0.1}"
: "${WAN_DNS:=192.168.0.1}"
: "${LAN_IP:=192.168.200.1}"
: "${LAN_NETMASK:=255.255.255.0}"
: "${WAN_WEB_ALLOW:=192.168.0.0/24}"

# Позволяет задать пароль при первом старте через переменную окружения ROOT_PASSWORD.
# Если пароль уже меняли вручную, файл-маркер не даст перезаписать его при каждом рестарте.
if [ -n "${ROOT_PASSWORD:-}" ] && [ ! -f /etc/.docker_root_password_set ]; then
    echo "root:${ROOT_PASSWORD}" | chpasswd
    touch /etc/.docker_root_password_set
fi

hostname "${HOSTNAME}" >/dev/null 2>&1 || true

non_lo_ifs() {
    ip -o link show 2>/dev/null | awk -F': ' '{print $2}' | cut -d'@' -f1 | grep -E '^eth[0-9]+$' || true
}

# Ждём, пока Docker создаст оба интерфейса.
i=0
while [ "$i" -lt 15 ]; do
    cnt=$(non_lo_ifs | wc -l)
    [ "$cnt" -ge 2 ] && break
    sleep 1
    i=$((i+1))
done

find_if_by_mac() {
    want=$(echo "$1" | tr 'A-F' 'a-f')
    for p in /sys/class/net/*; do
        n="${p##*/}"
        [ "$n" = "lo" ] && continue
        mac=$(cat "$p/address" 2>/dev/null || true)
        mac=$(echo "$mac" | tr 'A-F' 'a-f')
        if [ -n "$mac" ] && [ "$mac" = "$want" ]; then
            echo "$n"
            return 0
        fi
    done
    return 1
}

WAN_IF=$(find_if_by_mac "$WAN_MAC" || true)
LAN_IF=$(find_if_by_mac "$LAN_MAC" || true)

# Если MAC-адреса не определились, пытаемся определить по порядку интерфейсов.
if [ -z "$WAN_IF" ] || [ -z "$LAN_IF" ] || [ "$WAN_IF" = "$LAN_IF" ]; then
    set -- $(non_lo_ifs)
    if [ -z "$WAN_IF" ]; then
        WAN_IF="$1"
    fi
    if [ -z "$LAN_IF" ] || [ "$LAN_IF" = "$WAN_IF" ]; then
        shift 2>/dev/null || true
        LAN_IF="$1"
    fi
fi

: "${WAN_IF:=eth0}"
: "${LAN_IF:=eth1}"
if [ "$WAN_IF" = "$LAN_IF" ]; then
    LAN_IF="eth1"
fi

echo "OpenWRT container: WAN=${WAN_IF}, LAN=${LAN_IF}"

cat > /etc/config/network <<NETWORK
config interface 'loopback'
    option device 'lo'
    option proto 'static'
    option ipaddr '127.0.0.1'
    option netmask '255.0.0.0'

config interface 'wan'
    option device '${WAN_IF}'
    option proto 'static'
    option ipaddr '${WAN_IP}'
    option netmask '${WAN_NETMASK}'
    option gateway '${WAN_GATEWAY}'
    list dns '${WAN_DNS}'
    option defaultroute '1'

config interface 'lan'
    option device '${LAN_IF}'
    option proto 'static'
    option ipaddr '${LAN_IP}'
    option netmask '${LAN_NETMASK}'
NETWORK

# Удаляем default route, который мог добавить Docker.
# OpenWRT сам создаст нужный default route через WAN_GATEWAY.
while ip route del default 2>/dev/null; do :; done

if [ -f /etc/config/system ]; then
    (
        set +e
        uci -q set system.@system[0].hostname="${HOSTNAME}"
        uci commit system
    )
fi

if [ -f /etc/config/dhcp ]; then
    (
        set +e
        uci -q set dhcp.lan=dhcp
        uci set dhcp.lan.interface=lan
        uci set dhcp.lan.start=100
        uci set dhcp.lan.limit=150
        uci set dhcp.lan.leasetime=12h

        uci -q set dhcp.wan=dhcp
        uci set dhcp.wan.interface=wan
        uci set dhcp.wan.ignore=1

        uci commit dhcp
    )
fi

ensure_web_rule() {
    name="$1"
    port="$2"

    if ! uci -q get "firewall.${name}" >/dev/null 2>&1; then
        uci set "firewall.${name}=rule"
    fi

    uci set "firewall.${name}.name=${name}"
    uci set "firewall.${name}.src=wan"
    uci set "firewall.${name}.proto=tcp"
    uci set "firewall.${name}.dest_port=${port}"
    uci set "firewall.${name}.target=ACCEPT"

    if [ -n "${WAN_WEB_ALLOW}" ]; then
        uci set "firewall.${name}.src_ip=${WAN_WEB_ALLOW}"
    else
        uci -q delete "firewall.${name}.src_ip"
    fi
}

if [ -f /etc/config/firewall ]; then
    (
        set +e
        ensure_web_rule allow_http_wan 80
        ensure_web_rule allow_https_wan 443
        uci commit firewall
    )
fi

# Самоподписанный сертификат для HTTPS/LuCI.
if [ ! -s /etc/uhttpd.crt ] || [ ! -s /etc/uhttpd.key ]; then
    if command -v openssl >/dev/null 2>&1; then
        openssl req -x509 -newkey rsa:2048 -nodes -days 825 \
            -keyout /etc/uhttpd.key -out /etc/uhttpd.crt \
            -subj "/CN=${HOSTNAME}" >/dev/null 2>&1 || true
        chmod 600 /etc/uhttpd.key 2>/dev/null || true
    fi
fi

exec /sbin/init
EOF

RUN chmod +x /entrypoint.sh

EXPOSE 80 443

ENTRYPOINT ["/entrypoint.sh"]

Важно:

  • ChangeMe_Strong замените на свой пароль root для LuCI/SSH.
  • Если ваш шлюз не 192.168.0.1, потом нужно будет поменять это и в Dockerfile/docker-compose.yml, и в секции сетей.

3. docker-compose.yml

В той же директории создайте docker-compose.yml:

services:
  openwrt:
    build:
      context: .
      args:
        OPENWRT_TAG: "23.05.4"
        ROOT_PASSWORD: "ChangeMe_Strong"
    image: openwrt-macvlan:23.05
    container_name: openwrt
    hostname: openwrt
    restart: unless-stopped
    stop_grace_period: 30s

    # Для OpenWRT в контейнере проще всего использовать privileged.
    # Без этого обычно приходится долго подбирать capabilities, устройства и права.
    privileged: true

    environment:
      TZ: "UTC"
      HOSTNAME: "openwrt"

      # MAC-адреса используются entrypoint-скриптом, чтобы правильно назначить eth0/eth1.
      WAN_MAC: "02:42:c0:a8:00:c8"
      LAN_MAC: "02:42:c0:a8:c8:01"

      # Сетевые настройки OpenWRT.
      WAN_IP: "192.168.0.200"
      WAN_NETMASK: "255.255.255.0"
      WAN_GATEWAY: "192.168.0.1"
      WAN_DNS: "192.168.0.1"

      LAN_IP: "192.168.200.1"
      LAN_NETMASK: "255.255.255.0"

      # Разрешаем доступ к web-интерфейсу со стороны WAN-сети.
      # Для большей безопасности оставляем только 192.168.0.0/24.
      # Если нужно разрешить любой источник, поставьте пустую строку:
      # WAN_WEB_ALLOW: ""
      WAN_WEB_ALLOW: "192.168.0.0/24"

    volumes:
      # Нужны модули ядра хоста для nftables/conntrack/NAT.
      - /lib/modules:/lib/modules:ro

      # Сохраняем конфигурацию OpenWRT между пересозданиями контейнера.
      - openwrt-etc:/etc

    sysctls:
      - net.ipv4.ip_forward=1
      - net.ipv4.conf.all.rp_filter=0
      - net.ipv4.conf.default.rp_filter=0
      - net.ipv4.conf.all.send_redirects=0
      - net.ipv4.conf.default.send_redirects=0
      - net.ipv4.conf.all.accept_redirects=0
      - net.ipv4.conf.default.accept_redirects=0

    networks:
      wan:
        ipv4_address: 192.168.0.200
        mac_address: "02:42:c0:a8:00:c8"
        priority: 1000
      lan:
        ipv4_address: 192.168.200.1
        mac_address: "02:42:c0:a8:c8:01"
        priority: 900

networks:
  wan:
    driver: macvlan
    driver_opts:
      parent: enp6s0
    enable_ipv6: false
    ipam:
      config:
        - subnet: 192.168.0.0/24
          gateway: 192.168.0.1
          # Разрешаем Docker выделять только адрес контейнера.
          # Если compose ругается на ip_range, можно удалить эту строку.
          ip_range: 192.168.0.200/32

  lan:
    driver: macvlan
    driver_opts:
      parent: enp7s0
    enable_ipv6: false
    ipam:
      config:
        - subnet: 192.168.200.0/24
          # 192.168.200.254 здесь -- служебный placeholder-шлюз для IPAM Docker.
          # Сам OpenWRT будет 192.168.200.1.
          # Не используйте 192.168.200.254 для других устройств.
          gateway: 192.168.200.254
          # Разрешаем Docker выделять только адрес контейнера.
          # Если compose ругается на ip_range, можно удалить эту строку.
          ip_range: 192.168.200.1/32

volumes:
  openwrt-etc:

Если ваш старый Docker Compose ругается на параметры mac_address или priority внутри services.openwrt.networks.*, удалите эти строки. Тогда важно сохранить порядок сетей: сначала wan, потом lan. В entrypoint есть fallback по порядку интерфейсов.


4. Сборка и запуск

Находясь в директории с Dockerfile и docker-compose.yml:

docker compose build --pull
docker compose up -d

Смотрим лог:

docker compose logs -f openwrt

В логе примерно должна быть строка:

OpenWRT container: WAN=eth0, LAN=eth1

Проверить интерфейсы внутри контейнера:

docker exec -it openwrt ip -br addr

Проверить WAN-шлюз:

docker exec -it openwrt ping -c 3 192.168.0.1

Проверить интернет через NAT OpenWRT:

docker exec -it openwrt ping -c 3 8.8.8.8

Или:

docker exec -it openwrt ping -c 3 1.1.1.1

5. Доступ к web-интерфейсу OpenWRT

После запуска:

Из LAN-сегмента OpenWRT

Подключите устройство к сети, которая физически висит на enp7s0.

Устройства должны получать DHCP из диапазона 192.168.200.100192.168.200.249.

Откройте:

http://192.168.200.1
https://192.168.200.1

Из вашей основной сети 192.168.0.0/24

С любого устройства в сети 192.168.0.0/24 откройте:

http://192.168.0.200
https://192.168.0.200

HTTPS будет использовать самоподписанный сертификат, поэтому браузер покажет предупреждение. Это нормально. Если хотите, потом можно импортировать сертификат или выпустить свой.

Логин:

root

Пароль:

ChangeMe_Strong

Или тот, который вы указали в Dockerfile в ARG ROOT_PASSWORD и в docker-compose.yml в build.args.ROOT_PASSWORD.

Обязательно смените пароль в LuCI:

System -> Administration

6. Важный момент: доступ к контейнеру с самого Docker-хоста

Из-за особенности macvlan Docker-хост часто не может напрямую обращаться к контейнеру в той же подсети через родительский интерфейс.

То есть с других машин в сети 192.168.0.0/24 и 192.168.200.0/24 доступ должен быть, а вот сам хост 192.168.0.162 может не открывать 192.168.0.200 без дополнительного macvlan-интерфейса.

Если вам нужен доступ именно с самого хоста, создайте служебные macvlan-интерфейсы.

Для WAN-доступа к OpenWRT:

sudo ip link add link enp6s0 name host-wan type macvlan mode bridge
sudo ip addr add 192.168.0.201/32 dev host-wan
sudo ip link set host-wan up
sudo ip route add 192.168.0.200/32 dev host-wan

Для LAN-доступа к OpenWRT:

sudo ip link add link enp7s0 name host-lan type macvlan mode bridge
sudo ip addr add 192.168.200.253/32 dev host-lan
sudo ip link set host-lan up
sudo ip route add 192.168.200.1/32 dev host-lan

После этого с хоста должны открываться:

http://192.168.0.200
http://192.168.200.1

Эти команды не сохраняются после перезагрузки автоматически. Если нужно, их можно оформить через systemd-networkd, rc.local, NetworkManager dispatcher или другой механизм в вашем дистрибутиве.


7. Что делать, если адреса или интерфейс поменялись

Если вы хотите изменить адреса, меняйте их одновременно в нескольких местах.

Например, для WAN:

  • services.openwrt.environment.WAN_IP
  • services.openwrt.networks.wan.ipv4_address
  • networks.wan.ipam.config[0].subnet
  • networks.wan.ipam.config[0].gateway, если меняется шлюз
  • при необходимости WAN_GATEWAY, WAN_DNS, WAN_WEB_ALLOW

Для LAN:

  • services.openwrt.environment.LAN_IP
  • services.openwrt.networks.lan.ipv4_address
  • networks.lan.ipam.config[0].subnet

Потом:

docker compose down
docker compose up -d

8. Если нужно полностью сбросить конфигурацию OpenWRT

Конфигурация хранится в Docker volume openwrt-etc. Чтобы сбросить её к состоянию из образа:

docker compose down
docker volume ls
docker volume rm <имя_проекта>_openwrt-etc
docker compose up -d

Имя проекта обычно совпадает с именем директории. Например, если папка называется openwrt-docker, volume может называться:

openwrt-docker_openwrt-etc

9. Важные замечания по безопасности

  1. LuCI на WAN-интерфейсе — потенциально опасно, если WAN когда-нибудь станет настоящим интернетом.
    В конфигурации выше доступ к web-интерфейсу со стороны WAN ограничен источником:
   192.168.0.0/24

Это переменная окружения:

   WAN_WEB_ALLOW: "192.168.0.0/24"

Если вам нужен доступ только с конкретного адреса, лучше указать, например:

   WAN_WEB_ALLOW: "192.168.0.162/32"
  1. SSH из WAN по умолчанию не разрешён. Разрешён только web 80/443. Это сделано специально. Если вам нужен SSH со стороны WAN, лучше не открывать его на весь WAN, а использовать VPN или отдельное правило с ограничением источника.
  2. Пароль root обязательно нужно сменить.
  3. Если enp7s0 и enp6s0 физически подключены к одному и тому же коммутатору без VLAN, LAN-сегмент OpenWRT может оказаться в одном L2-домене с основной сетью. Это нежелательно. Лучше использовать отдельный коммутатор/VLAN для enp7s0.

10. Короткая проверка работы

После запуска:

docker exec -it openwrt ip -br addr

Ожидается примерно:

lo               UNKNOWN        127.0.0.1/8
eth0             UP             192.168.0.200/24
eth1             UP             192.168.200.1/24

Маршруты:

docker exec -it openwrt ip route

Должен быть default route через ваш шлюз 192.168.0.1.

Firewall-правила для WAN-web:

docker exec -it openwrt uci show firewall | grep allow_http
docker exec -it openwrt uci show firewall | grep allow_https

Если всё работает, открывайте браузером:

http://192.168.200.1
http://192.168.0.200

root@panel:/opt/openwrt# docker compose build —pull
[+] Building 6.8s (4/4) FINISHED docker:default
=> [openwrt internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 5.48kB 0.0s
=> [openwrt] resolve image config for docker-image://docker.io/docker/dockerfile:1.8 2.4s
=> [openwrt] docker-image://docker.io/docker/dockerfile:1.8@sha256:e87caa74dcb7d46cd820352bfea12591f3dba3ddc4285e19c7dcd13359f7cefd 3.2s
=> => resolve docker.io/docker/dockerfile:1.8@sha256:e87caa74dcb7d46cd820352bfea12591f3dba3ddc4285e19c7dcd13359f7cefd 0.0s
=> => sha256:1d7e21b9eae3317824ebc8707e591a810a37dff6aff8e1d175f7d8c95a3aa532 12.09MB / 12.09MB 2.4s
=> => sha256:e87caa74dcb7d46cd820352bfea12591f3dba3ddc4285e19c7dcd13359f7cefd 8.40kB / 8.40kB 0.0s
=> => sha256:dde28735fbb848299b7a43b7561d828b89c76f84f745c31896a7a7efbf93851a 482B / 482B 0.0s
=> => sha256:265d11c3a0f3387fc23a28141d04918dae4cfa0b0ff311f958e05b23f772db95 1.26kB / 1.26kB 0.0s
=> => extracting sha256:1d7e21b9eae3317824ebc8707e591a810a37dff6aff8e1d175f7d8c95a3aa532 0.7s

=> ERROR [openwrt internal] load metadata for docker.io/openwrt/rootfs:23.05.4 1.1s

[openwrt internal] load metadata for docker.io/openwrt/rootfs:23.05.4:

failed to solve: openwrt/rootfs:23.05.4: docker.io/openwrt/rootfs:23.05.4: not found

Ошибка openwrt/rootfs:23.05.4: not found возникла по двум причинам:

  1. Формат тегов: В официальном репозитории Docker Hub (openwrt/rootfs) теги включают архитектуру. Тега 23.05.4 не существует, есть теги вида x86-64-23.05.4 или x86-64-23.05.5.
  2. Смена репозитория: Разработчики OpenWrt недавно перенесли образы с openwrtorg/rootfs на openwrt/rootfs, что часто вызывает путаницу с кэшем Docker.

Чтобы избежать подобных проблем в будущем и не зависеть от тегов Docker Hub, самый надежный способ — это собирать образ самостоятельно, скачивая официальный архив rootfs напрямую с сервера OpenWrt и импортируя его в Docker.

Ниже приведены обновленные конфигурационные файлы. Этот подход гарантирует, что вы получите именно ту версию OpenWrt и архитектуру (x86_64), которая вам нужна.

1. Обновленный Dockerfile

Замените содержимое вашего Dockerfile на следующее. Здесь используется FROM scratch, скачивается официальный rootfs и устанавливаются все необходимые пакеты.

# syntax=docker/dockerfile:1.8

ARG OPENWRT_VERSION=23.05.5
ARG TARGET_ARCH=x86-64
# Ссылка на официальный архив rootfs от OpenWrt
ARG ROOTFS_URL=https://downloads.openwrt.org/releases/${OPENWRT_VERSION}/targets/x86/64/openwrt-${OPENWRT_VERSION}-${TARGET_ARCH}-rootfs.tar.gz

FROM scratch AS base
ARG ROOTFS_URL
# Скачиваем и распаковываем официальный rootfs напрямую с сервера OpenWrt
ADD ${ROOTFS_URL} /

FROM base AS openwrt

ARG ROOT_PASSWORD=ChangeMe_Strong
ENV TZ=UTC

# OpenWRT требует некоторых базовых директорий для работы в контейнере
RUN mkdir -p /var/lock /var/run /tmp /rom \
 && cp -a /etc /rom/ \
 && ln -sf /proc/mounts /etc/mtab

# Устанавливаем пакеты, необходимые для работы роутера и Web-интерфейса (LuCI)
RUN opkg update \
 && opkg install --force-overwrite --force-space \
        luci-ssl \
        uhttpd \
        uhttpd-mod-ubus \
        dnsmasq \
        firewall4 \
        nftables \
        dropbear \
        ip-full \
        ca-bundle \
        openssl-util \
 && rm -rf /var/opkg-lists /tmp/*

# Устанавливаем пароль root
RUN echo "root:${ROOT_PASSWORD}" | chpasswd

# Включаем автозапуск нужных сервисов
RUN for s in boot network firewall dnsmasq dropbear uhttpd rpcd sysctl; do \
        if [ -x "/etc/init.d/$s" ]; then "/etc/init.d/$s" enable || true; fi; \
    done

COPY <<'EOF' /entrypoint.sh
#!/bin/sh
set -e

: "${HOSTNAME:=openwrt}"
: "${WAN_MAC:=02:42:c0:a8:00:c8}"
: "${LAN_MAC:=02:42:c0:a8:c8:01}"
: "${WAN_IP:=192.168.0.200}"
: "${WAN_NETMASK:=255.255.255.0}"
: "${WAN_GATEWAY:=192.168.0.1}"
: "${WAN_DNS:=192.168.0.1}"
: "${LAN_IP:=192.168.200.1}"
: "${LAN_NETMASK:=255.255.255.0}"
: "${WAN_WEB_ALLOW:=192.168.0.0/24}"

if [ -n "${ROOT_PASSWORD:-}" ] && [ ! -f /etc/.docker_root_password_set ]; then
    echo "root:${ROOT_PASSWORD}" | chpasswd
    touch /etc/.docker_root_password_set
fi

hostname "${HOSTNAME}" >/dev/null 2>&1 || true

non_lo_ifs() {
    ip -o link show 2>/dev/null | awk -F': ' '{print $2}' | cut -d'@' -f1 | grep -E '^eth[0-9]+$' || true
}

i=0
while [ "$i" -lt 15 ]; do
    cnt=$(non_lo_ifs | wc -l)
    [ "$cnt" -ge 2 ] && break
    sleep 1
    i=$((i+1))
done

find_if_by_mac() {
    want=$(echo "$1" | tr 'A-F' 'a-f')
    for p in /sys/class/net/*; do
        n="${p##*/}"
        [ "$n" = "lo" ] && continue
        mac=$(cat "$p/address" 2>/dev/null || true)
        mac=$(echo "$mac" | tr 'A-F' 'a-f')
        if [ -n "$mac" ] && [ "$mac" = "$want" ]; then
            echo "$n"
            return 0
        fi
    done
    return 1
}

WAN_IF=$(find_if_by_mac "$WAN_MAC" || true)
LAN_IF=$(find_if_by_mac "$LAN_MAC" || true)

if [ -z "$WAN_IF" ] || [ -z "$LAN_IF" ] || [ "$WAN_IF" = "$LAN_IF" ]; then
    set -- $(non_lo_ifs)
    if [ -z "$WAN_IF" ]; then
        WAN_IF="$1"
    fi
    if [ -z "$LAN_IF" ] || [ "$LAN_IF" = "$WAN_IF" ]; then
        shift 2>/dev/null || true
        LAN_IF="$1"
    fi
fi

: "${WAN_IF:=eth0}"
: "${LAN_IF:=eth1}"
if [ "$WAN_IF" = "$LAN_IF" ]; then
    LAN_IF="eth1"
fi

echo "OpenWRT container: WAN=${WAN_IF}, LAN=${LAN_IF}"

cat > /etc/config/network <<NETWORK
config interface 'loopback'
    option device 'lo'
    option proto 'static'
    option ipaddr '127.0.0.1'
    option netmask '255.0.0.0'

config interface 'wan'
    option device '${WAN_IF}'
    option proto 'static'
    option ipaddr '${WAN_IP}'
    option netmask '${WAN_NETMASK}'
    option gateway '${WAN_GATEWAY}'
    list dns '${WAN_DNS}'
    option defaultroute '1'

config interface 'lan'
    option device '${LAN_IF}'
    option proto 'static'
    option ipaddr '${LAN_IP}'
    option netmask '${LAN_NETMASK}'
NETWORK

while ip route del default 2>/dev/null; do :; done

if [ -f /etc/config/system ]; then
    (
        set +e
        uci -q set system.@system[0].hostname="${HOSTNAME}"
        uci commit system
    )
fi

if [ -f /etc/config/dhcp ]; then
    (
        set +e
        uci -q set dhcp.lan=dhcp
        uci set dhcp.lan.interface=lan
        uci set dhcp.lan.start=100
        uci set dhcp.lan.limit=150
        uci set dhcp.lan.leasetime=12h

        uci -q set dhcp.wan=dhcp
        uci set dhcp.wan.interface=wan
        uci set dhcp.wan.ignore=1

        uci commit dhcp
    )
fi

ensure_web_rule() {
    name="$1"
    port="$2"

    if ! uci -q get "firewall.${name}" >/dev/null 2>&1; then
        uci set "firewall.${name}=rule"
    fi

    uci set "firewall.${name}.name=${name}"
    uci set "firewall.${name}.src=wan"
    uci set "firewall.${name}.proto=tcp"
    uci set "firewall.${name}.dest_port=${port}"
    uci set "firewall.${name}.target=ACCEPT"

    if [ -n "${WAN_WEB_ALLOW}" ]; then
        uci set "firewall.${name}.src_ip=${WAN_WEB_ALLOW}"
    else
        uci -q delete "firewall.${name}.src_ip"
    fi
}

if [ -f /etc/config/firewall ]; then
    (
        set +e
        ensure_web_rule allow_http_wan 80
        ensure_web_rule allow_https_wan 443
        uci commit firewall
    )
fi

if [ ! -s /etc/uhttpd.crt ] || [ ! -s /etc/uhttpd.key ]; then
    if command -v openssl >/dev/null 2>&1; then
        openssl req -x509 -newkey rsa:2048 -nodes -days 825 \
            -keyout /etc/uhttpd.key -out /etc/uhttpd.crt \
            -subj "/CN=${HOSTNAME}" >/dev/null 2>&1 || true
        chmod 600 /etc/uhttpd.key 2>/dev/null || true
    fi
fi

exec /sbin/init
EOF

RUN chmod +x /entrypoint.sh

EXPOSE 80 443

ENTRYPOINT ["/entrypoint.sh"]

2. Обновленный docker-compose.yml

Внесите изменения в секцию build.args. Убран неиспользуемый OPENWRT_TAG и добавлен OPENWRT_VERSION.

services:
  openwrt:
    build:
      context: .
      args:
        OPENWRT_VERSION: "23.05.5"
        ROOT_PASSWORD: "ChangeMe_Strong"
    image: openwrt-macvlan:23.05
    container_name: openwrt
    hostname: openwrt
    restart: unless-stopped
    stop_grace_period: 30s

    privileged: true

    environment:
      TZ: "UTC"
      HOSTNAME: "openwrt"

      WAN_MAC: "02:42:c0:a8:00:c8"
      LAN_MAC: "02:42:c0:a8:c8:01"

      WAN_IP: "192.168.0.200"
      WAN_NETMASK: "255.255.255.0"
      WAN_GATEWAY: "192.168.0.1"
      WAN_DNS: "192.168.0.1"

      LAN_IP: "192.168.200.1"
      LAN_NETMASK: "255.255.255.0"

      WAN_WEB_ALLOW: "192.168.0.0/24"

    volumes:
      - /lib/modules:/lib/modules:ro
      - openwrt-etc:/etc

    sysctls:
      - net.ipv4.ip_forward=1
      - net.ipv4.conf.all.rp_filter=0
      - net.ipv4.conf.default.rp_filter=0
      - net.ipv4.conf.all.send_redirects=0
      - net.ipv4.conf.default.send_redirects=0
      - net.ipv4.conf.all.accept_redirects=0
      - net.ipv4.conf.default.accept_redirects=0

    networks:
      wan:
        ipv4_address: 192.168.0.200
        mac_address: "02:42:c0:a8:00:c8"
        priority: 1000
      lan:
        ipv4_address: 192.168.200.1
        mac_address: "02:42:c0:a8:c8:01"
        priority: 900

networks:
  wan:
    driver: macvlan
    driver_opts:
      parent: enp6s0
    enable_ipv6: false
    ipam:
      config:
        - subnet: 192.168.0.0/24
          gateway: 192.168.0.1
          ip_range: 192.168.0.200/32

  lan:
    driver: macvlan
    driver_opts:
      parent: enp7s0
    enable_ipv6: false
    ipam:
      config:
        - subnet: 192.168.200.0/24
          gateway: 192.168.200.254
          ip_range: 192.168.200.1/32

volumes:
  openwrt-etc:

3. Как пересобрать образ

Теперь выполните следующие команды для принудительной пересборки (без использования старого кэша, который мог запомнить ошибку):

# 1. Остановите и удалите старый контейнер и сеть
docker compose down

# 2. Полностью пересоберите образ, игнорируя старый кэш
docker compose build --no-cache --pull

# 3. Запустите контейнер
docker compose up -d

Что изменилось в новой версии:

  • Надежность: Мы больше не зависим от того, успели ли мейнтейнеры OpenWrt залить нужный тег на Docker Hub. Мы качаем оригинальный архив с downloads.openwrt.org.
  • Архитектура: Явно задана архитектура x86-64 (так как ваши интерфейсы enp... говорят о том, что это стандартный x86_64 сервер/ПК).
  • Чистота: Пакеты ставятся один раз при сборке образа, а не каждый раз при запуске контейнера, что ускоряет старт.

root@panel:/opt/openwrt# docker compose build —no-cache —pull
[+] Building 2.6s (8/13) docker:default
=> [openwrt internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 5.63kB 0.0s
=> [openwrt] resolve image config for docker-image://docker.io/docker/dockerfile:1.8 1.2s
=> CACHED [openwrt] docker-image://docker.io/docker/dockerfile:1.8@sha256:e87caa74dcb7d46cd820352bfea12591f3dba3ddc4285e19c7dcd13359f7cefd 0.0s
=> [openwrt internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [openwrt base 1/1] ADD https://downloads.openwrt.org/releases/23.05.5/targets/x86/64/openwrt-23.05.5-x86-64-rootfs.tar.gz / 1.0s
=> [openwrt internal] preparing inline document 0.0s
=> [openwrt base 1/1] ADD https://downloads.openwrt.org/releases/23.05.5/targets/x86/64/openwrt-23.05.5-x86-64-rootfs.tar.gz / 0.0s

=> ERROR [openwrt openwrt 1/6] RUN mkdir -p /var/lock /var/run /tmp /rom && cp -a /etc /rom/ && ln -sf /proc/mounts /etc/mtab 0.2s

[openwrt openwrt 1/6] RUN mkdir -p /var/lock /var/run /tmp /rom && cp -a /etc /rom/ && ln -sf /proc/mounts /etc/mtab:

0.202 runc run failed: unable to start container process: exec: «/bin/sh»: stat /bin/sh: no such file or directory

failed to solve: process «/bin/sh -c mkdir -p /var/lock /var/run /tmp /rom && cp -a /etc /rom/ && ln -sf /proc/mounts /etc/mtab» did not complete successfully: exit code: 1
root@panel:/opt/openwrt#

Ошибка exec: "/bin/sh": stat /bin/sh: no such file or directory возникает по одной конкретной причине: в официальном архиве rootfs OpenWrt отсутствует файл /bin/sh.

По умолчанию Docker при сборке пытается выполнять все инструкции RUN через /bin/sh. В стандартных дистрибутивах (Alpine, Ubuntu) /bin/sh существует, но в «чистом» rootfs OpenWrt его нет — есть только /bin/ash (или симлинки на busybox). Из-за этого сборщик Docker просто не может выполнить ни одной команды после распаковки архива.

Решение

Нужно явно указать Docker использовать /bin/ash вместо /bin/sh для всех команд сборки. Это делается одной инструкцией SHELL.

Вот полностью исправленный Dockerfile:

# syntax=docker/dockerfile:1.8

ARG OPENWRT_VERSION=23.05.5
ARG TARGET_ARCH=x86-64
ARG ROOTFS_URL=https://downloads.openwrt.org/releases/${OPENWRT_VERSION}/targets/x86/64/openwrt-${OPENWRT_VERSION}-${TARGET_ARCH}-rootfs.tar.gz

FROM scratch AS base
ARG ROOTFS_URL
# Скачиваем и распаковываем официальный rootfs напрямую с сервера OpenWrt
ADD ${ROOTFS_URL} /

FROM base AS openwrt

ARG ROOT_PASSWORD=ChangeMe_Strong
ENV TZ=UTC

# КРИТИЧЕСКИ ВАЖНО: В OpenWrt rootfs нет /bin/sh, поэтому переключаем shell на /bin/ash
SHELL ["/bin/ash", "-c"]

# OpenWRT требует некоторых базовых директорий для работы в контейнере
RUN mkdir -p /var/lock /var/run /tmp /rom \
 && cp -a /etc /rom/ \
 && ln -sf /proc/mounts /etc/mtab

# Устанавливаем пакеты, необходимые для работы роутера и Web-интерфейса (LuCI)
RUN opkg update \
 && opkg install --force-overwrite --force-space \
        luci-ssl \
        uhttpd \
        uhttpd-mod-ubus \
        dnsmasq \
        firewall4 \
        nftables \
        dropbear \
        ip-full \
        ca-bundle \
        openssl-util \
 && rm -rf /var/opkg-lists /tmp/*

# Устанавливаем пароль root
RUN echo "root:${ROOT_PASSWORD}" | chpasswd

# Включаем автозапуск нужных сервисов
RUN for s in boot network firewall dnsmasq dropbear uhttpd rpcd sysctl; do \
        if [ -x "/etc/init.d/$s" ]; then "/etc/init.d/$s" enable || true; fi; \
    done

COPY <<'EOF' /entrypoint.sh
#!/bin/sh
set -e

: "${HOSTNAME:=openwrt}"
: "${WAN_MAC:=02:42:c0:a8:00:c8}"
: "${LAN_MAC:=02:42:c0:a8:c8:01}"
: "${WAN_IP:=192.168.0.200}"
: "${WAN_NETMASK:=255.255.255.0}"
: "${WAN_GATEWAY:=192.168.0.1}"
: "${WAN_DNS:=192.168.0.1}"
: "${LAN_IP:=192.168.200.1}"
: "${LAN_NETMASK:=255.255.255.0}"
: "${WAN_WEB_ALLOW:=192.168.0.0/24}"

if [ -n "${ROOT_PASSWORD:-}" ] && [ ! -f /etc/.docker_root_password_set ]; then
    echo "root:${ROOT_PASSWORD}" | chpasswd
    touch /etc/.docker_root_password_set
fi

hostname "${HOSTNAME}" >/dev/null 2>&1 || true

non_lo_ifs() {
    ip -o link show 2>/dev/null | awk -F': ' '{print $2}' | cut -d'@' -f1 | grep -E '^eth[0-9]+$' || true
}

i=0
while [ "$i" -lt 15 ]; do
    cnt=$(non_lo_ifs | wc -l)
    [ "$cnt" -ge 2 ] && break
    sleep 1
    i=$((i+1))
done

find_if_by_mac() {
    want=$(echo "$1" | tr 'A-F' 'a-f')
    for p in /sys/class/net/*; do
        n="${p##*/}"
        [ "$n" = "lo" ] && continue
        mac=$(cat "$p/address" 2>/dev/null || true)
        mac=$(echo "$mac" | tr 'A-F' 'a-f')
        if [ -n "$mac" ] && [ "$mac" = "$want" ]; then
            echo "$n"
            return 0
        fi
    done
    return 1
}

WAN_IF=$(find_if_by_mac "$WAN_MAC" || true)
LAN_IF=$(find_if_by_mac "$LAN_MAC" || true)

if [ -z "$WAN_IF" ] || [ -z "$LAN_IF" ] || [ "$WAN_IF" = "$LAN_IF" ]; then
    set -- $(non_lo_ifs)
    if [ -z "$WAN_IF" ]; then
        WAN_IF="$1"
    fi
    if [ -z "$LAN_IF" ] || [ "$LAN_IF" = "$WAN_IF" ]; then
        shift 2>/dev/null || true
        LAN_IF="$1"
    fi
fi

: "${WAN_IF:=eth0}"
: "${LAN_IF:=eth1}"
if [ "$WAN_IF" = "$LAN_IF" ]; then
    LAN_IF="eth1"
fi

echo "OpenWRT container: WAN=${WAN_IF}, LAN=${LAN_IF}"

cat > /etc/config/network <<NETWORK
config interface 'loopback'
    option device 'lo'
    option proto 'static'
    option ipaddr '127.0.0.1'
    option netmask '255.0.0.0'

config interface 'wan'
    option device '${WAN_IF}'
    option proto 'static'
    option ipaddr '${WAN_IP}'
    option netmask '${WAN_NETMASK}'
    option gateway '${WAN_GATEWAY}'
    list dns '${WAN_DNS}'
    option defaultroute '1'

config interface 'lan'
    option device '${LAN_IF}'
    option proto 'static'
    option ipaddr '${LAN_IP}'
    option netmask '${LAN_NETMASK}'
NETWORK

while ip route del default 2>/dev/null; do :; done

if [ -f /etc/config/system ]; then
    (
        set +e
        uci -q set system.@system[0].hostname="${HOSTNAME}"
        uci commit system
    )
fi

if [ -f /etc/config/dhcp ]; then
    (
        set +e
        uci -q set dhcp.lan=dhcp
        uci set dhcp.lan.interface=lan
        uci set dhcp.lan.start=100
        uci set dhcp.lan.limit=150
        uci set dhcp.lan.leasetime=12h

        uci -q set dhcp.wan=dhcp
        uci set dhcp.wan.interface=wan
        uci set dhcp.wan.ignore=1

        uci commit dhcp
    )
fi

ensure_web_rule() {
    name="$1"
    port="$2"

    if ! uci -q get "firewall.${name}" >/dev/null 2>&1; then
        uci set "firewall.${name}=rule"
    fi

    uci set "firewall.${name}.name=${name}"
    uci set "firewall.${name}.src=wan"
    uci set "firewall.${name}.proto=tcp"
    uci set "firewall.${name}.dest_port=${port}"
    uci set "firewall.${name}.target=ACCEPT"

    if [ -n "${WAN_WEB_ALLOW}" ]; then
        uci set "firewall.${name}.src_ip=${WAN_WEB_ALLOW}"
    else
        uci -q delete "firewall.${name}.src_ip"
    fi
}

if [ -f /etc/config/firewall ]; then
    (
        set +e
        ensure_web_rule allow_http_wan 80
        ensure_web_rule allow_https_wan 443
        uci commit firewall
    )
fi

if [ ! -s /etc/uhttpd.crt ] || [ ! -s /etc/uhttpd.key ]; then
    if command -v openssl >/dev/null 2>&1; then
        openssl req -x509 -newkey rsa:2048 -nodes -days 825 \
            -keyout /etc/uhttpd.key -out /etc/uhttpd.crt \
            -subj "/CN=${HOSTNAME}" >/dev/null 2>&1 || true
        chmod 600 /etc/uhttpd.key 2>/dev/null || true
    fi
fi

exec /sbin/init
EOF

RUN chmod +x /entrypoint.sh

EXPOSE 80 443

ENTRYPOINT ["/entrypoint.sh"]

Что изменилось:

  1. Добавлена строка SHELL ["/bin/ash", "-c"]. Теперь все команды RUN будут выполняться через /bin/ash, который точно есть в архиве.
  2. Убран лишний COPY (все делается через ADD).
  3. Файл docker-compose.yml остается без изменений.

Запуск сборки:

Теперь выполните сборку заново:

docker compose build --no-cache
docker compose up -d

Сборка может занять 1-3 минуты, так как opkg update && opkg install будет скачивать и устанавливать все пакеты LuCI и firewall прямо в образ. После успешной сборки контейнер сразу поднимется с готовой конфигурацией.

root@panel:/opt/openwrt# docker compose build —no-cache
[+] Building 1.7s (8/13) docker:default
=> [openwrt internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 5.71kB 0.0s
=> [openwrt] resolve image config for docker-image://docker.io/docker/dockerfile:1.8 1.1s
=> CACHED [openwrt] docker-image://docker.io/docker/dockerfile:1.8@sha256:e87caa74dcb7d46cd820352bfea12591f3dba3ddc4285e19c7dcd13359f7cefd 0.0s
=> [openwrt internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> CACHED [openwrt internal] preparing inline document 0.0s
=> CACHED [openwrt base 1/1] ADD https://downloads.openwrt.org/releases/23.05.5/targets/x86/64/openwrt-23.05.5-x86-64-rootfs.tar.gz / 0.3s
=> [openwrt base 1/1] ADD https://downloads.openwrt.org/releases/23.05.5/targets/x86/64/openwrt-23.05.5-x86-64-rootfs.tar.gz / 0.0s

=> ERROR [openwrt openwrt 1/6] RUN mkdir -p /var/lock /var/run /tmp /rom && cp -a /etc /rom/ && ln -sf /proc/mounts /etc/mtab 0.2s

[openwrt openwrt 1/6] RUN mkdir -p /var/lock /var/run /tmp /rom && cp -a /etc /rom/ && ln -sf /proc/mounts /etc/mtab:

0.197 runc run failed: unable to start container process: exec: «/bin/ash»: stat /bin/ash: no such file or directory

failed to solve: process «/bin/ash -c mkdir -p /var/lock /var/run /tmp /rom && cp -a /etc /rom/ && ln -sf /proc/mounts /etc/mtab» did not complete successfully: exit code: 1
root@panel:/opt/openwrt#

Ошибка stat /bin/ash: no such file or directory возникает из-за особенности инструкции ADD в Docker.

Когда вы используете ADD с URL-адресом (например, ADD https://.../rootfs.tar.gz /), Docker скачивает файл, но не распаковывает его. В результате внутри образа оказывался один архивный файл, а не файловая система OpenWrt. Поэтому ни /bin/sh, ни /bin/ash не существовали, и Docker не мог выполнить ни одну команду RUN.

Решение: Многоэтапная сборка (Multi-stage Build)

Чтобы гарантированно распаковать архив, мы будем использовать двухэтапную сборку:

  1. Stage 1 (Alpine): Скачивает архив по URL и распаковывает его в чистую директорию. Alpine точно имеет curl и tar.
  2. Stage 2 (Scratch): Копирует уже распакованную файловую систему из Alpine в финальный пустой образ scratch.

Исправленный Dockerfile

Замените содержимое вашего Dockerfile на этот код. Он полностью самодостаточен и не зависит от наличия тегов на Docker Hub.

# syntax=docker/dockerfile:1.8

ARG OPENWRT_VERSION=23.05.5
ARG TARGET_ARCH=x86-64
# Ссылка на официальный архив rootfs
ARG ROOTFS_URL=https://downloads.openwrt.org/releases/${OPENWRT_VERSION}/targets/x86/64/openwrt-${OPENWRT_VERSION}-${TARGET_ARCH}-rootfs.tar.gz

# --- ЭТАП 1: Скачивание и распаковка ---
FROM alpine:3.19 AS downloader
ARG ROOTFS_URL
# Устанавливаем curl и tar, скачиваем архив и распаковываем его в /rootfs
RUN apk add --no-cache curl tar \
 && mkdir -p /rootfs \
 && curl -sSL ${ROOTFS_URL} | tar -xz -C /rootfs

# --- ЭТАП 2: Сборка финального образа ---
FROM scratch AS base
# Копируем распакованную файловую систему из этапа 1
COPY --from=downloader /rootfs /

FROM base AS openwrt

# В OpenWrt нет /bin/sh, поэтому переключаем shell для всех RUN команд на /bin/ash
SHELL ["/bin/ash", "-c"]

ARG ROOT_PASSWORD=ChangeMe_Strong
ENV TZ=UTC

# Создаем необходимые директории и симлинки для работы OpenWrt в контейнере
RUN mkdir -p /var/lock /var/run /tmp /rom \
 && cp -a /etc /rom/ \
 && ln -sf /proc/mounts /etc/mtab

# Устанавливаем пакеты для Web-интерфейса (LuCI) и маршрутизации
RUN opkg update \
 && opkg install --force-overwrite --force-space \
        luci-ssl \
        uhttpd \
        uhttpd-mod-ubus \
        dnsmasq \
        firewall4 \
        nftables \
        dropbear \
        ip-full \
        ca-bundle \
        openssl-util \
 && rm -rf /var/opkg-lists /tmp/*

# Устанавливаем пароль root
RUN echo "root:${ROOT_PASSWORD}" | chpasswd

# Включаем автозапуск сервисов
RUN for s in boot network firewall dnsmasq dropbear uhttpd rpcd sysctl; do \
        if [ -x "/etc/init.d/$s" ]; then "/etc/init.d/$s" enable || true; fi; \
    done

# Entrypoint-скрипт для настройки сети и фаервола при каждом запуске
COPY <<'EOF' /entrypoint.sh
#!/bin/sh
set -e

: "${HOSTNAME:=openwrt}"
: "${WAN_MAC:=02:42:c0:a8:00:c8}"
: "${LAN_MAC:=02:42:c0:a8:c8:01}"
: "${WAN_IP:=192.168.0.200}"
: "${WAN_NETMASK:=255.255.255.0}"
: "${WAN_GATEWAY:=192.168.0.1}"
: "${WAN_DNS:=192.168.0.1}"
: "${LAN_IP:=192.168.200.1}"
: "${LAN_NETMASK:=255.255.255.0}"
: "${WAN_WEB_ALLOW:=192.168.0.0/24}"

if [ -n "${ROOT_PASSWORD:-}" ] && [ ! -f /etc/.docker_root_password_set ]; then
    echo "root:${ROOT_PASSWORD}" | chpasswd
    touch /etc/.docker_root_password_set
fi

hostname "${HOSTNAME}" >/dev/null 2>&1 || true

non_lo_ifs() {
    ip -o link show 2>/dev/null | awk -F': ' '{print $2}' | cut -d'@' -f1 | grep -E '^eth[0-9]+$' || true
}

i=0
while [ "$i" -lt 15 ]; do
    cnt=$(non_lo_ifs | wc -l)
    [ "$cnt" -ge 2 ] && break
    sleep 1
    i=$((i+1))
done

find_if_by_mac() {
    want=$(echo "$1" | tr 'A-F' 'a-f')
    for p in /sys/class/net/*; do
        n="${p##*/}"
        [ "$n" = "lo" ] && continue
        mac=$(cat "$p/address" 2>/dev/null || true)
        mac=$(echo "$mac" | tr 'A-F' 'a-f')
        if [ -n "$mac" ] && [ "$mac" = "$want" ]; then
            echo "$n"
            return 0
        fi
    done
    return 1
}

WAN_IF=$(find_if_by_mac "$WAN_MAC" || true)
LAN_IF=$(find_if_by_mac "$LAN_MAC" || true)

if [ -z "$WAN_IF" ] || [ -z "$LAN_IF" ] || [ "$WAN_IF" = "$LAN_IF" ]; then
    set -- $(non_lo_ifs)
    if [ -z "$WAN_IF" ]; then
        WAN_IF="$1"
    fi
    if [ -z "$LAN_IF" ] || [ "$LAN_IF" = "$WAN_IF" ]; then
        shift 2>/dev/null || true
        LAN_IF="$1"
    fi
fi

: "${WAN_IF:=eth0}"
: "${LAN_IF:=eth1}"
if [ "$WAN_IF" = "$LAN_IF" ]; then
    LAN_IF="eth1"
fi

echo "OpenWRT container: WAN=${WAN_IF}, LAN=${LAN_IF}"

cat > /etc/config/network <<NETWORK
config interface 'loopback'
    option device 'lo'
    option proto 'static'
    option ipaddr '127.0.0.1'
    option netmask '255.0.0.0'

config interface 'wan'
    option device '${WAN_IF}'
    option proto 'static'
    option ipaddr '${WAN_IP}'
    option netmask '${WAN_NETMASK}'
    option gateway '${WAN_GATEWAY}'
    list dns '${WAN_DNS}'
    option defaultroute '1'

config interface 'lan'
    option device '${LAN_IF}'
    option proto 'static'
    option ipaddr '${LAN_IP}'
    option netmask '${LAN_NETMASK}'
NETWORK

while ip route del default 2>/dev/null; do :; done

if [ -f /etc/config/system ]; then
    (
        set +e
        uci -q set system.@system[0].hostname="${HOSTNAME}"
        uci commit system
    )
fi

if [ -f /etc/config/dhcp ]; then
    (
        set +e
        uci -q set dhcp.lan=dhcp
        uci set dhcp.lan.interface=lan
        uci set dhcp.lan.start=100
        uci set dhcp.lan.limit=150
        uci set dhcp.lan.leasetime=12h

        uci -q set dhcp.wan=dhcp
        uci set dhcp.wan.interface=wan
        uci set dhcp.wan.ignore=1

        uci commit dhcp
    )
fi

ensure_web_rule() {
    name="$1"
    port="$2"

    if ! uci -q get "firewall.${name}" >/dev/null 2>&1; then
        uci set "firewall.${name}=rule"
    fi

    uci set "firewall.${name}.name=${name}"
    uci set "firewall.${name}.src=wan"
    uci set "firewall.${name}.proto=tcp"
    uci set "firewall.${name}.dest_port=${port}"
    uci set "firewall.${name}.target=ACCEPT"

    if [ -n "${WAN_WEB_ALLOW}" ]; then
        uci set "firewall.${name}.src_ip=${WAN_WEB_ALLOW}"
    else
        uci -q delete "firewall.${name}.src_ip"
    fi
}

if [ -f /etc/config/firewall ]; then
    (
        set +e
        ensure_web_rule allow_http_wan 80
        ensure_web_rule allow_https_wan 443
        uci commit firewall
    )
fi

if [ ! -s /etc/uhttpd.crt ] || [ ! -s /etc/uhttpd.key ]; then
    if command -v openssl >/dev/null 2>&1; then
        openssl req -x509 -newkey rsa:2048 -nodes -days 825 \
            -keyout /etc/uhttpd.key -out /etc/uhttpd.crt \
            -subj "/CN=${HOSTNAME}" >/dev/null 2>&1 || true
        chmod 600 /etc/uhttpd.key 2>/dev/null || true
    fi
fi

exec /sbin/init
EOF

RUN chmod +x /entrypoint.sh

EXPOSE 80 443

ENTRYPOINT ["/entrypoint.sh"]

Файл docker-compose.yml (без изменений)

Оставьте его таким же, как в предыдущем ответе. Он не требует изменений.

Как запустить сборку

Перед сборкой обязательно нужно очистить старые слои кэша, чтобы Docker не пытался использовать битые промежуточные слои:

# 1. Остановите и удалите старые контейнеры и сети
docker compose down

# 2. Удалите старые неиспользуемые образы и кэш сборки
docker builder prune -af

# 3. Соберите образ заново с нуля
docker compose build --no-cache

# 4. Запустите контейнер
docker compose up -d

Теперь сборка должна пройти успешно. Этап downloader скачает архив, распакует его, а затем opkg установит все необходимые пакеты (LuCI, firewall4, dropbear) прямо в файловую систему. После запуска контейнер сразу будет готов к работе с настроенными сетевыми интерфейсами и открытым Web-интерфейсом.

root@panel:/opt/openwrt# docker compose build —no-cache
[+] Building 5.2s (3/3) FINISHED docker:default
=> [openwrt internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 4.27kB 0.0s
=> [openwrt] resolve image config for docker-image://docker.io/docker/dockerfile:1.8 2.0s
=> [openwrt] docker-image://docker.io/docker/dockerfile:1.8@sha256:e87caa74dcb7d46cd820352bfea12591f3dba3ddc4285e19c7dcd13359f7cefd 3.1s
=> => resolve docker.io/docker/dockerfile:1.8@sha256:e87caa74dcb7d46cd820352bfea12591f3dba3ddc4285e19c7dcd13359f7cefd 0.0s
=> => sha256:1d7e21b9eae3317824ebc8707e591a810a37dff6aff8e1d175f7d8c95a3aa532 12.09MB / 12.09MB 2.3s
=> => sha256:e87caa74dcb7d46cd820352bfea12591f3dba3ddc4285e19c7dcd13359f7cefd 8.40kB / 8.40kB 0.0s
=> => sha256:dde28735fbb848299b7a43b7561d828b89c76f84f745c31896a7a7efbf93851a 482B / 482B 0.0s
=> => sha256:265d11c3a0f3387fc23a28141d04918dae4cfa0b0ff311f958e05b23f772db95 1.26kB / 1.26kB 0.0s
=> => extracting sha256:1d7e21b9eae3317824ebc8707e591a810a37dff6aff8e1d175f7d8c95a3aa532 0.7s
failed to solve: unterminated heredoc
root@panel:/opt/openwrt#

Ошибка failed to solve: unterminated heredoc возникает из-за того, что при копировании текста из чата в Dockerfile редактор часто добавляет лишние пробелы или символы табуляции перед закрывающим маркером EOF (или NETWORK). Парсер Dockerfile требует, чтобы закрывающий маркер находился строго в самом начале строки без каких-либо отступов.

Самый надежный и чистый способ избавиться от этой ошибки навсегда — вынести скрипт инициализации в отдельный файл в вашей директории и просто скопировать его в образ.

В вашей директории /opt/openwrt должно теперь находиться три файла:

  1. Dockerfile
  2. docker-compose.yml
  3. entrypoint.sh (новый файл)

1. Создайте файл entrypoint.sh

Создайте в текущей директории файл с именем entrypoint.sh и вставьте в него следующий код:

#!/bin/sh
set -e

: "${HOSTNAME:=openwrt}"
: "${WAN_MAC:=02:42:c0:a8:00:c8}"
: "${LAN_MAC:=02:42:c0:a8:c8:01}"
: "${WAN_IP:=192.168.0.200}"
: "${WAN_NETMASK:=255.255.255.0}"
: "${WAN_GATEWAY:=192.168.0.1}"
: "${WAN_DNS:=192.168.0.1}"
: "${LAN_IP:=192.168.200.1}"
: "${LAN_NETMASK:=255.255.255.0}"
: "${WAN_WEB_ALLOW:=192.168.0.0/24}"

if [ -n "${ROOT_PASSWORD:-}" ] && [ ! -f /etc/.docker_root_password_set ]; then
    echo "root:${ROOT_PASSWORD}" | chpasswd
    touch /etc/.docker_root_password_set
fi

hostname "${HOSTNAME}" >/dev/null 2>&1 || true

non_lo_ifs() {
    ip -o link show 2>/dev/null | awk -F': ' '{print $2}' | cut -d'@' -f1 | grep -E '^eth[0-9]+$' || true
}

i=0
while [ "$i" -lt 15 ]; do
    cnt=$(non_lo_ifs | wc -l)
    [ "$cnt" -ge 2 ] && break
    sleep 1
    i=$((i+1))
done

find_if_by_mac() {
    want=$(echo "$1" | tr 'A-F' 'a-f')
    for p in /sys/class/net/*; do
        n="${p##*/}"
        [ "$n" = "lo" ] && continue
        mac=$(cat "$p/address" 2>/dev/null || true)
        mac=$(echo "$mac" | tr 'A-F' 'a-f')
        if [ -n "$mac" ] && [ "$mac" = "$want" ]; then
            echo "$n"
            return 0
        fi
    done
    return 1
}

WAN_IF=$(find_if_by_mac "$WAN_MAC" || true)
LAN_IF=$(find_if_by_mac "$LAN_MAC" || true)

if [ -z "$WAN_IF" ] || [ -z "$LAN_IF" ] || [ "$WAN_IF" = "$LAN_IF" ]; then
    set -- $(non_lo_ifs)
    if [ -z "$WAN_IF" ]; then
        WAN_IF="$1"
    fi
    if [ -z "$LAN_IF" ] || [ "$LAN_IF" = "$WAN_IF" ]; then
        shift 2>/dev/null || true
        LAN_IF="$1"
    fi
fi

: "${WAN_IF:=eth0}"
: "${LAN_IF:=eth1}"
if [ "$WAN_IF" = "$LAN_IF" ]; then
    LAN_IF="eth1"
fi

echo "OpenWRT container: WAN=${WAN_IF}, LAN=${LAN_IF}"

# Обратите внимание: heredoc внутри самого скрипта bash/sh работает отлично,
# проблема была именно в heredoc внутри Dockerfile
cat > /etc/config/network <<NETWORK
config interface 'loopback'
    option device 'lo'
    option proto 'static'
    option ipaddr '127.0.0.1'
    option netmask '255.0.0.0'

config interface 'wan'
    option device '${WAN_IF}'
    option proto 'static'
    option ipaddr '${WAN_IP}'
    option netmask '${WAN_NETMASK}'
    option gateway '${WAN_GATEWAY}'
    list dns '${WAN_DNS}'
    option defaultroute '1'

config interface 'lan'
    option device '${LAN_IF}'
    option proto 'static'
    option ipaddr '${LAN_IP}'
    option netmask '${LAN_NETMASK}'
NETWORK

while ip route del default 2>/dev/null; do :; done

if [ -f /etc/config/system ]; then
    (
        set +e
        uci -q set system.@system[0].hostname="${HOSTNAME}"
        uci commit system
    )
fi

if [ -f /etc/config/dhcp ]; then
    (
        set +e
        uci -q set dhcp.lan=dhcp
        uci set dhcp.lan.interface=lan
        uci set dhcp.lan.start=100
        uci set dhcp.lan.limit=150
        uci set dhcp.lan.leasetime=12h

        uci -q set dhcp.wan=dhcp
        uci set dhcp.wan.interface=wan
        uci set dhcp.wan.ignore=1

        uci commit dhcp
    )
fi

ensure_web_rule() {
    name="$1"
    port="$2"

    if ! uci -q get "firewall.${name}" >/dev/null 2>&1; then
        uci set "firewall.${name}=rule"
    fi

    uci set "firewall.${name}.name=${name}"
    uci set "firewall.${name}.src=wan"
    uci set "firewall.${name}.proto=tcp"
    uci set "firewall.${name}.dest_port=${port}"
    uci set "firewall.${name}.target=ACCEPT"

    if [ -n "${WAN_WEB_ALLOW}" ]; then
        uci set "firewall.${name}.src_ip=${WAN_WEB_ALLOW}"
    else
        uci -q delete "firewall.${name}.src_ip"
    fi
}

if [ -f /etc/config/firewall ]; then
    (
        set +e
        ensure_web_rule allow_http_wan 80
        ensure_web_rule allow_https_wan 443
        uci commit firewall
    )
fi

if [ ! -s /etc/uhttpd.crt ] || [ ! -s /etc/uhttpd.key ]; then
    if command -v openssl >/dev/null 2>&1; then
        openssl req -x509 -newkey rsa:2048 -nodes -days 825 \
            -keyout /etc/uhttpd.key -out /etc/uhttpd.crt \
            -subj "/CN=${HOSTNAME}" >/dev/null 2>&1 || true
        chmod 600 /etc/uhttpd.key 2>/dev/null || true
    fi
fi

exec /sbin/init

2. Обновите Dockerfile

Замените ваш текущий Dockerfile на этот упрощенный вариант. Мы убрали из него весь многострочный текст и заменили его на простую команду COPY:

# syntax=docker/dockerfile:1.8

ARG OPENWRT_VERSION=23.05.5
ARG TARGET_ARCH=x86-64
ARG ROOTFS_URL=https://downloads.openwrt.org/releases/${OPENWRT_VERSION}/targets/x86/64/openwrt-${OPENWRT_VERSION}-${TARGET_ARCH}-rootfs.tar.gz

# --- ЭТАП 1: Скачивание и распаковка ---
FROM alpine:3.19 AS downloader
ARG ROOTFS_URL
RUN apk add --no-cache curl tar \
 && mkdir -p /rootfs \
 && curl -sSL ${ROOTFS_URL} | tar -xz -C /rootfs

# --- ЭТАП 2: Сборка финального образа ---
FROM scratch AS base
COPY --from=downloader /rootfs /

FROM base AS openwrt

# В OpenWrt нет /bin/sh, переключаемся на /bin/ash
SHELL ["/bin/ash", "-c"]

ARG ROOT_PASSWORD=ChangeMe_Strong
ENV TZ=UTC

RUN mkdir -p /var/lock /var/run /tmp /rom \
 && cp -a /etc /rom/ \
 && ln -sf /proc/mounts /etc/mtab

RUN opkg update \
 && opkg install --force-overwrite --force-space \
        luci-ssl \
        uhttpd \
        uhttpd-mod-ubus \
        dnsmasq \
        firewall4 \
        nftables \
        dropbear \
        ip-full \
        ca-bundle \
        openssl-util \
 && rm -rf /var/opkg-lists /tmp/*

RUN echo "root:${ROOT_PASSWORD}" | chpasswd

RUN for s in boot network firewall dnsmasq dropbear uhttpd rpcd sysctl; do \
        if [ -x "/etc/init.d/$s" ]; then "/etc/init.d/$s" enable || true; fi; \
    done

# Просто копируем готовый скрипт из папки в образ
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

EXPOSE 80 443

ENTRYPOINT ["/entrypoint.sh"]

3. Запуск сборки

Теперь структура файлов в /opt/openwrt выглядит так:

  • Dockerfile
  • docker-compose.yml
  • entrypoint.sh

Запустите сборку и старт:

# Очищаем кэш и старые слои
docker builder prune -af

# Собираем
docker compose build --no-cache

# Запускаем
docker compose up -d

Теперь ошибка unterminated heredoc физически невозможна, так как Docker просто берет готовый файл entrypoint.sh и копирует его внутрь.

3.810 Downloading https://downloads.openwrt.org/releases/23.05.5/packages/x86_64/luci/Packages.sig
3.810 Signature check passed.
3.810 Downloading https://downloads.openwrt.org/releases/23.05.5/packages/x86_64/packages/Packages.gz
3.810 Updated list of available packages in /var/opkg-lists/openwrt_packages
3.810 Downloading https://downloads.openwrt.org/releases/23.05.5/packages/x86_64/packages/Packages.sig
3.810 Signature check passed.
5.428 Downloading https://downloads.openwrt.org/releases/23.05.5/packages/x86_64/routing/Packages.gz
5.428 Updated list of available packages in /var/opkg-lists/openwrt_routing
5.428 Downloading https://downloads.openwrt.org/releases/23.05.5/packages/x86_64/routing/Packages.sig
5.428 Signature check passed.
5.428 Downloading https://downloads.openwrt.org/releases/23.05.5/packages/x86_64/telephony/Packages.gz
5.428 Updated list of available packages in /var/opkg-lists/openwrt_telephony
5.428 Downloading https://downloads.openwrt.org/releases/23.05.5/packages/x86_64/telephony/Packages.sig
5.428 Signature check passed.
5.736 Upgrading luci-ssl on root from git-23.035.26083-7550ad6 to git-25.222.75657-7ce34fe…
7.654 Downloading https://downloads.openwrt.org/releases/23.05.5/packages/x86_64/luci/luci-ssl_git-25.222.75657-7ce34fe_all.ipk
7.654 Package uhttpd (2023-06-25-34a8a74d-2) installed in root is up to date.
7.654 Package uhttpd-mod-ubus (2023-06-25-34a8a74d-2) installed in root is up to date.
7.654 Package dnsmasq (2.90-2) installed in root is up to date.
7.654 Package firewall4 (2023-09-01-598d9fbb-1) installed in root is up to date.
7.654 Package nftables-json (1.0.8-1) installed in root is up to date.
7.654 Package dropbear (2022.82-6) installed in root is up to date.
7.654 Installing ip-full (6.3.0-1) to root…
7.654 Downloading https://downloads.openwrt.org/releases/23.05.5/packages/x86_64/base/ip-full_6.3.0-1_x86_64.ipk
7.654 Installing zlib (1.2.13-1) to root…
7.654 Downloading https://downloads.openwrt.org/releases/23.05.5/packages/x86_64/base/zlib_1.2.13-1_x86_64.ipk
7.654 Installing libelf1 (0.189-1) to root…
7.654 Downloading https://downloads.openwrt.org/releases/23.05.5/packages/x86_64/base/libelf1_0.189-1_x86_64.ipk
7.654 Installing libbpf1 (1.2.2-1) to root…
7.654 Downloading https://downloads.openwrt.org/releases/23.05.5/packages/x86_64/base/libbpf1_1.2.2-1_x86_64.ipkGenerating engines.cnf
11.24 Generating providers.cnf
11.28
11.28 Upgrading ca-bundle on root from 20230311-1 to 20241223-1…
11.28 Downloading https://downloads.openwrt.org/releases/23.05.5/packages/x86_64/base/ca-bundle_20241223-1_all.ipk
11.28 Installing openssl-util (3.0.16-1) to root…
11.28 Downloading https://downloads.openwrt.org/releases/23.05.5/packages/x86_64/base/openssl-util_3.0.16-1_x86_64.ipk
11.28 Installing libopenssl3 (3.0.16-1) to root…
11.28 Downloading https://downloads.openwrt.org/releases/23.05.5/packages/x86_64/base/libopenssl3_3.0.16-1_x86_64.ipk
11.28 Installing libopenssl-conf (3.0.16-1) to root…
11.28 Downloading https://downloads.openwrt.org/releases/23.05.5/packages/x86_64/base/libopenssl-conf_3.0.16-1_x86_64.ipk
11.28 Configuring libopenssl3.
11.28 Configuring zlib.
11.28 Configuring libelf1.
11.28 Configuring libbpf1.
11.28 Configuring ip-full.
11.28 Configuring libopenssl-conf.
11.28 Configuring openssl-util.
11.28 Configuring luci-ssl.
11.28 Configuring ca-bundle.

11.28 rm: can’t remove ‘/tmp/resolv.conf’: Resource busy

failed to solve: process «/bin/ash -c opkg update && opkg install —force-overwrite —force-space luci-ssl uhttpd uhttpd-mod-ubus dnsmasq firewall4 nftables dropbear ip-full ca-bundle openssl-util && rm -rf /var/opkg-lists /tmp/*» did not complete successfully: exit code: 1
root@panel:/opt/openwrt#

Ошибка rm: can't remove '/tmp/resolv.conf': Resource busy — это классическая проблема при сборке OpenWrt в Docker.

Почему это происходит?

В файловой системе OpenWrt файл /etc/resolv.conf является симлинком (ссылкой) на /tmp/resolv.conf.
Когда Docker выполняет команды внутри контейнера (например, opkg update), он автоматически пробрасывает DNS-настройки хоста, чтобы контейнер мог резолвить доменные имена. Docker видит симлинк /etc/resolv.conf и монтирует файл с DNS-серверами хоста прямо поверх цели симлинка — то есть поверх /tmp/resolv.conf.
В результате /tmp/resolv.conf становится точкой монтирования (mount point). Когда скрипт пытается удалить всё содержимое /tmp/ командой rm -rf /tmp/*, он натыкается на примонтированный файл, получает отказ системы («Resource busy») и прерывает сборку.

Решение

Нужно просто игнорировать ошибку удаления для /tmp/*, так как это временная файловая система, и примонтированный файл всё равно исчезнет после завершения сборки.

Откройте ваш Dockerfile и найдите блок с opkg install. Замените его на следующий (мы добавили || true в конце, чтобы сборка не падала из-за ошибки rm):

RUN opkg update \
 && opkg install --force-overwrite --force-space \
        luci-ssl \
        uhttpd \
        uhttpd-mod-ubus \
        dnsmasq \
        firewall4 \
        nftables \
        dropbear \
        ip-full \
        ca-bundle \
        openssl-util \
 && rm -rf /var/opkg-lists \
 && (rm -rf /tmp/* 2>/dev/null || true)

Что делать дальше

Так как предыдущая сборка прервалась на этом шаге, Docker не сохранил этот слой. Вам нужно просто запустить сборку заново. Можно не использовать --no-cache для предыдущих шагов (распаковка архива уже закэширована и пройдет мгновенно), но сам opkg придется подождать еще раз:

docker compose build

После успешной сборки запустите контейнер:

docker compose up -d

Проверить, что контейнер жив и интерфейсы поднялись, можно командой:

docker exec -it openwrt ip -br addr

=> ERROR [openwrt openwrt 3/6] RUN echo «root:P@ssword2» | chpasswd

Ошибка возникает из-за того, что в стандартном rootfs OpenWrt утилита chpasswd отсутствует (она является частью пакета shadow, который не установлен по умолчанию). Docker пытается выполнить команду, не находит её и прерывает сборку.

Чтобы решить эту проблему надежно и не тянуть лишние пакеты, мы будем генерировать хэш пароля с помощью openssl (который мы уже установили в предыдущем шаге) и записывать его напрямую в файл /etc/shadow через sed.

Вам нужно внести изменения в два файла.

1. Обновите Dockerfile

Найдите в вашем Dockerfile блок установки пароля:

# Устанавливаем пароль root
RUN echo "root:${ROOT_PASSWORD}" | chpasswd

Замените его на следующий код:

# Устанавливаем пароль root через openssl и sed (так как chpasswd нет в базовом rootfs)
RUN HASH=$(openssl passwd -5 "${ROOT_PASSWORD}") && \
    if grep -q '^root:' /etc/shadow; then \
        sed -i "s|^root:[^:]*:|root:${HASH}:|" /etc/shadow; \
    else \
        echo "root:${HASH}:19800:0:99999:7:::" >> /etc/shadow; \
    fi

2. Обновите entrypoint.sh

Такую же замену нужно сделать в скрипте инициализации, иначе контейнер упадет с той же ошибкой уже на этапе запуска (когда попытается переустановить пароль из переменных окружения).

Откройте файл entrypoint.sh, найдите этот блок:

if [ -n "${ROOT_PASSWORD:-}" ] && [ ! -f /etc/.docker_root_password_set ]; then
    echo "root:${ROOT_PASSWORD}" | chpasswd
    touch /etc/.docker_root_password_set
fi

Замените его на:

if [ -n "${ROOT_PASSWORD:-}" ] && [ ! -f /etc/.docker_root_password_set ]; then
    HASH=$(openssl passwd -5 "${ROOT_PASSWORD}")
    if grep -q '^root:' /etc/shadow; then
        sed -i "s|^root:[^:]*:|root:${HASH}:|" /etc/shadow
    else
        echo "root:${HASH}:19800:0:99999:7:::" >> /etc/shadow
    fi
    touch /etc/.docker_root_password_set
fi

3. Пересоберите образ

Теперь запустите сборку заново. Так как мы изменили файлы, Docker корректно подхватит изменения:

# Остановите текущий контейнер, если он пытался запуститься
docker compose down

# Пересоберите образ
docker compose build

# Запустите
docker compose up -d

После успешного старта вы сможете зайти в Web-интерфейс OpenWrt (http://192.168.0.200 или http://192.168.200.1) под логином root и вашим паролем.

От alex

Обучаемый.