Compare commits

...

6 Commits

Author SHA1 Message Date
Mikan
71ffcc89bf fix 2026-02-13 21:58:35 +03:00
Mikan
60204db3d2 new arch 2026-02-13 01:47:50 +03:00
Mikan
3831545d38 [AI] 2025-11-22 15:56:02 +03:00
Mikan
3d69f8596b update paths 2025-11-22 15:41:27 +03:00
Mikan
9e65be9024 fix 2025-11-22 15:34:52 +03:00
Mikan
a274d809f0 fix 2025-11-21 15:54:11 +03:00
16 changed files with 405 additions and 276 deletions

7
.env.example Normal file
View File

@@ -0,0 +1,7 @@
LOCAL_DOMAIN=mikan
PUBLIC_DOMAIN=
LETSENCRYPT_EMAIL=admin@${LOCAL_DOMAIN}
NGINX_IP=10.0.0.3
DNSMASQ_IP=10.0.0.2
SUBNET=10.0.0.0/24
GATEWAY=10.0.0.1

17
.gitignore vendored
View File

@@ -1 +1,16 @@
certs/
# Environment
.env
domains.txt
# Certificates and secrets
/nginx/certs/
/ca/
/acme/
# Service data
/services/*/data/
# IDE
.vscode/
*.swp
*~

117
README.md
View File

@@ -1,2 +1,117 @@
# local-dns
# Локальная сеть с доменами `.mikan` и HTTPS
Система для запуска сервисов в Docker с автоматическим HTTPS, локальными доменами (`service.mikan`, `service.local`) и возможностью расширения (Tailscale, публичные домены).
## Требования
- Docker + Docker Compose
- `mkcert` — [инструкция по установке](https://github.com/FiloSottile/mkcert#installation)
- `bash`, `coreutils`
## Быстрый старт
1. **Клонируй репозиторий**
```bash
git clone ... && cd local-dns
```
2. **Настрой домен (опционально)**
```bash
echo "LOCAL_DOMAIN=mikan" > .env
```
Замени `mikan` на желаемый суффикс (например, `home`).
3. **Инициализируй CA и базовый сертификат**
```bash
./scripts/01-init-ca.sh
```
- Создаст доверенный корневой сертификат (`ca/rootCA.pem`)
- Сгенерирует `default.crt` для nginx-proxy
4. **Установи корневой сертификат в систему**
Импортируй файл `ca/rootCA.pem` в хранилище **доверенных корневых центров сертификации**:
- **Linux**:
```bash
sudo cp ca/rootCA.pem /usr/local/share/ca-certificates/mkcert-local.crt
sudo update-ca-certificates
```
- **Windows/macOS**: двойной клик по файлу → «Доверять всегда» (macOS) или импорт в «Доверенные корневые центры» (Windows)
5. **Запусти инфраструктуру**
```bash
docker compose up -d
```
6. **Добавь сервис**
```bash
./scripts/add-service.sh gitea
```
Сервис будет доступен по:
- `https://gitea.mikan`
- `https://gitea.local`
\+ может пригодиться:
```bash
Add-Content -Path "C:\Windows\System32\drivers\etc\hosts" -Value "10.0.0.3 youtrack.mikan"
```
7. **Настрой сервис в Docker Compose**
Убедись, что в `docker-compose.yml` сервиса указано:
```yaml
services:
gitea:
environment:
- VIRTUAL_HOST=gitea.mikan,gitea.local
- VIRTUAL_PORT=3000 # порт внутри контейнера
networks:
- devnet
dns: 10.0.0.2 # обязательно для резолвинга доменов из контейнеров
```
## Важные замечания
### DNS из контейнеров
Контейнеры **не могут** резолвить `*.mikan` без явного указания DNS:
```yaml
dns: 10.0.0.2 # IP dnsmasq
```
Добавляй эту строку во все сервисы, которым нужен доступ к другим по домену.
### Обновление сертификатов
При добавлении сервиса через `add-service.sh`:
- Автоматически обновляется `domains.txt`
- Генерируется новый `default.crt`
- Перезагружается `nginx-proxy`
### Пути
- Сертификаты: `nginx/certs/default.crt`
- Домены: `domains.txt`
- Корневой CA: `ca/rootCA.pem`
## Расширение
### Tailscale
Добавь в `docker-compose.yml` сервис `tailscale` и объявляй дополнительные домены:
```yaml
environment:
- VIRTUAL_HOST=gitea.mikan,gitea.local,gitea.yourname.ts.net
```
### Публичный домен
1. Купи домен (например, `example.com`)
2. В `.env` добавь:
```env
PUBLIC_DOMAIN=example.com
```
3. В сервисах укажи:
```yaml
environment:
- VIRTUAL_HOST=gitea.mikan,gitea.local,gitea.example.com
- LETSENCRYPT_HOST=gitea.example.com
```
Сертификат для `example.com` получит `acme-companion`.
---
Готово. Теперь все сервисы работают по HTTPS с валидными сертификатами.

2
dnsmasq/dnsmasq.conf Normal file
View File

@@ -0,0 +1,2 @@
# Этот файл будет перезаписан при запуске ./scripts/02-apply-config.sh
# Не редактируйте вручную

View File

@@ -1,55 +1,87 @@
services:
dnsmasq:
image: jpillora/dnsmasq
container_name: dnsmasq
restart: always
ports:
- "53:53/udp"
- "53:53/tcp"
volumes:
- ./dnsmasq.conf:/etc/dnsmasq.conf:ro
cap_add:
- NET_ADMIN
networks:
- devnet
version: '3.8'
services:
nginx-proxy:
image: nginxproxy/nginx-proxy:alpine
container_name: nginx-proxy
restart: always
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/tmp/docker.sock:ro
- ./certs:/etc/nginx/certs:ro
- ./vhost.d:/etc/nginx/vhost.d:ro
- ./html:/usr/share/nginx/html:rw
- ./nginx/certs:/etc/nginx/certs:ro
- ./nginx/vhost.d:/etc/nginx/vhost.d:ro
- ./nginx/html:/usr/share/nginx/html:rw
networks:
- devnet
devnet:
ipv4_address: ${NGINX_IP}
environment:
- DEFAULT_HOST=welcome.${LOCAL_DOMAIN}
- HTTPS_METHOD=redirect
- SSL_POLICY=Mozilla-Modern
- DEFAULT_SSL_CERT=/etc/nginx/certs/default.crt
- DEFAULT_SSL_KEY=/etc/nginx/certs/default.key
labels:
- "com.github.nginx-proxy.nginx"
- "com.github.nginx-proxy.nginx-proxy=true"
nginx-proxy-companion:
image: nginxproxy/acme-companion
container_name: nginx-proxy-companion
restart: always
restart: unless-stopped
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./certs:/etc/nginx/certs:rw
- ./nginx/certs:/etc/nginx/certs:rw
- ./acme:/etc/acme.sh
- ./vhost.d:/etc/nginx/vhost.d
- ./html:/usr/share/nginx/html
- ./nginx/vhost.d:/etc/nginx/vhost.d:rw
networks:
- devnet
environment:
- DEFAULT_EMAIL=${LETSENCRYPT_EMAIL}
- NGINX_PROXY_CONTAINER=nginx-proxy
- ACME_CA_URI=https://acme-staging-v02.api.letsencrypt.org/directory
depends_on:
- nginx-proxy
welcome:
image: nginx:alpine
container_name: welcome
restart: unless-stopped
volumes:
- ./nginx/html:/usr/share/nginx/html:ro
environment:
- VIRTUAL_HOST=welcome.${LOCAL_DOMAIN},welcome.${LOCAL_DOMAIN}.local
networks:
- devnet
depends_on:
- nginx-proxy
dnsmasq:
image: jpillora/dnsmasq
container_name: dnsmasq
restart: unless-stopped
ports:
- "53:53/udp"
- "53:53/tcp"
volumes:
- ./dnsmasq/dnsmasq.conf:/etc/dnsmasq.conf:ro
cap_add:
- NET_ADMIN
networks:
devnet:
ipv4_address: ${DNSMASQ_IP}
healthcheck:
test: ["CMD", "nslookup", "localhost", "127.0.0.1"]
interval: 30s
timeout: 5s
retries: 3
networks:
devnet:
external: true
name: devnet
volumes:
acme:
vhost.d:
html:
html:

7
nginx/html/index.html Normal file
View File

@@ -0,0 +1,7 @@
<!DOCTYPE html>
<html>
<head><title>Placeholder</title></head>
<body>
<p>Run ./scripts/00-init.sh to generate content</p>
</body>
</html>

40
scripts/00-init.sh Normal file
View File

@@ -0,0 +1,40 @@
#!/bin/bash
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$SCRIPT_DIR/.."
if [ ! -f "$PROJECT_ROOT/.env" ]; then
echo "Файл .env не найден. Копирую из .env.example..."
cp "$PROJECT_ROOT/.env.example" "$PROJECT_ROOT/.env"
fi
export $(grep -v '^#' "$PROJECT_ROOT/.env" | xargs)
# Создаём каталоги
mkdir -p \
"$PROJECT_ROOT/dnsmasq" \
"$PROJECT_ROOT/nginx/{certs,vhost.d,html,conf.d}" \
"$PROJECT_ROOT/acme" \
"$PROJECT_ROOT/ca" \
"$PROJECT_ROOT/services"
# Создаём общую Docker-сеть с фиксированными настройками
echo "Проверка сети devnet..."
if ! docker network inspect devnet &>/dev/null; then
echo "Создаём сеть devnet..."
docker network create \
--subnet="${SUBNET}" \
--gateway="${GATEWAY}" \
devnet
else
echo "Сеть devnet уже существует."
fi
"$SCRIPT_DIR/02-apply-config.sh"
echo "Проект инициализирован."
echo "Домен: .$LOCAL_DOMAIN"
if [ -n "$PUBLIC_DOMAIN" ]; then
echo "Публичный домен: $PUBLIC_DOMAIN"
fi

38
scripts/01-init-ca.sh Normal file
View File

@@ -0,0 +1,38 @@
#!/bin/bash
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$SCRIPT_DIR/.."
if [ -f "$ROOT_DIR/.env" ]; then
export $(grep -v '^#' "$ROOT_DIR/.env" | xargs)
fi
LOCAL_DOMAIN=${LOCAL_DOMAIN:-mikan}
CA_DIR="$ROOT_DIR/ca"
CERTS_DIR="$ROOT_DIR/nginx/certs"
DOMAINS_FILE="$ROOT_DIR/domains.txt"
mkdir -p "$CA_DIR" "$CERTS_DIR"
if [ ! -f "$DOMAINS_FILE" ]; then
touch "$DOMAINS_FILE"
fi
if [ ! -f "$CA_DIR/rootCA.pem" ]; then
mkcert -install
cp "$(mkcert -CAROOT)/rootCA.pem" "$CA_DIR/rootCA.pem"
cp "$(mkcert -CAROOT)/rootCA-key.pem" "$CA_DIR/rootCA-key.pem"
cat > "$DOMAINS_FILE" <<EOF
$LOCAL_DOMAIN
welcome.$LOCAL_DOMAIN
localhost
127.0.0.1
::1
*.services.$LOCAL_DOMAIN
*.services.local
EOF
fi
exec "$SCRIPT_DIR/regenerate-cert.sh"

View File

@@ -0,0 +1,38 @@
#!/bin/bash
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$SCRIPT_DIR/.."
if [ ! -f "$PROJECT_ROOT/.env" ]; then
echo "Файл .env не найден. Запустите сначала ./scripts/00-init.sh"
exit 1
fi
export $(grep -v '^#' "$PROJECT_ROOT/.env" | xargs)
cat > "$PROJECT_ROOT/dnsmasq/dnsmasq.conf" <<EOF
address=/.$LOCAL_DOMAIN/$NGINX_IP
address=/.$LOCAL_DOMAIN.local/$NGINX_IP
local=/.$LOCAL_DOMAIN/
local=/.$LOCAL_DOMAIN.local/
no-resolv
server=1.1.1.1
server=8.8.8.8
log-queries
log-facility=/dev/stdout
EOF
mkdir -p "$PROJECT_ROOT/nginx/html"
cat > "$PROJECT_ROOT/nginx/html/index.html" <<EOF
<!DOCTYPE html>
<html>
<head><title>Добро пожаловать!</title></head>
<body>
<h1>Сервисы .$LOCAL_DOMAIN</h1>
<p>Добавьте свои сервисы в папку <code>services/</code></p>
</body>
</html>
EOF
echo "Конфигурация применена для .$LOCAL_DOMAIN"

41
scripts/add-service.sh Normal file
View File

@@ -0,0 +1,41 @@
#!/bin/bash
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$SCRIPT_DIR/.."
if [ -f "$ROOT_DIR/.env" ]; then
export $(grep -v '^#' "$ROOT_DIR/.env" | xargs)
fi
LOCAL_DOMAIN=${LOCAL_DOMAIN:-mikan}
if [ $# -lt 1 ]; then
echo "Usage: $0 <service_name> [extra_domains...]"
exit 1
fi
SERVICE_NAME=$1
shift
EXTRA_DOMAINS=("$@")
DOMAINS_FILE="$ROOT_DIR/domains.txt"
if [ ! -f "$DOMAINS_FILE" ]; then
touch "$DOMAINS_FILE"
fi
PRIMARY="${SERVICE_NAME}.${LOCAL_DOMAIN}"
ALIAS="${SERVICE_NAME}.local"
{
echo "$PRIMARY"
echo "$ALIAS"
for domain in "${EXTRA_DOMAINS[@]}"; do
echo "$domain"
done
} >> "$DOMAINS_FILE"
sort -u "$DOMAINS_FILE" | grep -v '^$' > "$DOMAINS_FILE.tmp" && mv "$DOMAINS_FILE.tmp" "$DOMAINS_FILE"
exec "$SCRIPT_DIR/regenerate-cert.sh"

View File

@@ -1,113 +0,0 @@
# cert_renew_restart.py
def generate_mkcert_command():
"""Генерирует команду для mkcert с указанием всех доменов"""
services = read_services_config()
domains = [f"{service['name']}.local" for service in services['services']]
domains.append("*.local")
# Создаем PowerShell команду
domains_str = " ".join([f'"{domain}"' for domain in domains])
powershell_cmd = f'mkcert {domains_str}'
return powershell_cmd
def copy_and_rename_certificates():
"""Копирует и переименовывает сертификаты из Windows в WSL"""
# Определяем пути
windows_certs_path = Path("C:/store/certs")
wsl_certs_path = Path.home() / "projects" / "docker" / "network" / "certs"
# Создаем директорию для сертификатов в WSL
wsl_certs_path.mkdir(parents=True, exist_ok=True)
# Находим сгенерированные mkcert файлы
cert_files = list(windows_certs_path.glob("_wildcard.local+*.pem"))
if not cert_files:
print("Сертификаты не найдены в C:/store/certs")
return False
# Предполагаем, что mkcert генерирует файлы в формате _wildcard.domain+N.pem
cert_file = cert_files[0]
key_file = cert_files[0].with_name(cert_file.name.replace(".pem", "-key.pem"))
if not key_file.exists():
print(f"Файл ключа не найден: {key_file}")
return False
# Копируем и переименовываем
target_cert = wsl_certs_path / "wildcard.local.pem"
target_key = wsl_certs_path / "wildcard.local.key"
shutil.copy2(cert_file, target_cert)
shutil.copy2(key_file, target_key)
# Создаем копии для default
default_cert = wsl_certs_path / "default.crt"
default_key = wsl_certs_path / "default.key"
shutil.copy2(target_cert, default_cert)
shutil.copy2(target_key, default_key)
print(f"Сертификаты скопированы в {wsl_certs_path}")
return True
def run_docker_command(cmd):
"""Выполняет команду Docker и возвращает результат"""
try:
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
if result.returncode != 0:
print(f"Ошибка выполнения команды: {cmd}")
print(f"Ошибка: {result.stderr}")
return False
return True
except Exception as e:
print(f"Ошибка выполнения команды: {e}")
return False
def main():
print("Обновление сертификатов и перезапуск сервисов...")
# Получаем имя директории проекта (предполагаем, что это "network")
project_name = "network"
# Генерируем конфигурационный файл dnsmasq
generate_dnsmasq_config()
# Выводим команду для генерации сертификатов
mkcert_cmd = generate_mkcert_command()
print("\nДля генерации сертификатов выполните в PowerShell (от администратора):")
print(f"cd C:\\store\\certs")
print(mkcert_cmd)
print()
# Ждем подтверждения пользователя
input("Нажмите Enter после генерации сертификатов в PowerShell...")
# Копируем сертификаты
if not copy_and_rename_certificates():
print("Не удалось скопировать сертификаты")
sys.exit(1)
# Переходим в директорию проекта
project_path = Path.home() / "projects" / "docker" / project_name
if not project_path.exists():
print(f"Директория проекта не найдена: {project_path}")
sys.exit(1)
os.chdir(project_path)
# Перезапускаем nginx-proxy для загрузки новых сертификатов
print("Перезапуск nginx-proxy...")
if not run_docker_command("docker-compose restart nginx-proxy"):
print("Не удалось перезапустить nginx-proxy")
# Если restart не сработал, пробуем поднять контейнеры
if not run_docker_command("docker-compose up -d"):
print("Не удалось поднять контейнеры")
sys.exit(1)
print("Сертификаты обновлены и сервисы перезапущены!")
if __name__ == "__main__":
main()

View File

@@ -1,112 +0,0 @@
# common.py
import subprocess
import sys
import os
import platform
import json
from pathlib import Path
def get_host_ip():
"""Получает IP-адрес хоста в локальной сети"""
try:
# Для WSL2 получаем IP хоста (Windows)
if 'microsoft' in platform.uname().release.lower():
# В WSL2 получаем IP хоста из файла resolv.conf
with open('/etc/resolv.conf', 'r') as f:
for line in f:
if line.startswith('nameserver'):
return line.split()[1]
# Для других систем используем подключение к внешнему серверу
else:
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
ip = s.getsockname()[0]
s.close()
return ip
except Exception as e:
print(f"Не удалось получить IP-адрес хоста: {e}")
# Возвращаем IP по умолчанию, если не удалось получить
return "192.168.31.60"
def read_services_config():
"""Читает конфигурацию сервисов из файла"""
config_path = Path.home() / "projects" / "docker" / "network" / "services.json"
default_config = {
"services": [
{"name": "gitea", "port": 3000},
{"name": "youtrack", "port": 8080},
{"name": "n8n", "port": 5678}
]
}
if config_path.exists():
with open(config_path, 'r', encoding='utf-8') as f:
return json.load(f)
else:
# Создаем файл конфигурации по умолчанию
config_path.parent.mkdir(parents=True, exist_ok=True)
with open(config_path, 'w', encoding='utf-8') as f:
json.dump(default_config, f, indent=2, ensure_ascii=False)
return default_config
def generate_dnsmasq_config():
"""Генерирует конфигурационный файл dnsmasq"""
host_ip = get_host_ip()
services = read_services_config()
config_content = f"""# Локальные домены → твой ПК
"""
for service in services['services']:
config_content += f"address=/{service['name']}.local/{host_ip}\n"
config_content += f"""address=/.local/{host_ip}
# Кэш DNS
cache-size=500
# Лог (опционально)
log-queries
log-facility=/var/log/dnsmasq.log
# Не перехватывать внешние запросы — делегируем
no-resolv
server=8.8.8.8
server=1.1.1.1
"""
# Путь к конфигурационному файлу
config_path = Path.home() / "projects" / "docker" / "network" / "dnsmasq.conf"
config_path.parent.mkdir(parents=True, exist_ok=True)
with open(config_path, 'w', encoding='utf-8') as f:
f.write(config_content.strip())
print(f"Конфигурационный файл dnsmasq обновлен: {config_path}")
def run_docker_command(cmd):
"""Выполняет команду Docker и возвращает результат"""
try:
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
if result.returncode != 0:
print(f"Ошибка выполнения команды: {cmd}")
print(f"Ошибка: {result.stderr}")
return False
return True
except Exception as e:
print(f"Ошибка выполнения команды: {e}")
return False
def setup_project():
"""Настройка проекта: генерация конфигов и переход в директорию"""
# Генерируем конфигурационный файл dnsmasq
generate_dnsmasq_config()
# Переходим в директорию проекта
project_path = Path.home() / "projects" / "docker" / "network"
if not project_path.exists():
print(f"Директория проекта не найдена: {project_path}")
sys.exit(1)
os.chdir(project_path)
return project_path

View File

@@ -0,0 +1,32 @@
#!/bin/bash
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$SCRIPT_DIR/.."
if [ -f "$ROOT_DIR/.env" ]; then
export $(grep -v '^#' "$ROOT_DIR/.env" | xargs)
fi
DOMAINS_FILE="$ROOT_DIR/domains.txt"
CERTS_DIR="$ROOT_DIR/nginx/certs"
if [ ! -f "$DOMAINS_FILE" ]; then
echo "Error: $DOMAINS_FILE not found" >&2
exit 1
fi
mapfile -t DOMAINS < <(grep -v '^$' "$DOMAINS_FILE")
if [ ${#DOMAINS[@]} -eq 0 ]; then
echo "Error: no domains found in $DOMAINS_FILE" >&2
exit 1
fi
mkcert \
-cert-file "$CERTS_DIR/default.crt.new" \
-key-file "$CERTS_DIR/default.key.new" \
"${DOMAINS[@]}"
mv "$CERTS_DIR/default.crt.new" "$CERTS_DIR/default.crt"
mv "$CERTS_DIR/default.key.new" "$CERTS_DIR/default.key"

11
scripts/reload-nginx.sh Normal file
View File

@@ -0,0 +1,11 @@
#!/bin/bash
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$SCRIPT_DIR/.."
CERTS_DIR="$ROOT_DIR/nginx/certs"
cp "$CERTS_DIR/default.crt" "$CERTS_DIR/default.key" "$CERTS_DIR/"
docker compose -f "$ROOT_DIR/docker-compose.yml" exec nginx-proxy nginx -s reload 2>/dev/null || \
docker compose -f "$ROOT_DIR/docker-compose.yml" restart nginx-proxy

View File

@@ -1,22 +0,0 @@
# restart_services.py
from common import *
def main():
print("Перезапуск сервисов...")
# Настройка проекта
setup_project()
# Перезапускаем контейнеры
print("Перезапуск контейнеров...")
if not run_docker_command("docker-compose restart"):
print("Не удалось перезапустить контейнеры")
# Если restart не сработал, пробуем поднять контейнеры
if not run_docker_command("docker-compose up -d"):
print("Не удалось поднять контейнеры")
sys.exit(1)
print("Сервисы перезапущены!")
if __name__ == "__main__":
main()

View File

@@ -1,2 +0,0 @@
ssl_certificate /etc/nginx/certs/wildcard.local.pem;
ssl_certificate_key /etc/nginx/certs/wildcard.local.key;