Shanraq.org Shanraq.org
Свой домен и HTTPS: чтобы блог открывали по имени
IT

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 уже работает: браузер запомнит надолго.

Карта урока

Карта урока: домен, прокси и TLS

Скажите своими словами

Не подглядывая, ответьте вслух или на бумаге. Ответы — в конце урока.

  1. Что именно видит чужой Wi-Fi, когда вы входите на сайт без HTTPS?
  2. Почему за прокси блог слушает петлю, а не все адреса?
  3. Почему заголовку 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, из-за которой в журнале снова видно читателя, а не прокси.

Долги. Блог по-прежнему не умеет останавливаться аккуратно: прокси уже переключился на новую версию, а старая обрывает недочитанные ответы. Это следующий урок — про службу, которая переживает перезагрузку.

Ответы

Показать ответы
  1. Всё, что вы отправили: адрес страницы, заголовки и содержимое формы. Пароль в открытом канале виден целиком, буквами, — это первый блок вывода программы.
  2. Потому что снаружи должен быть виден только прокси: он держит сертификат и разбирает TLS. Слушай блог все адреса — к нему можно было бы подойти по http мимо прокси, и весь смысл сертификата пропал бы.
  3. Потому что его может прислать кто угодно: это обычный заголовок запроса. Верить ему можно, только когда соединение пришло от прокси, — тогда его поставил прокси, а не читатель.

Источники

Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом

Проверить задание

Сначала решите и запустите в VS Code — редактор покажет ошибку на месте. Готовое решение вставьте сюда. Проверяет модель: она укажет на ошибку, но не даст готовый ответ.

Чтобы проверить, нужно войти. Войти

Комментарии (0)

Пока нет комментариев. Будьте первым.