Go: с нуля до своего блога Урок 47 из 50
Свой домен и HTTPS: чтобы блог открывали по имени
Сорок седьмой урок курса по Go. Домен — это одна запись в DNS, которая ведёт на ваш адрес, а HTTPS — не галочка, а необходимость: измерено, что в открытом канале пароль читателя виден целиком, все 222 байта, а в TLS его в этих байтах нет. Плюс прокси спереди и заголовок, которому нельзя верить.
Зачем это нужно
Блог живёт по адресу вроде 95.217.3.14:10000. Такое не отправляют другу и не пишут на визитке — его просто забывают. Нужно имя: blog.example.kz.
И вместе с именем нужен замок в адресной строке. Не ради красоты: без него всё, что читатель отправляет в форму, летит по чужому Wi-Fi открытым текстом. Насколько открытым — сейчас увидим.
Домена может ещё не быть, и это не повод пропускать урок. Первая половина — что видно в открытом канале и что даёт TLS — измеряется на своей машине, без единой покупки. Вторая — запись в DNS, сертификат и прокси — читается как план на день, когда домен появится. Блог из курса остаётся законченным и без домена: он работает по адресу и порту.
Сразу целиком
Новая папка, go mod init sabaq44. Программа изображает чужой Wi-Fi между читателем и блогом: она пропускает байты дальше и запоминает, что прошло.
package main
import (
"bytes"
"crypto/tls"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"strings"
)
// Пароль, который читатель вводит в форму входа. Дальше мы посмотрим, что
// увидит чужой Wi-Fi между читателем и блогом.
const password = "degentaudynbasy"
func main() {
// Блог, который принимает форму входа. Один и тот же обработчик будет
// работать и без TLS, и с TLS: разница не в коде, а в канале.
login := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
fmt.Fprintf(w, "вошёл: %s", r.FormValue("email"))
})
fmt.Println("== http: между вами и сайтом сидит чужой")
plain := httptest.NewServer(login)
defer plain.Close()
seen := post(plain.URL, false)
show(seen)
fmt.Println()
fmt.Println("== https: тот же обработчик, тот же пароль")
secure := httptest.NewTLSServer(login)
defer secure.Close()
seen = post(secure.URL, true)
show(seen)
}
// post отправляет форму через подслушивающий прокси и возвращает всё, что этот
// прокси успел прочитать по дороге.
func post(target string, useTLS bool) *bytes.Buffer {
u, _ := url.Parse(target)
addr, seen := spy(u.Host)
client := &http.Client{Transport: &http.Transport{
// Сертификат самоподписанный, поэтому проверку выключаем: в уроке нас
// интересует не доверие, а то, что видно в канале.
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}}
scheme := "http"
if useTLS {
scheme = "https"
}
resp, err := client.PostForm(scheme+"://"+addr+"/login", url.Values{
"email": {"oqyrman@example.kz"},
"password": {password},
})
if err != nil {
fmt.Println("запрос не прошёл:", err)
return seen
}
resp.Body.Close()
return seen
}
// spy — чужой Wi-Fi: он просто передаёт байты дальше, но запоминает всё, что
// прошло от читателя к сайту.
func spy(target string) (string, *bytes.Buffer) {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
panic(err)
}
seen := &bytes.Buffer{}
go func() {
conn, err := l.Accept()
if err != nil {
return
}
up, err := net.Dial("tcp", target)
if err != nil {
return
}
go io.Copy(conn, up)
io.Copy(io.MultiWriter(up, seen), conn)
}()
return l.Addr().String(), seen
}
// show печатает первую строку увиденного и отвечает на единственный важный
// вопрос: виден ли в этих байтах пароль.
func show(seen *bytes.Buffer) {
raw := seen.String()
first := strings.SplitN(raw, "\r\n", 2)[0]
if len(first) > 60 {
first = first[:60]
}
if !isText(first) {
first = fmt.Sprintf("% x …", seen.Bytes()[:min(12, seen.Len())])
}
fmt.Printf("прошло байт: %d\n", seen.Len())
fmt.Printf("начало: %s\n", first)
if strings.Contains(raw, password) {
fmt.Printf("пароль в канале: виден целиком — %q\n", password)
return
}
fmt.Println("пароль в канале: не найден")
}
// isText — можно ли это показать человеку как строку.
func isText(s string) bool {
for _, r := range s {
if r < 32 && r != '\t' {
return false
}
}
return true
}
Вывод:
== http: между вами и сайтом сидит чужой
прошло байт: 222
начало: POST /login HTTP/1.1
пароль в канале: виден целиком — "degentaudynbasy"
== https: тот же обработчик, тот же пароль
прошло байт: 1761
начало: 16 03 01 05 a8 01 00 05 a4 03 03 88 …
пароль в канале: не найден
Разбор
Пароль в открытом канале — это просто слово
Первый блок вывода нечего толковать: в 222 байтах, прошедших через чужую точку доступа, пароль лежит целиком, буквами. Не «теоретически уязвим», а виден.
Во втором блоке тот же обработчик и тот же пароль, но канал другой: 1761 байт, начинающихся с 16 03 01 — это заголовок TLS-записи, — и пароля в них нет.
Разница не в коде блога. Обработчик мы даже не меняли: он один и тот же в обоих случаях. Разница в том, по какому каналу приходит запрос.
Образ. Открытка и конверт. Почтальон не злодей, но открытку он читает мимоходом, а конверт — нет.
Домен — это одна запись в DNS
Домен не «прикрепляют к сайту». Домен указывает на адрес машины одной записью типа A, и выглядит это так:
== куда указывает домен
shanraq.org. 231 IN A 85.202.192.61
== что отвечает http
HTTP/1.1 308 Permanent Redirect
Connection: close
Location: https://shanraq.org/
Server: Caddy
== чей сертификат и до какого числа
issuer=C=US, O=Let's Encrypt, CN=YE1
subject=CN=shanraq.org
notBefore=Jul 27 04:47:02 2026 GMT
notAfter=Oct 25 04:47:01 2026 GMT
== что сайт просит запомнить
HTTP/2 200
strict-transport-security: max-age=63072000; includeSubDomains
Первая строка и есть весь секрет: shanraq.org — это 85.202.192.61, и цифра 231 рядом — сколько секунд браузеру разрешено помнить ответ, не переспрашивая. Отсюда правило: меняйте адрес заранее. Понизили это число до минуты, подождали, переехали — тогда переезд занимает минуты, а не сутки.
Что нужно сделать, если домена ещё нет: купить его у регистратора (для .kz это местные регистраторы, для .com и .org — любые), в панели домена создать запись A с вашим IP, и подождать. Всё, никакой магии.
HTTPS выдаёт не хостинг, а центр сертификации
Сертификат — это подтверждение того, что домен ваш, подписанное тем, кому браузеры верят. Раньше его покупали; сейчас Let’s Encrypt выдаёт бесплатно и автоматически, и наш собственный сайт живёт именно так — вот его сертификат в третьем блоке измерений.
Три вещи в этом блоке стоит прочитать. issuer — кто выдал, subject — кому, а даты — что сертификат живёт три месяца. Это не жадность: короткий срок заставляет обновление быть автоматическим, а автоматическое обновление не забывают.
Прокси спереди: одна строка вместо возни с ключами
Работать с сертификатами руками не надо. Перед блогом ставят обратный прокси — программу, которая принимает соединение из интернета, держит сертификат и передаёт запрос дальше на ваш порт. Самый простой из них — Caddy, и вся его настройка в шаге 32 такая:
blog.example.kz {
reverse_proxy 127.0.0.1:8080
encode zstd gzip
}
Про сертификат здесь нет ни слова — Caddy сам сходит в Let’s Encrypt при первом запуске и сам будет обновлять. Проверка этой настройки настоящим Caddy печатает, кроме Valid configuration, ещё одну строку: enabling automatic HTTP->HTTPS redirects. Он и перенаправление с http включил сам — то самое 308, которое видно во втором блоке измерений.
И обратите внимание, куда прокси передаёт запрос: 127.0.0.1:8080. С этого момента блог слушает только петлю, и из интернета к нему не подойти в обход прокси. Правило из прошлого урока не отменяется, а уточняется: на хостинге слушают все адреса, за своим прокси — только петлю.
Заголовок, который врёт
У прокси есть последствие внутри программы. Соединение к блогу теперь всегда приходит с 127.0.0.1, и в журнале у всех читателей один и тот же адрес — адрес прокси.
Настоящий адрес прокси кладёт в заголовок X-Forwarded-For. Беда в том, что этот заголовок может прислать кто угодно. Поэтому верить ему можно только тогда, когда соединение пришло от прокси:
ip := net.ParseIP(host)
if ip == nil || !ip.IsLoopback() {
return host
}
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
// ...
}
Измерено на шаге 32:
запрос с петли, без заголовка: в журнале 127.0.0.1
запрос с петли, X-Forwarded-For: 1.2.3.4: в журнале 1.2.3.4
запрос из сети, X-Forwarded-For: 1.2.3.4: в журнале 192.168.1.200
Третья строка — весь смысл проверки: запрос из сети сам назвался чужим адресом, и блог ему не поверил. Без этой проверки любой читатель писал бы в ваш журнал что захочет — а журнал вы потом читаете, когда разбираетесь, кто перебирал пароли.
HSTS: попросить браузер больше не ходить по http
Перенаправление с http спасает не всегда: первый запрос всё равно ушёл открытым. Поэтому сайт просит браузер запомнить, что сюда ходят только по https:
strict-transport-security: max-age=63072000; includeSubDomains
Два года в секундах. После первого визита браузер даже не попробует http — он сам заменит его на https, ещё до отправки запроса. Ставится это одной строкой в прокси, но включать её стоит, когда HTTPS уже работает: браузер запомнит надолго.
Карта урока
Скажите своими словами
Не подглядывая, ответьте вслух или на бумаге. Ответы — в конце урока.
- Что именно видит чужой Wi-Fi, когда вы входите на сайт без HTTPS?
- Почему за прокси блог слушает петлю, а не все адреса?
- Почему заголовку
X-Forwarded-Forнельзя верить всегда?
Задание
Обязательное. Подключите к своему блогу домен и HTTPS: запись A ведёт на ваш адрес, перед блогом стоит прокси с сертификатом, блог слушает 127.0.0.1, а в журнале виден адрес читателя, а не прокси. Проверьте curl -I http://ваш-домен — там должно быть перенаправление, а не страница.
Всё это сделано в step-32 — сверьтесь после того, как сделаете сами.
По желанию.
- Запустите программу из урока на своей машине и посмотрите на свой пароль в первом блоке. Это неприятное зрелище стоит увидеть один раз.
- Посмотрите сертификат любого сайта:
echo | openssl s_client -connect example.com:443 | openssl x509 -noout -issuer -dates. - Включите HSTS и найдите в браузере страницу, где эта память хранится (в Chrome —
chrome://net-internals/#hsts).
Куда это встанет в блоге
В шаге 32 появился Caddyfile — настоящий, он проверен настоящим Caddy, — и функция clientIP, из-за которой в журнале снова видно читателя, а не прокси.
Долги. Блог по-прежнему не умеет останавливаться аккуратно: прокси уже переключился на новую версию, а старая обрывает недочитанные ответы. Это следующий урок — про службу, которая переживает перезагрузку.
Ответы
Показать ответы
- Всё, что вы отправили: адрес страницы, заголовки и содержимое формы. Пароль в открытом канале виден целиком, буквами, — это первый блок вывода программы.
- Потому что снаружи должен быть виден только прокси: он держит сертификат и разбирает TLS. Слушай блог все адреса — к нему можно было бы подойти по
httpмимо прокси, и весь смысл сертификата пропал бы. - Потому что его может прислать кто угодно: это обычный заголовок запроса. Верить ему можно, только когда соединение пришло от прокси, — тогда его поставил прокси, а не читатель.
Источники
Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом
Комментарии (0)
Войдите, чтобы оставить комментарий →
Пока нет комментариев. Будьте первым.