├── .vscode
└── settings.json
├── example
├── akebi-keyless-server.socket
├── akebi-keyless-server.service
├── akebi-https-server.json
└── akebi-keyless-server.json
├── .editorconfig
├── .gitignore
├── go.mod
├── keyless-server
├── caa_test.go
├── cron.go
├── caa.go
├── replica.go
├── config.go
├── main.go
├── http.go
├── setup.go
├── letsencrypt.go
└── dns.go
├── License.txt
├── Makefile
├── replace_net_http.sh
├── replace_net_http.ps1
├── go.sum
├── https-server
├── main.go
├── config.go
└── keyless.go
└── Readme.md
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "files.associations": {
3 | "*.json": "jsonc"
4 | }
5 | }
--------------------------------------------------------------------------------
/example/akebi-keyless-server.socket:
--------------------------------------------------------------------------------
1 | [Unit]
2 | Description = Akebi Keyless Server Socket
3 |
4 | [Socket]
5 | ListenStream = 443
6 | ListenDatagram = 53
7 |
8 | [Install]
9 | WantedBy = sockets.target
10 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | charset = utf-8
5 | end_of_line = lf
6 | insert_final_newline = true
7 | indent_size = 4
8 | indent_style = space
9 | trim_trailing_whitespace = true
10 |
11 | [*.md]
12 | trim_trailing_whitespace = false
13 |
14 | [*.json]
15 | insert_final_newline = false
16 |
17 | [Makefile]
18 | indent_style = tab
19 |
--------------------------------------------------------------------------------
/example/akebi-keyless-server.service:
--------------------------------------------------------------------------------
1 | [Unit]
2 | Description = Akebi Keyless Server Service
3 | Requires = network.target
4 | Requires = akebi-keyless-server.socket
5 |
6 | [Service]
7 | Type = notify
8 | Restart = on-failure
9 | ExecStart = /home/ubuntu/Akebi/akebi-keyless-server
10 | WorkingDirectory = /home/ubuntu/Akebi
11 | User = root
12 | NonBlocking = true
13 |
14 | [Install]
15 | WantedBy=multi-user.target
16 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Files with no extension
2 | *
3 | !*/
4 | !*.*
5 | !Makefile
6 |
7 | # Binaries for programs and plugins
8 | *.exe
9 | *.exe~
10 | *.dll
11 | *.so
12 | *.dylib
13 |
14 | # Test binary, built with `go test -c`
15 | *.test
16 |
17 | # Output of the go coverage tool, specifically when used with LiteIDE
18 | *.out
19 |
20 | # Dependency directories (remove the comment below to include it)
21 | # vendor/
22 |
23 | # Credentials, configuration
24 | *.pem
25 | *.json
26 | !example/*.json
27 | !.vscode/settings.json
28 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/tsukumijima/Akebi
2 |
3 | go 1.24.0
4 |
5 | require (
6 | github.com/coreos/go-systemd/v22 v22.5.0
7 | github.com/fatih/color v1.18.0
8 | github.com/mholt/acmez v1.2.0
9 | golang.org/x/net v0.47.0
10 | muzzammil.xyz/jsonc v1.0.0
11 | )
12 |
13 | require (
14 | github.com/mattn/go-colorable v0.1.14 // indirect
15 | github.com/mattn/go-isatty v0.0.20 // indirect
16 | go.uber.org/multierr v1.11.0 // indirect
17 | go.uber.org/zap v1.27.0 // indirect
18 | golang.org/x/crypto v0.45.0 // indirect
19 | golang.org/x/sys v0.38.0 // indirect
20 | golang.org/x/text v0.31.0 // indirect
21 | )
22 |
--------------------------------------------------------------------------------
/keyless-server/caa_test.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "testing"
5 | )
6 |
7 | func TestConvertTXTtoCAA(t *testing.T) {
8 | tests := []struct {
9 | name string
10 | in string
11 | out string
12 | }{
13 | {
14 | "empty",
15 | "\x00\x10\x00\x01\x00\x00\x00\x00\x00\x0c\x0b0 issue \";\"",
16 | "\x01\x01\x00\x01\x00\x00\x00\x00\x00\x08\x00\x05issue;",
17 | },
18 | {
19 | "empty",
20 | "\x00\x10\x00\x01\x00\x00\x00\x00\x00\x1e\x1d0 issuewild \"letsencrypt.org\"",
21 | "\x01\x01\x00\x01\x00\x00\x00\x00\x00\x1a\x00\x09issuewildletsencrypt.org",
22 | },
23 | }
24 | for _, tt := range tests {
25 | t.Run(tt.name, func(t *testing.T) {
26 | got := string(convertTXTtoCAA([]byte(tt.in)))
27 | if got != tt.out {
28 | t.Errorf("got %q, wanted %q", got, tt.out)
29 | }
30 | })
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/example/akebi-https-server.json:
--------------------------------------------------------------------------------
1 | {
2 | // Address that HTTPS server listens on.
3 | // Specify 0.0.0.0:port to listen on all interfaces.
4 | "listen_address": "0.0.0.0:3000",
5 |
6 | // URL of HTTP server to reverse proxy.
7 | "proxy_pass_url": "http://localhost:3001/",
8 |
9 | // URL of Keyless API Server.
10 | "keyless_server_url": "https://akebi.example.com/",
11 |
12 | "mtls": {
13 | // Optional: Client certificate and private key of mTLS for akebi.example.com (Keyless API).
14 | "client_certificate": "",
15 | "client_certificate_key": ""
16 | },
17 |
18 | "custom_certificate": {
19 | // Optional: Use your own HTTPS certificate and private key instead of Akebi Keyless Server.
20 | // In this case, the value of keyless_server_url is never actually used.
21 | // This is just an HTTPS reverse proxy, but it has the advantage of being able to enable HTTP/2 and making it HTTPS with the same software.
22 | "certificate": "",
23 | "private_key": ""
24 | }
25 | }
--------------------------------------------------------------------------------
/License.txt:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Nuno Cruces
4 | Copyright (c) 2022-2023 tsukumi
5 |
6 | Permission is hereby granted, free of charge, to any person obtaining a copy
7 | of this software and associated documentation files (the "Software"), to deal
8 | in the Software without restriction, including without limitation the rights
9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 | copies of the Software, and to permit persons to whom the Software is
11 | furnished to do so, subject to the following conditions:
12 |
13 | The above copyright notice and this permission notice shall be included in all
14 | copies or substantial portions of the Software.
15 |
16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 | SOFTWARE.
23 |
--------------------------------------------------------------------------------
/example/akebi-keyless-server.json:
--------------------------------------------------------------------------------
1 | {
2 | // Domain for DNS Server.
3 | // For example, 192-168-1-11.local.example.com resolves to 192.168.1.11.
4 | "domain": "local.example.com",
5 |
6 | // Domain of Nameserver for local.example.com.
7 | // Specify value set in NS record for local.example.com.
8 | "nameserver": "akebi.example.com",
9 |
10 | // Optional: CNAME record for local.example.com.
11 | "cname": "",
12 |
13 | // HTTPS Certificate and private key for *.local.example.com.
14 | "certificate": "certificates/cert.pem",
15 | "master_key": "certificates/private_key.pem",
16 |
17 | // Limit DNS resolves to private IP ranges only.
18 | // This includes IP range (100.64.0.0/10, fd7a:115c:a1e0:ab12::/64) used by Tailscale.
19 | "is_private_ip_ranges_only": true,
20 |
21 | "keyless_api": {
22 | // URL of Keyless API (URL schema excluded).
23 | "handler": "akebi.example.com/",
24 |
25 | // HTTPS Certificate and private key for akebi.example.com (Keyless API).
26 | "certificate": "certificates/keyless_api/cert.pem",
27 | "private_key": "certificates/keyless_api/private_key.pem",
28 |
29 | // Optional: Client CA certificate of mTLS for akebi.example.com (Keyless API).
30 | "client_ca": ""
31 | },
32 |
33 | "letsencrypt": {
34 | // Let's Encrypt account settings and private key.
35 | "account": "letsencrypt/account.json",
36 | "account_key": "letsencrypt/account.pem"
37 | }
38 | }
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | MAKEFLAGS += --no-print-directory
2 |
3 | # build executables for current platform
4 | # On Linux, depending on where Golang is installed, root privileges may be required to replace net/http
5 | build-https-server:
6 | @echo "HTTPS Server: Building..."
7 | ifeq ($(OS),Windows_NT)
8 | @powershell -ExecutionPolicy RemoteSigned -File .\replace_net_http.ps1
9 | @go build -ldflags="-s -w" -a -v -o "akebi-https-server.exe" "./https-server/"
10 | else
11 | @bash ./replace_net_http.sh
12 | @go build -ldflags="-s -w" -a -v -o "akebi-https-server" "./https-server/"
13 | endif
14 |
15 | # build executables for all platforms
16 | # On Linux, depending on where Golang is installed, root privileges may be required to replace net/http
17 | build-https-server-all-platforms:
18 | ifeq ($(OS),Windows_NT)
19 | @powershell -ExecutionPolicy RemoteSigned -File .\replace_net_http.ps1
20 | else
21 | @bash ./replace_net_http.sh
22 | endif
23 | @echo "HTTPS Server: Building Windows Build..."
24 | @GOARCH=amd64 GOOS="windows" go build -ldflags="-s -w" -a -v -o "akebi-https-server.exe" "./https-server/"
25 | @echo "HTTPS Server: Building Linux (x64) Build..."
26 | @GOARCH=amd64 GOOS="linux" go build -ldflags="-s -w" -a -v -o "akebi-https-server" "./https-server/"
27 | @echo "HTTPS Server: Building Linux (arm64) Build..."
28 | @GOARCH=arm64 GOOS="linux" go build -ldflags="-s -w" -a -v -o "akebi-https-server-arm" "./https-server/"
29 |
30 | # currently, linux and systemd combination only
31 | build-keyless-server:
32 | @echo "Keyless Server: Building..."
33 | @go build -ldflags="-s -w" -a -v -o "akebi-keyless-server" "./keyless-server/"
34 |
--------------------------------------------------------------------------------
/replace_net_http.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # HTTPS サーバーに HTTP でアクセスした際に出力される HTML は、残念ながら Golang 標準ライブラリの net/http/server.go にハードコードされている
4 | # このためにわざわざフォークを作るのも面倒なので、sed でソース自体を強引に置換するためのスクリプト (with GPT-4)
5 |
6 | # net/http/server.go のフルパス
7 | go_path=$(go env GOROOT)
8 | server_go_file="${go_path}/src/net/http/server.go"
9 |
10 | # ファイルが存在することを確認
11 | if [ ! -f "$server_go_file" ]; then
12 | echo "Error: File $server_go_file does not exist"
13 | exit 1
14 | fi
15 |
16 | # 置換する文字列
17 | search_str="Client sent an HTTP request to an HTTPS server."
18 |
19 | # 置換後のエスケープ済み HTML
20 | replace_str="
Automatically jump to HTTPS"
19 |
20 | # 一時ファイルを作成
21 | $temp_file = [System.IO.Path]::GetTempFileName()
22 |
23 | # ファイルを一時ファイルにコピー
24 | Copy-Item -Path $server_go_file -Destination $temp_file -Force
25 |
26 | # 一時ファイルで置換を実行 (Raw で読み込み、UTF8 を指定)
27 | try {
28 | $content = Get-Content $temp_file -Raw -Encoding UTF8
29 | $content = $content -replace [regex]::Escape($search_str), $replace_str
30 | Set-Content -Path $temp_file -Value $content -Encoding UTF8 -NoNewline
31 | } catch {
32 | Write-Host "Error during file content replacement in temporary file: $($_.Exception.Message)"
33 | Remove-Item $temp_file
34 | exit 1
35 | }
36 |
37 | # 置換が成功したか確認
38 | if (Select-String -Path $temp_file -Pattern ([regex]::Escape($search_str)) -Encoding UTF8 -Quiet) {
39 | Write-Host "Failed to replace strings in temporary file. Content verification failed."
40 | # デバッグ用に一時ファイルの内容を出力する
41 | Write-Host "Temporary file content:"
42 | Get-Content $temp_file -Raw
43 | Remove-Item $temp_file
44 | exit 1
45 | }
46 |
47 | # 元のファイルの読み取り専用属性を解除
48 | try {
49 | Set-ItemProperty -Path $server_go_file -Name IsReadOnly -Value $false -ErrorAction SilentlyContinue
50 | } catch {
51 | # エラーを無視
52 | }
53 |
54 | # 一時ファイルを元の場所にコピー
55 | try {
56 | Copy-Item -Path $temp_file -Destination $server_go_file -Force
57 | } catch {
58 | Write-Host "Failed to copy temporary file to ${server_go_file}: $($_.Exception.Message)"
59 | Write-Host "Try running this script as Administrator"
60 | Remove-Item $temp_file
61 | exit 1
62 | }
63 |
64 | # 一時ファイルを削除
65 | Remove-Item $temp_file
66 |
67 | Write-Host "Successfully replaced strings in $server_go_file"
68 |
--------------------------------------------------------------------------------
/keyless-server/config.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "io/ioutil"
7 | "os"
8 | "path/filepath"
9 |
10 | "muzzammil.xyz/jsonc"
11 | )
12 |
13 | var config struct {
14 | Domain string `json:"domain"` // required
15 | Nameserver string `json:"nameserver"` // required
16 | CName string `json:"cname"` // optional
17 |
18 | Certificate string `json:"certificate"` // required, file path
19 | MasterKey string `json:"master_key"` // required, file path
20 | LegacyKeys string `json:"legacy_keys"` // optional, file glob
21 |
22 | IsPrivateIPRangesOnly bool `json:"is_private_ip_ranges_only"` // required
23 |
24 | KeylessAPI struct {
25 | Handler string `json:"handler"` // required
26 | Certificate string `json:"certificate"` // required, file path
27 | PrivateKey string `json:"private_key"` // required, file path
28 | ClientCA string `json:"client_ca"` // optional, file path
29 | } `json:"keyless_api"`
30 |
31 | LetsEncrypt struct {
32 | Account string `json:"account"` // required, file path
33 | AccountKey string `json:"account_key"` // required, file path
34 | } `json:"letsencrypt"`
35 |
36 | Replica string `json:"replica"` // optional
37 | }
38 |
39 | func loadConfig() error {
40 | path, err := os.Executable()
41 | f, err := ioutil.ReadFile(filepath.Dir(path) + "/akebi-keyless-server.json")
42 | if err != nil {
43 | return err
44 | }
45 |
46 | if err := jsonc.Unmarshal(f, &config); err != nil {
47 | return fmt.Errorf("akebi-keyless-server.json: %w", err)
48 | }
49 |
50 | // check required fields
51 | if config.Domain == "" {
52 | return errors.New("domain is not configured.")
53 | }
54 | if config.Nameserver == "" {
55 | return errors.New("nameserver is not configured.")
56 | }
57 | if config.Certificate == "" {
58 | return errors.New("certificate file path is not configured.")
59 | }
60 | if config.MasterKey == "" {
61 | return errors.New("master_key file path is not configured.")
62 | }
63 | if config.KeylessAPI.Handler == "" {
64 | return errors.New("keyless_api.handler is not configured.")
65 | }
66 | if config.KeylessAPI.Certificate == "" {
67 | return errors.New("keyless_api.certificate file path is not configured.")
68 | }
69 | if config.KeylessAPI.PrivateKey == "" {
70 | return errors.New("keyless_api.private_key file path is not configured.")
71 | }
72 | if config.LetsEncrypt.Account == "" {
73 | return errors.New("letsencrypt.account file path is not configured.")
74 | }
75 | if config.LetsEncrypt.AccountKey == "" {
76 | return errors.New("letsencrypt.account_key file path is not configured.")
77 | }
78 |
79 | return dnsConfig()
80 | }
81 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
2 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
3 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
4 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
5 | github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
6 | github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
7 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
8 | github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
9 | github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
10 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
11 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
12 | github.com/mholt/acmez v1.2.0 h1:1hhLxSgY5FvH5HCnGUuwbKY2VQVo8IU7rxXKSnZ7F30=
13 | github.com/mholt/acmez v1.2.0/go.mod h1:VT9YwH1xgNX1kmYY89gY8xPJC84BFAisjo8Egigt4kE=
14 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
15 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
16 | github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
17 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
18 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
19 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
20 | go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
21 | go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
22 | go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
23 | go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
24 | golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
25 | golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
26 | golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
27 | golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
28 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
29 | golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
30 | golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
31 | golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
32 | golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
33 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
34 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
35 | muzzammil.xyz/jsonc v1.0.0 h1:B6kaT3wHueZ87mPz3q1nFuM1BlL32IG0wcq0/uOsQ18=
36 | muzzammil.xyz/jsonc v1.0.0/go.mod h1:rFv8tUUKe+QLh7v02BhfxXEf4ZHhYD7unR93HL/1Uvo=
37 |
--------------------------------------------------------------------------------
/keyless-server/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "log"
7 | "net"
8 | "net/http"
9 | "os"
10 | "os/signal"
11 | "syscall"
12 |
13 | "github.com/coreos/go-systemd/v22/activation"
14 | "github.com/coreos/go-systemd/v22/daemon"
15 | )
16 |
17 | func main() {
18 | if len(os.Args) > 1 && os.Args[1] == "setup" {
19 | // run the interactive setup and exit
20 | interactiveSetup()
21 | return
22 | }
23 |
24 | if err := loadConfig(); err != nil {
25 | log.Fatalln("configuration:", err)
26 | }
27 | if err := checkSetup(); err != nil {
28 | // ask the user to run the interactive setup
29 | log.Println("setup:", err)
30 | log.Fatalln("please, run:", os.Args[0], "setup")
31 | }
32 |
33 | shutdown := make(chan os.Signal, 1)
34 | signal.Notify(shutdown, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM)
35 | ctx, cancel := context.WithCancel(context.Background())
36 | defer cancel()
37 |
38 | fs := activation.Files(true)
39 | if len(fs) > 3 {
40 | log.Fatalln("activation: unexpected number of files")
41 | }
42 |
43 | var httpln net.Listener
44 | var dnsconn net.PacketConn
45 | var replconn net.PacketConn
46 | if len(fs) > 0 {
47 | var err error
48 | httpln, err = net.FileListener(fs[0])
49 | if err != nil {
50 | log.Fatalln("activation:", err)
51 | }
52 | fs[0].Close()
53 | }
54 | if len(fs) > 1 {
55 | var err error
56 | dnsconn, err = net.FilePacketConn(fs[1])
57 | if err != nil {
58 | log.Fatalln("activation:", err)
59 | }
60 | fs[1].Close()
61 | } else {
62 | var err error
63 | dnsconn, err = net.ListenPacket("udp", "localhost:5353")
64 | if err != nil {
65 | log.Fatalln("dns server:", err)
66 | }
67 | defer dnsconn.Close()
68 | }
69 | if len(fs) > 2 {
70 | var err error
71 | replconn, err = net.FilePacketConn(fs[2])
72 | if err != nil {
73 | log.Fatalln("activation:", err)
74 | }
75 | fs[2].Close()
76 | }
77 |
78 | httpsrv, err := httpInit()
79 | if err != nil {
80 | log.Fatalln("http server:", err)
81 | }
82 | httpsrv.Addr = "localhost:8080"
83 | httpsrv.BaseContext = func(_ net.Listener) context.Context { return ctx }
84 |
85 | go func() {
86 | var err error
87 | if httpln == nil {
88 | err = httpsrv.ListenAndServe()
89 | } else {
90 | err = httpsrv.ServeTLS(httpln, "", "")
91 | }
92 | if !errors.Is(err, http.ErrServerClosed) {
93 | log.Fatalln("http server:", err)
94 | }
95 | }()
96 |
97 | go func() {
98 | err := dnsServe(dnsconn)
99 | if errors.Is(err, net.ErrClosed) {
100 | log.Fatalln("dns server:", err)
101 | }
102 | }()
103 |
104 | if replconn != nil {
105 | go func() {
106 | err := replicaServe(replconn)
107 | if errors.Is(err, net.ErrClosed) {
108 | log.Fatalln("replica server:", err)
109 | }
110 | }()
111 | }
112 |
113 | daemon.SdNotify(true, daemon.SdNotifyReady)
114 | go renewCertificates()
115 |
116 | <-shutdown
117 | go func() {
118 | log.Fatalln(<-shutdown)
119 | }()
120 | if err := dnsconn.Close(); err != nil {
121 | log.Fatalln("close dns connection:", err)
122 | }
123 | if err := httpsrv.Shutdown(ctx); err != nil {
124 | log.Fatalln("shutdown http server:", err)
125 | }
126 | }
127 |
128 | func logError(err error) {
129 | if err != nil {
130 | log.Println(err)
131 | }
132 | }
133 |
--------------------------------------------------------------------------------
/https-server/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "crypto/tls"
5 | "fmt"
6 | "io/ioutil"
7 | "log"
8 | "net/http"
9 | "net/http/httputil"
10 | "net/url"
11 | "os"
12 | "os/signal"
13 | "time"
14 |
15 | "github.com/fatih/color"
16 | )
17 |
18 | // logging
19 | func info_log(logs ...any) {
20 | now := time.Now()
21 | fmt.Print(now.Format("[2006/01/02 15:04:05] "), color.GreenString("INFO"), ": ")
22 | for _, log := range logs {
23 | fmt.Print(log, " ")
24 | }
25 | fmt.Print("\n")
26 | }
27 | func error_log(logs ...any) {
28 | now := time.Now()
29 | fmt.Print(now.Format("[2006/01/02 15:04:05] "), color.RedString("ERROR"), ": ")
30 | for _, log := range logs {
31 | fmt.Print(log, " ")
32 | }
33 | fmt.Print("\n")
34 | }
35 |
36 | func main() {
37 |
38 | // load config data
39 | if err := loadConfig(); err != nil {
40 | error_log("Configuration:", err)
41 | return
42 | }
43 |
44 | // suppress standard logger output
45 | log.SetOutput(ioutil.Discard)
46 |
47 | // setup reverse proxy
48 | var proxyPassURL, _ = url.Parse(config.ProxyPassURL)
49 | var reverseProxy = httputil.NewSingleHostReverseProxy(proxyPassURL)
50 | reverseProxy.ModifyResponse = func(response *http.Response) error {
51 | // set status code color
52 | var statusCodeText string
53 | switch {
54 | case response.StatusCode >= 200 && response.StatusCode <= 299:
55 | statusCodeText = color.GreenString(response.Status)
56 | case response.StatusCode >= 300 && response.StatusCode <= 399:
57 | statusCodeText = color.YellowString(response.Status)
58 | case response.StatusCode >= 400 && response.StatusCode <= 599:
59 | statusCodeText = color.RedString(response.Status)
60 | default:
61 | statusCodeText = response.Status
62 | }
63 |
64 | // print access log
65 | log := fmt.Sprintf(
66 | "%s - \"%s %s %s\" %s",
67 | response.Request.RemoteAddr,
68 | response.Request.Method,
69 | response.Request.URL.Path,
70 | response.Request.Proto,
71 | statusCodeText,
72 | )
73 | info_log(log)
74 | return nil
75 | }
76 |
77 | // setup TLS config
78 | var tlsConfig tls.Config
79 | if config.CustomCertificate.Certificate != "" && config.CustomCertificate.PrivateKey != "" {
80 | // use custom certificate
81 | tlsConfig = tls.Config{
82 | Certificates: []tls.Certificate{customCertificate},
83 | }
84 | } else {
85 | // use keyless server
86 | tlsConfig = tls.Config{
87 | // set GetCertificate callback
88 | GetCertificate: func() func(info *tls.ClientHelloInfo) (*tls.Certificate, error) {
89 | if config.MTLS.ClientCertificate != "" && config.MTLS.ClientCertificateKey != "" {
90 | // enable mTLS
91 | return GetKeylessServerCertificate(config.KeylessServerURL, mTLSCertificate)
92 | } else {
93 | // disable mTLS
94 | return GetKeylessServerCertificate(config.KeylessServerURL)
95 | }
96 | }(),
97 | }
98 | }
99 |
100 | // setup reverse proxy server
101 | var reverseProxyServer = http.Server{
102 | Addr: config.ListenAddress,
103 | Handler: reverseProxy,
104 | TLSConfig: &tlsConfig,
105 | }
106 |
107 | // serve reverse proxy
108 | go func() {
109 | info_log("Starting https reverse proxy server...")
110 | if config.CustomCertificate.Certificate != "" && config.CustomCertificate.PrivateKey != "" {
111 | info_log("Use custom https certificate and private key.")
112 | } else if config.MTLS.ClientCertificate != "" && config.MTLS.ClientCertificateKey != "" {
113 | info_log("Use mTLS client certificate and private key for " + config.KeylessServerURL + ".")
114 | }
115 | log := fmt.Sprintf("Listening on %s, Proxying %s.", config.ListenAddress, config.ProxyPassURL)
116 | info_log(log)
117 | var err = reverseProxyServer.ListenAndServeTLS("", "")
118 | if err != nil {
119 | error_log(err)
120 | return
121 | }
122 | }()
123 |
124 | // set signal trap
125 | quit := make(chan os.Signal)
126 | signal.Notify(quit, os.Interrupt)
127 |
128 | // When Ctrl+C is pressed
129 | <-quit
130 | defer info_log("Terminated https reverse proxy server.")
131 | }
132 |
--------------------------------------------------------------------------------
/https-server/config.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "crypto/tls"
5 | "errors"
6 | "flag"
7 | "fmt"
8 | "io/ioutil"
9 | "os"
10 | "path/filepath"
11 |
12 | "muzzammil.xyz/jsonc"
13 | )
14 |
15 | var config struct {
16 | ListenAddress string `json:"listen_address"` // required, example: 0.0.0.0:3000
17 | ProxyPassURL string `json:"proxy_pass_url"` // required, example: http://localhost:3001/
18 | KeylessServerURL string `json:"keyless_server_url"` // required, example: https://akebi.example.com/
19 |
20 | MTLS struct {
21 | ClientCertificate string `json:"client_certificate"` // optional, file path
22 | ClientCertificateKey string `json:"client_certificate_key"` // optional, file path
23 | } `json:"mtls"`
24 |
25 | CustomCertificate struct {
26 | Certificate string `json:"certificate"` // optional, file path
27 | PrivateKey string `json:"private_key"` // optional, file path
28 | } `json:"custom_certificate"`
29 | }
30 |
31 | var mTLSCertificate tls.Certificate
32 | var customCertificate tls.Certificate
33 |
34 | func loadConfig() error {
35 | path, err := os.Executable()
36 | f, err := ioutil.ReadFile(filepath.Dir(path) + "/akebi-https-server.json")
37 | if err == nil {
38 | if err := jsonc.Unmarshal(f, &config); err != nil {
39 | return fmt.Errorf("akebi-https-server.json: %w", err)
40 | }
41 | }
42 | err = nil // reset
43 |
44 | // parse arguments
45 | argument1 := flag.String("listen-address", "", "Address that HTTPS server listens on.\nSpecify 0.0.0.0:port to listen on all interfaces.")
46 | argument2 := flag.String("proxy-pass-url", "", "URL of HTTP server to reverse proxy.")
47 | argument3 := flag.String("keyless-server-url", "", "URL of Keyless API Server.")
48 | argument4 := flag.String("mtls-client-certificate", "", "Optional: Client certificate of mTLS for akebi.example.com (Keyless API).")
49 | argument5 := flag.String("mtls-client-certificate-key", "", "Optional: Client private key of mTLS for akebi.example.com (Keyless API).")
50 | argument6 := flag.String("custom-certificate", "", "Optional: Use your own HTTPS certificate instead of Akebi Keyless Server.")
51 | argument7 := flag.String("custom-private-key", "", "Optional: Use your own HTTPS private key instead of Akebi Keyless Server.")
52 | flag.Parse()
53 |
54 | // set arguments
55 | if *argument1 != "" {
56 | config.ListenAddress = *argument1
57 | }
58 | if *argument2 != "" {
59 | config.ProxyPassURL = *argument2
60 | }
61 | if *argument3 != "" {
62 | config.KeylessServerURL = *argument3
63 | }
64 | if *argument4 != "" {
65 | config.MTLS.ClientCertificate = *argument4
66 | }
67 | if *argument5 != "" {
68 | config.MTLS.ClientCertificateKey = *argument5
69 | }
70 | if *argument6 != "" {
71 | config.CustomCertificate.Certificate = *argument6
72 | }
73 | if *argument7 != "" {
74 | config.CustomCertificate.PrivateKey = *argument7
75 | }
76 |
77 | // check required fields
78 | if config.ListenAddress == "" {
79 | return errors.New("--listen-address (json:listen_address) is not configured.")
80 | }
81 | if config.ProxyPassURL == "" {
82 | return errors.New("--proxy-pass-url (json:proxy_pass_url) is not configured.")
83 | }
84 | if config.KeylessServerURL == "" {
85 | return errors.New("--keyless-server-url (json:keyless_server_url) is not configured.")
86 | }
87 |
88 | // load mTLS certificate
89 | if config.MTLS.ClientCertificate != "" && config.MTLS.ClientCertificateKey != "" {
90 | // load client certificate pair
91 | mTLSCertificate, err = tls.LoadX509KeyPair(config.MTLS.ClientCertificate, config.MTLS.ClientCertificateKey)
92 | if err != nil {
93 | return fmt.Errorf("could not open certificate file: %w", err)
94 | }
95 | }
96 |
97 | // load custom certificate
98 | if config.CustomCertificate.Certificate != "" && config.CustomCertificate.PrivateKey != "" {
99 | // load certificate pair
100 | customCertificate, err = tls.LoadX509KeyPair(config.CustomCertificate.Certificate, config.CustomCertificate.PrivateKey)
101 | if err != nil {
102 | return fmt.Errorf("could not open certificate file: %w", err)
103 | }
104 | }
105 |
106 | return err
107 | }
108 |
--------------------------------------------------------------------------------
/https-server/keyless.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "bytes"
5 | "crypto"
6 | "crypto/sha256"
7 | "crypto/tls"
8 | "crypto/x509"
9 | "encoding/base64"
10 | "encoding/pem"
11 | "fmt"
12 | "io"
13 | "io/ioutil"
14 | "net/http"
15 | "net/url"
16 | "strings"
17 | "time"
18 | )
19 |
20 | func GetKeylessServerCertificate(apiURL string, mTLSCertificate ...tls.Certificate) func(info *tls.ClientHelloInfo) (*tls.Certificate, error) {
21 | apiURL = strings.TrimSuffix(apiURL, "/")
22 |
23 | var client *http.Client
24 | if len(mTLSCertificate) == 0 {
25 | client = http.DefaultClient
26 | } else {
27 | client = &http.Client{
28 | Transport: &http.Transport{
29 | Proxy: http.ProxyFromEnvironment,
30 | IdleConnTimeout: 10 * time.Minute,
31 | TLSClientConfig: &tls.Config{
32 | Certificates: mTLSCertificate,
33 | },
34 | },
35 | Timeout: 5 * time.Second,
36 | }
37 | }
38 |
39 | return func(info *tls.ClientHelloInfo) (*tls.Certificate, error) {
40 | // require SNI
41 | if info.ServerName == "" {
42 | error_log("Fetching certificate: missing server name")
43 | return nil, nil
44 | }
45 |
46 | // fetch certificate
47 | res, err := client.Get(apiURL + "/certificate?" + url.QueryEscape(info.ServerName))
48 | if err != nil {
49 | log := fmt.Sprintf("Fetching certificate: %s", err)
50 | error_log(log)
51 | return nil, err
52 | }
53 | defer res.Body.Close()
54 |
55 | if res.StatusCode != 200 {
56 | log := fmt.Sprintf("Fetching certificate: keyless api server returned returned http error: %s", res.Status)
57 | error_log(log)
58 | return nil, nil
59 | }
60 |
61 | data, err := ioutil.ReadAll(res.Body)
62 | if err != nil {
63 | log := fmt.Sprintf("Fetching certificate: %s", err)
64 | error_log(log)
65 | return nil, err
66 | }
67 |
68 | // decode certificate
69 | var cert tls.Certificate
70 | for {
71 | var block *pem.Block
72 | block, data = pem.Decode(data)
73 | if block == nil {
74 | break
75 | }
76 | if block.Type == "CERTIFICATE" {
77 | cert.Certificate = append(cert.Certificate, block.Bytes)
78 | }
79 | }
80 |
81 | if len(cert.Certificate) == 0 {
82 | error_log("Fetching certificate: no certificates returned")
83 | return nil, nil
84 | }
85 |
86 | cert.Leaf, err = x509.ParseCertificate(cert.Certificate[0])
87 | if err != nil {
88 | log := fmt.Sprintf("Fetching certificate: %s", err)
89 | error_log(log)
90 | return nil, err
91 | }
92 |
93 | der, err := x509.MarshalPKIXPublicKey(cert.Leaf.PublicKey)
94 | if err != nil {
95 | log := fmt.Sprintf("Fetching certificate: %s", err)
96 | error_log(log)
97 | return nil, err
98 | }
99 |
100 | // initialize Signer
101 | hash := sha256.Sum256(der)
102 | cert.PrivateKey = Signer{
103 | pub: cert.Leaf.PublicKey,
104 | id: base64.RawURLEncoding.EncodeToString(hash[:]),
105 | api: apiURL,
106 | client: client,
107 | }
108 |
109 | if err := info.SupportsCertificate(&cert); err != nil {
110 | log := fmt.Sprintf("Fetching certificate: %s", err)
111 | error_log(log)
112 | return nil, err
113 | }
114 |
115 | return &cert, nil
116 | }
117 | }
118 |
119 | var _ crypto.Signer = Signer{}
120 |
121 | type Signer struct {
122 | pub crypto.PublicKey
123 | id string
124 | api string
125 | client *http.Client
126 | }
127 |
128 | func (signer Signer) Public() crypto.PublicKey {
129 | return signer.pub
130 | }
131 |
132 | func (signer Signer) Sign(rand io.Reader, digest []byte, opts crypto.SignerOpts) (signature []byte, err error) {
133 | hash := opts.HashFunc().String()
134 |
135 | // send signing request
136 | // "key" parameter is Base64-encoded SHA-256 hash of the certificate in DER format.
137 | // "hash" parameter is string that represents the type of hash function (e.g. SHA-256)
138 | // example of URL: https://akebi.example.com/sign?key=pyrfaV5udNlWgp5ZSSSHVRd8nDQ5yp8ILTiU_CVXmRk&hash=SHA-256
139 | res, err := signer.client.Post(
140 | signer.api+"/sign?key="+url.QueryEscape(signer.id)+"&hash="+url.QueryEscape(hash),
141 | "application/octet-stream", bytes.NewReader(digest))
142 | if err != nil {
143 | log := fmt.Sprintf("Signing digest: %s", err)
144 | error_log(log)
145 | return nil, err
146 | }
147 | defer res.Body.Close()
148 |
149 | if res.StatusCode != 200 {
150 | log := fmt.Sprintf("Signing digest: %s", err)
151 | error_log(log)
152 | return nil, err
153 | }
154 |
155 | // read the signature
156 | data, err := ioutil.ReadAll(res.Body)
157 | if err != nil {
158 | log := fmt.Sprintf("Signing digest: %s", err)
159 | error_log(log)
160 | return nil, err
161 | }
162 |
163 | info_log("Obtained signature from keyless api server.")
164 |
165 | return data, nil
166 | }
167 |
--------------------------------------------------------------------------------
/keyless-server/http.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "crypto"
5 | "crypto/ecdsa"
6 | "crypto/rand"
7 | "crypto/tls"
8 | "crypto/x509"
9 | "errors"
10 | "fmt"
11 | "io"
12 | "io/ioutil"
13 | "math/big"
14 | "net/http"
15 | "os"
16 | "sync"
17 | "time"
18 |
19 | "github.com/mholt/acmez"
20 | )
21 |
22 | var httpCert struct {
23 | sync.Mutex
24 | *tls.Certificate
25 | }
26 |
27 | func httpInit() (*http.Server, error) {
28 | cert, err := tls.LoadX509KeyPair(config.KeylessAPI.Certificate, config.KeylessAPI.PrivateKey)
29 | if os.IsNotExist(err) {
30 | cert.PrivateKey, err = loadKey(config.KeylessAPI.PrivateKey)
31 | } else if err == nil {
32 | cert.Leaf, err = x509.ParseCertificate(cert.Certificate[0])
33 | }
34 | if err != nil {
35 | return nil, err
36 | }
37 | httpCert.Certificate = &cert
38 |
39 | var cfg tls.Config
40 | cfg.NextProtos = []string{"h2", "http/1.1", acmez.ACMETLS1Protocol}
41 |
42 | cfg.GetCertificate = func(chi *tls.ClientHelloInfo) (*tls.Certificate, error) {
43 | if chi.ServerName == "" {
44 | return nil, errors.New("missing server name")
45 | }
46 | if len(chi.SupportedProtos) == 1 && chi.SupportedProtos[0] == acmez.ACMETLS1Protocol {
47 | return solvers.GetTLSChallengeCert(chi.ServerName)
48 | }
49 | httpCert.Lock()
50 | defer httpCert.Unlock()
51 | if len(httpCert.Certificate.Certificate) == 0 {
52 | return getSelfSignedCert(httpCert.PrivateKey)
53 | }
54 | if err := chi.SupportsCertificate(httpCert.Certificate); err != nil {
55 | return nil, err
56 | }
57 | return httpCert.Certificate, nil
58 | }
59 |
60 | if config.KeylessAPI.ClientCA != "" {
61 | cert, err := ioutil.ReadFile(config.KeylessAPI.ClientCA)
62 | if err != nil {
63 | return nil, err
64 | }
65 |
66 | cfg.ClientCAs = x509.NewCertPool()
67 | cfg.ClientCAs.AppendCertsFromPEM(cert)
68 | cfg.ClientAuth = tls.RequireAndVerifyClientCert
69 | }
70 |
71 | var mux http.ServeMux
72 | mux.Handle("/.well-known/acme-challenge/", http.HandlerFunc(solvers.HandleHTTPChallenge))
73 | mux.Handle("/certificate", http.HandlerFunc(certificateHandler))
74 | mux.Handle("/sign", http.HandlerFunc(signingHandler))
75 | mux.Handle("/", http.HandlerFunc(notFoundHandler))
76 |
77 | server := http.Server{
78 | Handler: &mux,
79 | TLSConfig: &cfg,
80 | ReadTimeout: 5 * time.Second,
81 | WriteTimeout: 10 * time.Second,
82 | IdleTimeout: 10 * time.Minute,
83 | }
84 |
85 | return &server, nil
86 | }
87 |
88 | func sendErrorPage(responseWriter http.ResponseWriter, status int) {
89 | html := `
90 |
91 |
92 |
93 | %d %s
94 |
99 |
100 |
101 | %d %s
102 |
103 | Akebi Keyless Server (https://github.com/tsukumijima/Akebi)
104 |
105 |
106 | `
107 | responseWriter.WriteHeader(status) // write status code
108 | responseWriter.Header().Set("Content-Type", "text/html; charset=utf-8")
109 | fmt.Fprintln(responseWriter, fmt.Sprintf(html, status, http.StatusText(status), status, http.StatusText(status)))
110 | }
111 |
112 | func certificateHandler(responseWriter http.ResponseWriter, request *http.Request) {
113 | responseWriter.Header().Set("Content-Type", "application/pem-certificate-chain")
114 | http.ServeFile(responseWriter, request, config.Certificate)
115 | }
116 |
117 | func signingHandler(responseWriter http.ResponseWriter, request *http.Request) {
118 | query := request.URL.Query()
119 |
120 | // get the private key that matches the Base64-encoded SHA-256 hash of the certificate in DER format
121 | key, ok := privateKeys[query.Get("key")]
122 | if !ok {
123 | sendErrorPage(responseWriter, http.StatusNotFound)
124 | return
125 | }
126 |
127 | // get the type of hash function (e.g. SHA-256)
128 | var hash crypto.Hash
129 | if h := query.Get("hash"); h != "" {
130 | for hash = crypto.MD4; ; hash++ {
131 | if hash > crypto.BLAKE2b_512 {
132 | sendErrorPage(responseWriter, http.StatusNotFound)
133 | return
134 | }
135 | if hash.String() == h && hash.Available() {
136 | // found
137 | break
138 | }
139 | }
140 | }
141 |
142 | // read the digest value
143 | var digest [65]byte
144 | n, err := io.ReadFull(request.Body, digest[:])
145 | if err != io.ErrUnexpectedEOF {
146 | sendErrorPage(responseWriter, http.StatusBadRequest)
147 | return
148 | }
149 |
150 | // sign the digest value with the private key
151 | signature, err := key.Sign(rand.Reader, digest[:n], hash)
152 | if err != nil {
153 | sendErrorPage(responseWriter, http.StatusInternalServerError)
154 | return
155 | }
156 |
157 | responseWriter.Header().Set("Content-Type", "application/octet-stream")
158 | responseWriter.Write(signature)
159 | }
160 |
161 | func notFoundHandler(responseWriter http.ResponseWriter, request *http.Request) {
162 | sendErrorPage(responseWriter, http.StatusNotFound)
163 | }
164 |
165 | func getSelfSignedCert(key crypto.PrivateKey) (*tls.Certificate, error) {
166 | pk, ok := key.(*ecdsa.PrivateKey)
167 | if !ok {
168 | return nil, fmt.Errorf("unexpected type %T", key)
169 | }
170 |
171 | template := x509.Certificate{
172 | SerialNumber: &big.Int{},
173 | NotBefore: time.Now(),
174 | NotAfter: time.Now().AddDate(0, 0, 1),
175 | KeyUsage: x509.KeyUsageDigitalSignature,
176 | ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
177 | BasicConstraintsValid: true,
178 | }
179 |
180 | data, err := x509.CreateCertificate(rand.Reader, &template, &template, &pk.PublicKey, pk)
181 | if err != nil {
182 | return nil, err
183 | }
184 |
185 | return &tls.Certificate{
186 | Certificate: [][]byte{data},
187 | PrivateKey: key,
188 | }, nil
189 | }
190 |
--------------------------------------------------------------------------------
/keyless-server/setup.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "crypto/ecdsa"
6 | "crypto/elliptic"
7 | "crypto/rand"
8 | "crypto/x509"
9 | "encoding/json"
10 | "encoding/pem"
11 | "errors"
12 | "fmt"
13 | "io/ioutil"
14 | "log"
15 | "net"
16 | "os"
17 | "path/filepath"
18 | "strings"
19 |
20 | "github.com/mholt/acmez"
21 | "github.com/mholt/acmez/acme"
22 | )
23 |
24 | // Checks that the server is setup correctly.
25 | // Load private keys (master and legacy) into memory.
26 | // Rotating the master key requires a process restart.
27 | func checkSetup() error {
28 | _, err := loadAccount(nil)
29 | if err != nil {
30 | return err
31 | }
32 | if err := loadAPI(); err != nil {
33 | return err
34 | }
35 | return loadCertificateAndKeys()
36 | }
37 |
38 | // Sets the server up interactively.
39 | func interactiveSetup() {
40 | log.SetFlags(0)
41 | log.SetOutput(os.Stdout)
42 | fmt.Println("Running setup...")
43 |
44 | if err := loadConfig(); err != nil {
45 | log.Fatalln("Error:", err)
46 | }
47 | if err := checkSetup(); err == nil {
48 | fmt.Println("It seems you're all set!")
49 | return
50 | }
51 |
52 | fmt.Println()
53 | ctx := context.Background()
54 | client := &acmez.Client{Client: &acme.Client{}}
55 |
56 | acct, err := setupAccount(ctx, client)
57 | if err != nil {
58 | log.Fatalln("Error:", err)
59 | }
60 |
61 | if err := setupCertificateAndKeys(ctx, client, acct); err != nil {
62 | log.Fatalln("Error:", err)
63 | }
64 |
65 | if err := setupAPI(ctx, client, acct); err != nil {
66 | log.Fatalln("Error:", err)
67 | }
68 |
69 | fmt.Println()
70 | fmt.Println("Done!")
71 | }
72 |
73 | func setupAccount(ctx context.Context, client *acmez.Client) (acct acme.Account, err error) {
74 | acct, err = loadAccount(client)
75 | if err == nil {
76 | fmt.Println("Using the existing Let's Encrypt account.")
77 | return acct, nil
78 | }
79 |
80 | fmt.Println("Creating a new Let's Encrypt account...")
81 |
82 | err = os.MkdirAll(filepath.Dir(config.LetsEncrypt.Account), 0700)
83 | if err != nil {
84 | return acct, err
85 | }
86 |
87 | acct.PrivateKey, err = setupKey("account", config.LetsEncrypt.AccountKey)
88 | if err != nil {
89 | return acct, err
90 | }
91 |
92 | var answer string
93 |
94 | fmt.Println()
95 | fmt.Print("Accept Let's Encrypt ToS? [y/n]: ")
96 | if n, _ := fmt.Scanln(&answer); n != 1 || answer != "y" {
97 | return acct, errors.New("did not accept Let's Encrypt ToS")
98 | }
99 |
100 | fmt.Print("Use the Let's Encrypt production API? [y/n]: ")
101 | if n, _ := fmt.Scanln(&answer); n != 1 || answer != "y" {
102 | client.Directory = letsencryptStaging + "directory"
103 | } else {
104 | client.Directory = letsencryptProduction + "directory"
105 | }
106 |
107 | fmt.Print("Enter an email address: ")
108 | if n, _ := fmt.Scanln(&answer); n == 1 && answer != "" {
109 | acct.Contact = append(acct.Contact, "mailto:"+answer)
110 | }
111 | fmt.Println()
112 |
113 | acct.TermsOfServiceAgreed = true
114 | acct, err = client.NewAccount(ctx, acct)
115 | if err != nil {
116 | return acct, err
117 | }
118 |
119 | json, err := json.MarshalIndent(acct, "", " ")
120 | if err != nil {
121 | return acct, err
122 | }
123 |
124 | err = ioutil.WriteFile(config.LetsEncrypt.Account, json, 0400)
125 | return acct, err
126 | }
127 |
128 | func setupCertificateAndKeys(ctx context.Context, client *acmez.Client, acct acme.Account) error {
129 | if loadCertificateAndKeys() == nil {
130 | fmt.Println("Using the existing certificate and keys.")
131 | return nil
132 | }
133 |
134 | nameserver := config.Nameserver
135 | app := filepath.Base(os.Args[0])
136 |
137 | key, err := setupKey("master", config.MasterKey)
138 | if err != nil {
139 | return err
140 | }
141 |
142 | fmt.Println()
143 | fmt.Println("Starting DNS server for domain validation...")
144 | fmt.Println("Please, ensure that:")
145 | fmt.Printf(" - NS records for %s point to %s\n", config.Domain, nameserver)
146 | fmt.Printf(" - %s is reachable from the internet on UDP %s:53\n", app, nameserver)
147 | fmt.Print("Continue? ")
148 | fmt.Scanln()
149 |
150 | conn, err := setupUDP(nameserver, ":53")
151 | if err != nil {
152 | return err
153 | }
154 | defer conn.Close()
155 | go dnsServe(conn)
156 |
157 | fmt.Println()
158 | client.ChallengeSolvers = solvers.GetDNSSolvers()
159 | fmt.Printf("Obtaining a certificate for *.%s...\n", config.Domain)
160 | return obtainCertificate(ctx, client, acct, key, config.Certificate, "*."+config.Domain)
161 | }
162 |
163 | func setupAPI(ctx context.Context, client *acmez.Client, acct acme.Account) error {
164 | if loadAPI() == nil {
165 | fmt.Println("Using the existing Keyless API certificates and key.")
166 | return nil
167 | }
168 |
169 | var hostname string
170 | app := filepath.Base(os.Args[0])
171 | if i := strings.IndexByte(config.KeylessAPI.Handler, '/'); i > 0 {
172 | hostname = config.KeylessAPI.Handler[:i]
173 | } else {
174 | return errors.New("Keyless API handler does not have a hostname.")
175 | }
176 |
177 | key, err := setupKey("Keyless API", config.KeylessAPI.PrivateKey)
178 | if err != nil {
179 | return err
180 | }
181 |
182 | fmt.Println()
183 | fmt.Println("Starting HTTPS server for hostname validation...")
184 | fmt.Println("Please, ensure that:")
185 | fmt.Printf(" - %s is reachable from the internet on TCP %s:443\n", app, hostname)
186 | fmt.Print("Continue? ")
187 | fmt.Scanln()
188 |
189 | ln, err := setupTCP(hostname, ":443")
190 | if err != nil {
191 | return err
192 | }
193 | defer ln.Close()
194 |
195 | server, err := httpInit()
196 | if err != nil {
197 | return err
198 | }
199 | defer server.Close()
200 |
201 | go server.ServeTLS(ln, "", "")
202 |
203 | fmt.Println()
204 | client.ChallengeSolvers = solvers.GetAPISolvers()
205 | fmt.Printf("Obtaining a certificate for %s...\n", hostname)
206 | return obtainCertificate(ctx, client, acct, key, config.KeylessAPI.Certificate, hostname)
207 | }
208 |
209 | func setupKey(keyName, keyFile string) (*ecdsa.PrivateKey, error) {
210 | if buf, err := ioutil.ReadFile(keyFile); os.IsNotExist(err) {
211 | fmt.Println("Creating a new", keyName, "private key...")
212 |
213 | err := os.MkdirAll(filepath.Dir(keyFile), 0700)
214 | if err != nil {
215 | return nil, err
216 | }
217 |
218 | key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
219 | if err != nil {
220 | return nil, err
221 | }
222 |
223 | der, err := x509.MarshalECPrivateKey(key)
224 | if err != nil {
225 | return nil, err
226 | }
227 |
228 | pem := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der})
229 | err = ioutil.WriteFile(keyFile, pem, 0400)
230 | if err != nil {
231 | return nil, err
232 | }
233 |
234 | return key, nil
235 |
236 | } else {
237 | fmt.Println("Using the existing", keyName, "private key...")
238 | if err != nil {
239 | return nil, err
240 | }
241 |
242 | blk, _ := pem.Decode(buf)
243 | if blk == nil {
244 | return nil, errors.New("no PEM data found")
245 | }
246 | return x509.ParseECPrivateKey(blk.Bytes)
247 | }
248 | }
249 |
250 | func setupUDP(host, port string) (net.PacketConn, error) {
251 | conn, _ := net.ListenPacket("udp", host+port)
252 | if conn == nil && host != "" {
253 | conn, _ = net.ListenPacket("udp", port)
254 | }
255 | if conn != nil {
256 | return conn, nil
257 | }
258 |
259 | fmt.Println()
260 | fmt.Printf("Could not listen on UDP %s.\n", port)
261 | addr, err := setupAddress()
262 | if err != nil {
263 | return nil, err
264 | }
265 |
266 | return net.ListenPacket("udp", addr)
267 | }
268 |
269 | func setupTCP(host, port string) (net.Listener, error) {
270 | conn, _ := net.Listen("tcp", host+port)
271 | if conn == nil && host != "" {
272 | conn, _ = net.Listen("tcp", port)
273 | }
274 | if conn != nil {
275 | return conn, nil
276 | }
277 |
278 | fmt.Println()
279 | fmt.Printf("Could not listen on TCP %s.\n", port)
280 | addr, err := setupAddress()
281 | if err != nil {
282 | return nil, err
283 | }
284 |
285 | return net.Listen("tcp", addr)
286 | }
287 |
288 | func setupAddress() (string, error) {
289 | fmt.Print("Enter the host:port address to listen on: ")
290 |
291 | var answer string
292 | fmt.Scanln(&answer)
293 | host, port, err := net.SplitHostPort(answer)
294 | return host + ":" + port, err
295 | }
296 |
--------------------------------------------------------------------------------
/keyless-server/letsencrypt.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "crypto"
6 | "crypto/ecdsa"
7 | "crypto/sha256"
8 | "crypto/tls"
9 | "crypto/x509"
10 | "encoding/base64"
11 | "encoding/json"
12 | "encoding/pem"
13 | "errors"
14 | "fmt"
15 | "io/ioutil"
16 | "net/http"
17 | "os"
18 | "path/filepath"
19 | "strings"
20 | "sync"
21 | "time"
22 |
23 | "github.com/mholt/acmez"
24 | "github.com/mholt/acmez/acme"
25 | )
26 |
27 | var privateKeys = make(map[string]crypto.Signer)
28 |
29 | const (
30 | letsencryptProduction = "https://acme-v02.api.letsencrypt.org/"
31 | letsencryptStaging = "https://acme-staging-v02.api.letsencrypt.org/"
32 | )
33 |
34 | func loadCertificateAndKeys() error {
35 | cert, err := loadCertificate(config.Certificate, config.MasterKey, "*."+config.Domain)
36 | if err != nil {
37 | return err
38 | }
39 |
40 | key, ok := cert.PrivateKey.(crypto.Signer)
41 | if !ok {
42 | return fmt.Errorf("unexpected type %T", cert.PrivateKey)
43 | }
44 |
45 | keys := []crypto.Signer{key}
46 | if config.LegacyKeys != "" {
47 | matches, err := filepath.Glob(config.LegacyKeys)
48 | if err != nil {
49 | return err
50 | }
51 |
52 | for _, match := range matches {
53 | key, err := loadKey(match)
54 | if err != nil {
55 | return err
56 | }
57 | keys = append(keys, key)
58 | }
59 | }
60 |
61 | for _, key := range keys {
62 | der, err := x509.MarshalPKIXPublicKey(key.Public())
63 | if err != nil {
64 | return err
65 | }
66 |
67 | hash := sha256.Sum256(der)
68 | privateKeys[base64.RawURLEncoding.EncodeToString(hash[:])] = key
69 | }
70 | return nil
71 | }
72 |
73 | func loadAPI() error {
74 | var hostname string
75 | if i := strings.IndexByte(config.KeylessAPI.Handler, '/'); i > 0 {
76 | hostname = config.KeylessAPI.Handler[:i]
77 | }
78 | _, err := loadCertificate(config.KeylessAPI.Certificate, config.KeylessAPI.PrivateKey, hostname)
79 | if err != nil {
80 | return err
81 | }
82 |
83 | if config.KeylessAPI.ClientCA != "" {
84 | cert, err := ioutil.ReadFile(config.KeylessAPI.ClientCA)
85 | if err != nil {
86 | return err
87 | }
88 |
89 | if pool := x509.NewCertPool(); !pool.AppendCertsFromPEM(cert) {
90 | return errors.New("could not parse client CA certificate")
91 | }
92 | }
93 | return nil
94 | }
95 |
96 | func loadAccount(client *acmez.Client) (acct acme.Account, err error) {
97 | acct.PrivateKey, err = loadKey(config.LetsEncrypt.AccountKey)
98 | if err != nil {
99 | return acct, err
100 | }
101 |
102 | f, err := os.Open(config.LetsEncrypt.Account)
103 | if err != nil {
104 | return acct, err
105 | }
106 | defer f.Close()
107 |
108 | if err := json.NewDecoder(f).Decode(&acct); err != nil {
109 | return acct, err
110 | }
111 |
112 | if client != nil && client.Directory == "" {
113 | if strings.HasPrefix(acct.Location, letsencryptProduction) {
114 | client.Directory = letsencryptProduction + "directory"
115 | } else {
116 | client.Directory = letsencryptStaging + "directory"
117 | }
118 | }
119 | return acct, err
120 | }
121 |
122 | func loadKey(keyFile string) (*ecdsa.PrivateKey, error) {
123 | buf, err := ioutil.ReadFile(keyFile)
124 | if err != nil {
125 | return nil, err
126 | }
127 |
128 | blk, _ := pem.Decode(buf)
129 | if blk == nil {
130 | return nil, errors.New("no PEM data found")
131 | }
132 |
133 | return x509.ParseECPrivateKey(blk.Bytes)
134 | }
135 |
136 | func loadCertificate(certFile, keyFile, hostname string) (tls.Certificate, error) {
137 | cert, err := tls.LoadX509KeyPair(certFile, keyFile)
138 | if err != nil {
139 | return tls.Certificate{}, err
140 | }
141 | if err := verifyCertificate(&cert, hostname); err != nil {
142 | return tls.Certificate{}, err
143 | }
144 | return cert, nil
145 | }
146 |
147 | func verifyCertificate(cert *tls.Certificate, hostname string) (err error) {
148 | cert.Leaf, err = x509.ParseCertificate(cert.Certificate[0])
149 | if err != nil {
150 | return err
151 | }
152 | if now := time.Now(); now.Before(cert.Leaf.NotBefore) || now.After(cert.Leaf.NotAfter) {
153 | return errors.New("expired certificate")
154 | }
155 | if hostname != "" {
156 | return cert.Leaf.VerifyHostname(hostname)
157 | }
158 | return nil
159 | }
160 |
161 | func obtainCertificate(ctx context.Context, client *acmez.Client, acct acme.Account, key crypto.Signer, certFile string, domains ...string) error {
162 | certs, err := client.ObtainCertificate(ctx, acct, key, domains)
163 | if err != nil {
164 | return err
165 | }
166 |
167 | for _, acme := range certs {
168 | f, err := ioutil.TempFile(filepath.Split(certFile))
169 | if err != nil {
170 | return err
171 | }
172 | _, err = f.Write(acme.ChainPEM)
173 | if cerr := f.Close(); err == nil {
174 | err = cerr
175 | }
176 | if err != nil {
177 | return err
178 | }
179 | return os.Rename(f.Name(), certFile)
180 | }
181 | return errors.New("no certificates obtained")
182 | }
183 |
184 | var (
185 | solvers acmeSolvers
186 | _ acmez.Solver = &solvers
187 | )
188 |
189 | type acmeSolvers struct {
190 | sync.Mutex
191 | challanges []acmeChallenge
192 | }
193 |
194 | type acmeChallenge struct {
195 | Created time.Time
196 | acme.Challenge
197 | }
198 |
199 | func (c acmeChallenge) Expired() bool {
200 | return time.Since(c.Created) > time.Minute
201 | }
202 |
203 | func (s *acmeSolvers) RemoveChallenges(match func(c acmeChallenge) bool) {
204 | var n int
205 | for _, c := range s.challanges {
206 | if !match(c) {
207 | s.challanges[n] = c
208 | n++
209 | }
210 | }
211 | for i := range s.challanges[n:] {
212 | s.challanges[i] = acmeChallenge{}
213 | }
214 | s.challanges = s.challanges[:n]
215 | }
216 |
217 | func (s *acmeSolvers) GetLocalAuthorizations(typ, name string) []string {
218 | var res []string
219 | for _, c := range s.GetChallenges(typ, name, false) {
220 | res = append(res, c.KeyAuthorization)
221 | }
222 | return res
223 | }
224 |
225 | func (s *acmeSolvers) GetDNSChallenges(domain string) []string {
226 | var res []string
227 | for _, c := range s.GetChallenges(acme.ChallengeTypeDNS01, domain, true) {
228 | res = append(res, c.DNS01KeyAuthorization())
229 | }
230 | return res
231 | }
232 |
233 | func (s *acmeSolvers) GetTLSChallengeCert(serverName string) (*tls.Certificate, error) {
234 | for _, c := range s.GetChallenges(acme.ChallengeTypeTLSALPN01, serverName, true) {
235 | return acmez.TLSALPN01ChallengeCert(c)
236 | }
237 | return nil, errors.New("no matching challanges found")
238 | }
239 |
240 | func (s *acmeSolvers) HandleHTTPChallenge(w http.ResponseWriter, r *http.Request) {
241 | if r.Method == "GET" {
242 | for _, c := range s.GetChallenges(acme.ChallengeTypeHTTP01, r.Host, true) {
243 | if r.URL.Path == c.HTTP01ResourcePath() {
244 | w.Write([]byte(c.KeyAuthorization))
245 | return
246 | }
247 | }
248 | }
249 | http.NotFound(w, r)
250 | }
251 |
252 | func (s *acmeSolvers) GetDNSSolvers() map[string]acmez.Solver {
253 | return map[string]acmez.Solver{
254 | acme.ChallengeTypeDNS01: s,
255 | }
256 | }
257 |
258 | func (s *acmeSolvers) GetAPISolvers() map[string]acmez.Solver {
259 | return map[string]acmez.Solver{
260 | acme.ChallengeTypeHTTP01: s,
261 | acme.ChallengeTypeTLSALPN01: s,
262 | }
263 | }
264 |
265 | func (s *acmeSolvers) Present(_ context.Context, chal acme.Challenge) error {
266 | s.Lock()
267 | defer s.Unlock()
268 | s.RemoveChallenges(acmeChallenge.Expired)
269 | s.challanges = append(s.challanges, acmeChallenge{
270 | Created: time.Now(),
271 | Challenge: chal,
272 | })
273 | return nil
274 | }
275 |
276 | func (s *acmeSolvers) CleanUp(_ context.Context, chal acme.Challenge) error {
277 | s.Lock()
278 | defer s.Unlock()
279 | s.RemoveChallenges(func(c acmeChallenge) bool { return chal == c.Challenge })
280 | return nil
281 | }
282 |
283 | func (s *acmeSolvers) GetChallenges(typ, name string, remote bool) []acme.Challenge {
284 | var res []acme.Challenge
285 |
286 | if remote {
287 | for _, auth := range replicaClient(typ, name) {
288 | if i := strings.IndexByte(auth, '.'); i >= 0 {
289 | res = append(res, acme.Challenge{
290 | Type: typ,
291 | KeyAuthorization: auth,
292 | Token: auth[:i],
293 | Identifier: acme.Identifier{Value: name},
294 | })
295 | }
296 | }
297 | }
298 |
299 | s.Lock()
300 | defer s.Unlock()
301 | s.RemoveChallenges(acmeChallenge.Expired)
302 | for _, c := range s.challanges {
303 | if c.Type == typ && strings.EqualFold(c.Identifier.Value, name) {
304 | res = append(res, c.Challenge)
305 | }
306 | }
307 | return res
308 | }
309 |
--------------------------------------------------------------------------------
/keyless-server/dns.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "errors"
5 | "net"
6 | "strings"
7 |
8 | "golang.org/x/net/dns/dnsmessage"
9 | )
10 |
11 | var (
12 | nameserver dnsmessage.Name
13 | cname dnsmessage.Name
14 | )
15 |
16 | func dnsConfig() error {
17 | var err error
18 | nameserver, err = dnsmessage.NewName(config.Nameserver + ".")
19 | if err == nil && config.CName != "" {
20 | cname, err = dnsmessage.NewName(config.CName + ".")
21 | }
22 | return err
23 | }
24 |
25 | func dnsServe(conn net.PacketConn) error {
26 | var buf [512]byte
27 | for {
28 | var nerr net.Error
29 | n, addr, err := conn.ReadFrom(buf[:])
30 | if errors.As(err, &nerr) && !nerr.Temporary() && !nerr.Timeout() {
31 | return err
32 | }
33 | if err != nil {
34 | logError(err)
35 | continue
36 | }
37 |
38 | var parser dnsmessage.Parser
39 | header, err := parser.Start(buf[:n])
40 | if err != nil {
41 | logError(err)
42 | continue
43 | }
44 |
45 | var res response
46 | res.header.ID = header.ID
47 | res.header.Response = true
48 | res.header.OpCode = header.OpCode
49 | res.header.Authoritative = true
50 | res.header.RecursionDesired = header.RecursionDesired
51 |
52 | // only QUERY is implemented
53 | if header.OpCode != 0 {
54 | res.header.RCode = dnsmessage.RCodeNotImplemented
55 | logError(res.send(conn, addr, buf[:0]))
56 | continue
57 | }
58 |
59 | question, err := parser.Question()
60 | // refuse zero questions
61 | if err == dnsmessage.ErrSectionDone {
62 | res.header.RCode = dnsmessage.RCodeRefused
63 | logError(res.send(conn, addr, buf[:0]))
64 | continue
65 | }
66 | // report error
67 | if err != nil {
68 | res.header.RCode = dnsmessage.RCodeFormatError
69 | logError(res.send(conn, addr, buf[:0]))
70 | continue
71 | }
72 | // answer the first question only, ingore everything else
73 | res.header.RCode = res.answerQuestion(question)
74 | logError(res.send(conn, addr, buf[:0]))
75 | }
76 | }
77 |
78 | type response struct {
79 | header dnsmessage.Header
80 | question dnsmessage.Question
81 | answer func(*dnsmessage.Builder) error
82 | }
83 |
84 | func (r *response) answerQuestion(question dnsmessage.Question) dnsmessage.RCode {
85 | // ANY is not implemented
86 | if question.Type == dnsmessage.TypeALL {
87 | return dnsmessage.RCodeNotImplemented
88 | }
89 |
90 | // refuse everything outside our zone
91 | name := strings.TrimSuffix(strings.ToLower(question.Name.String()), ".")
92 | if n := strings.TrimSuffix(name, config.Domain); len(n) != len(name) {
93 | switch {
94 | case len(n) == 0:
95 | name = ""
96 | case n[len(n)-1] == '.':
97 | name = n[:len(n)-1]
98 | default:
99 | return dnsmessage.RCodeRefused
100 | }
101 | } else {
102 | return dnsmessage.RCodeRefused
103 | }
104 |
105 | // otherwise, answer the question
106 | r.question = question
107 |
108 | header := dnsmessage.ResourceHeader{
109 | Name: question.Name,
110 | Class: dnsmessage.ClassINET,
111 | }
112 |
113 | // apex domain, must have SOA and NS
114 | if name == "" {
115 | switch {
116 | case question.Type == dnsmessage.TypeSOA:
117 | r.answer = func(b *dnsmessage.Builder) error {
118 | return b.SOAResource(getAuthority(r.question.Name))
119 | }
120 |
121 | case question.Type == dnsmessage.TypeNS:
122 | header.TTL = 7 * 86400 // 7 days
123 | r.answer = func(b *dnsmessage.Builder) error {
124 | return b.NSResource(header, dnsmessage.NSResource{NS: nameserver})
125 | }
126 |
127 | // CAA allows Let's Encrypt wildcards, denies everything else
128 | // this prevents DoS by exausting Let's Encrypt quotas
129 | // dnsmessage does not support CAA records: use TXT, convert to CAA later
130 | case question.Type == 257: // CAA
131 | header.TTL = 7 * 86400 // 7 days
132 | r.answer = func(b *dnsmessage.Builder) error {
133 | err := b.TXTResource(header, dnsmessage.TXTResource{TXT: []string{`0 issue ";"`}})
134 | if err != nil {
135 | return err
136 | }
137 | return b.TXTResource(header, dnsmessage.TXTResource{TXT: []string{`0 issuewild "letsencrypt.org"`}})
138 | }
139 |
140 | // CNAME is not RFC compliant, but mostly works:
141 | // https://blog.cloudflare.com/zone-apex-naked-domain-root-domain-cname-supp/
142 | case cname.Length != 0:
143 | header.TTL = 5 * 60 // 5 minutes
144 | r.answer = func(b *dnsmessage.Builder) error {
145 | return b.CNAMEResource(header, dnsmessage.CNAMEResource{CNAME: cname})
146 | }
147 | }
148 | return dnsmessage.RCodeSuccess
149 | }
150 |
151 | // Let's Encrypt challenge
152 | if name == "_acme-challenge" {
153 | if question.Type == dnsmessage.TypeTXT {
154 | challenges := solvers.GetDNSChallenges(config.Domain)
155 | if len(challenges) > 0 {
156 | header.TTL = 5 * 60 // 5 minutes
157 | r.answer = func(b *dnsmessage.Builder) error {
158 | return b.TXTResource(header, dnsmessage.TXTResource{
159 | TXT: challenges,
160 | })
161 | }
162 | }
163 | }
164 | return dnsmessage.RCodeSuccess
165 | }
166 |
167 | // NXDOMAIN multi-level subdomains
168 | if strings.ContainsRune(name, '.') {
169 | return dnsmessage.RCodeNameError
170 | }
171 |
172 | // finally, IP addresses
173 | ipv4 := getIPv4(name)
174 | ipv6 := getIPv6(name)
175 | if ipv4 != nil || ipv6 != nil {
176 | switch question.Type {
177 | case dnsmessage.TypeA:
178 | if ipv4 != nil {
179 | res := dnsmessage.AResource{}
180 | copy(res.A[:], ipv4)
181 | header.TTL = 7 * 86400 // 7 days
182 |
183 | r.answer = func(b *dnsmessage.Builder) error {
184 | return b.AResource(header, res)
185 | }
186 | }
187 |
188 | case dnsmessage.TypeAAAA:
189 | if ipv6 != nil {
190 | res := dnsmessage.AAAAResource{}
191 | copy(res.AAAA[:], ipv6)
192 | header.TTL = 7 * 86400 // 7 days
193 |
194 | r.answer = func(b *dnsmessage.Builder) error {
195 | return b.AAAAResource(header, res)
196 | }
197 | }
198 | }
199 | return dnsmessage.RCodeSuccess
200 | }
201 |
202 | // NXDOMAIN everything else
203 | return dnsmessage.RCodeNameError
204 | }
205 |
206 | func (r *response) send(conn net.PacketConn, addr net.Addr, buf []byte) error {
207 | builder := dnsmessage.NewBuilder(buf, r.header)
208 | builder.EnableCompression()
209 |
210 | err := r.sendQuestion(&builder)
211 | if err != nil {
212 | return err
213 | }
214 |
215 | err = r.sendAnswer(&builder)
216 | if err != nil {
217 | return err
218 | }
219 |
220 | err = r.sendAuthority(&builder)
221 | if err != nil {
222 | return err
223 | }
224 |
225 | out, err := builder.Finish()
226 | if err != nil {
227 | return err
228 | }
229 |
230 | out = convertTXTtoCAA(out)
231 |
232 | // truncate
233 | if len(out) > 512 {
234 | out = out[:512]
235 | out[2] |= 2
236 | }
237 |
238 | _, err = conn.WriteTo(out, addr)
239 | return err
240 | }
241 |
242 | func (r *response) sendQuestion(builder *dnsmessage.Builder) error {
243 | if r.question.Type == 0 {
244 | return nil
245 | }
246 |
247 | err := builder.StartQuestions()
248 | if err != nil {
249 | return err
250 | }
251 |
252 | return builder.Question(r.question)
253 | }
254 |
255 | func (r *response) sendAnswer(builder *dnsmessage.Builder) error {
256 | if r.answer == nil {
257 | return nil
258 | }
259 |
260 | err := builder.StartAnswers()
261 | if err != nil {
262 | return err
263 | }
264 |
265 | return r.answer(builder)
266 | }
267 |
268 | func (r *response) sendAuthority(builder *dnsmessage.Builder) error {
269 | // send SOA for NOERROR or NXDOMAIN with no answer
270 | if r.header.RCode != dnsmessage.RCodeSuccess && r.header.RCode != dnsmessage.RCodeNameError {
271 | return nil
272 | }
273 | if r.answer != nil {
274 | return nil
275 | }
276 |
277 | err := builder.StartAuthorities()
278 | if err != nil {
279 | return err
280 | }
281 |
282 | return builder.SOAResource(getAuthority(r.question.Name))
283 | }
284 |
285 | // Tailscale assigns an address of 100.64.0.0/10 (IPv4) and fd7a:115c:a1e0:ab12::/64 (IPv6) reserved for CGNAT.
286 | // Reference: https://tailscale.com/kb/1015/100.x-addresses/
287 | func IsTailscale(ip net.IP) bool {
288 | if ip4 := ip.To4(); ip4 != nil {
289 | return ip4[0] == 100 && ip4[1] >= 64 && ip4[1] <= 127
290 | }
291 | return len(ip) == net.IPv6len &&
292 | ip[0] == 0xfd && ip[1] == 0x7a && ip[2] == 0x11 && ip[3] == 0x5c &&
293 | ip[4] == 0xa1 && ip[5] == 0xe0 && ip[6] == 0xab && ip[7] == 0x12
294 | }
295 |
296 | func getIPv4(name string) net.IP {
297 | if name == "my" || name == "local" || name == "localhost" {
298 | return net.IPv4(127, 0, 0, 1).To4()
299 | }
300 |
301 | name = strings.ReplaceAll(name, "-", ".")
302 | ipv4 := net.ParseIP(string(name)).To4()
303 | if ipv4 == nil {
304 | return nil
305 | }
306 |
307 | if config.IsPrivateIPRangesOnly == true {
308 | if !ipv4.IsLoopback() && !ipv4.IsPrivate() && !ipv4.IsLinkLocalUnicast() && !IsTailscale(ipv4) {
309 | return nil
310 | }
311 | }
312 |
313 | return ipv4
314 | }
315 |
316 | func getIPv6(name string) net.IP {
317 | if name == "my" || name == "local" || name == "localhost" {
318 | return net.IPv6loopback
319 | }
320 |
321 | name = strings.ReplaceAll(name, "-", ":")
322 | ipv6 := net.ParseIP(string(name))
323 | if ipv6 == nil {
324 | return nil
325 | }
326 |
327 | if config.IsPrivateIPRangesOnly == true {
328 | if !ipv6.IsLoopback() && !ipv6.IsPrivate() && !ipv6.IsLinkLocalUnicast() && !IsTailscale(ipv6) {
329 | return nil
330 | }
331 | }
332 |
333 | return ipv6
334 | }
335 |
336 | func getAuthority(name dnsmessage.Name) (dnsmessage.ResourceHeader, dnsmessage.SOAResource) {
337 | return dnsmessage.ResourceHeader{
338 | Name: name,
339 | Class: dnsmessage.ClassINET,
340 | TTL: 7 * 86400, // 7 days
341 | }, dnsmessage.SOAResource{
342 | NS: nameserver,
343 | MBox: nameserver,
344 | // https://www.ripe.net/publications/docs/ripe-203
345 | Refresh: 86400,
346 | Retry: 7200,
347 | Expire: 3600000,
348 | MinTTL: 3600,
349 | }
350 | }
351 |
--------------------------------------------------------------------------------
/Readme.md:
--------------------------------------------------------------------------------
1 |
2 | # Akebi
3 |
4 | 💠 **Akebi:** **A** **ke**yless https server, and **b**ackend dns server that resolves **i**p from domain
5 |
6 | > Sorry, the documentation is currently in Japanese only. Google Translate is available.
7 |
8 | インターネットに公開されていないプライベート Web サイトを「正規」の Let’s Encrypt の証明書で HTTPS 化するための、HTTPS リバースプロキシサーバーです。
9 |
10 | この HTTPS リバースプロキシサーバーは、
11 |
12 | - **権威 DNS サーバー:** `192-168-1-11.local.example.com` のようにサブドメインとして IP アドレスを指定すると、そのまま `192.168.1.11` に名前解決するワイルドカード DNS
13 | - **API サーバー:** 事前に Let’s Encrypt で取得した証明書と秘密鍵を保持し、TLS ハンドシェイク時の証明書の供給と、Pre-master Secret Key の生成に使う乱数に秘密鍵でデジタル署名を行う API
14 | - **デーモンプロセス:** Let’s Encrypt で取得した *.local.example.com の HTTPS ワイルドカード証明書と、API サーバーの HTTPS 証明書を定期的に更新するデーモン
15 |
16 | の3つのコンポーネントによって構成される、**Keyless Server** に依存しています。
17 |
18 | 以下、HTTPS リバースプロキシサーバーを **HTTPS Server** 、上記の3つの機能を持つバックエンドサーバーを **Keyless Server** と呼称します。
19 |
20 | Keyless Server のコードの大半と HTTPS Server の TLS ハンドシェイク処理は、[ncruces](https://github.com/ncruces) さん開発の [keyless](https://github.com/ncruces/keyless) をベースに、個人的な用途に合わせてカスタマイズしたものです。
21 | 偉大な発明をしてくださった ncruces さんに、この場で心から深く感謝を申し上げます(私が書いたコードは 20% 程度にすぎません)。
22 |
23 | ## 開発背景
24 |
25 | **Akebi は、オレオレ証明書以外での HTTPS 化が困難なローカル LAN 上でリッスンされるサーバーアプリケーションを、Let's Encrypt 発行の正規の HTTPS 証明書で HTTPS 化するために開発されました。**
26 |
27 | -----
28 |
29 | ローカル LAN やイントラネットなどのプライベートネットワークでリッスンされている Web サーバーは、HTTP でリッスンされていることがほとんどです。
30 |
31 | これは盗聴されるリスクが著しく低く、VPN 経由なら元々暗号化されているなどの理由で HTTPS にする必要がないこと、プライベートネットワークで信頼される HTTPS 証明書の入手が事実上難しいことなどが理由でしょう。HTTP の方が単純で簡単ですし。
32 |
33 | ### ブラウザの HTTPS 化の圧力
34 |
35 | …ところが、最近のブラウザはインターネット上に公開されている Web サイトのみならず、**盗聴のリスクが著しく低いプライベートネットワーク上の Web サイトにも、HTTPS を要求するようになってきました。**
36 |
37 | すでに PWA の主要機能である Service Worker や Web Push API などをはじめ、近年追加された多くの Web API の利用に(中には WebCodecs API のような HTTPS 化を必須にする必要が皆無なものも含めて)**HTTPS が必須になってしまっています。**
38 |
39 | > [!NOTE]
40 | > 正確には **[安全なコンテキスト (Secure Contexts)](https://developer.mozilla.org/ja/docs/Web/Security/Secure_Contexts)** でないと動作しないようになっていて、特別に localhost (127.0.0.1) だけは http:// でも安全なコンテキストだと認められるようになっています。
41 |
42 | プライベート Web サイトであっても、たとえばビデオチャットのために [getUserMedia()](https://developer.mozilla.org/ja/docs/Web/API/MediaDevices/getUserMedia) を、クリップボードにコピーするために [Clipboard API](https://developer.mozilla.org/ja/docs/Web/API/Clipboard_API) を使いたい要件が出てくることもあるでしょう(どちらも Secure Contexts が必須です)。
43 |
44 | - せっかくコードは Service Worker に対応しているのに、HTTP では Service Worker が動かないのでキャッシュが効かず、読み込みがたびたび遅くなる
45 | - PWA で Android のホーム画面にインストールしてもアイコンが Chrome 扱いになるし、フォームに入力すると上部に「保護されていない通信」というバナーが表示されてうざい
46 | - Clipboard API・Storage API・SharedArrayBuffer などの強力な API が Secure Contexts でないと使えず、今後の機能開発が大きく制約される
47 |
48 | 私が開発している [KonomiTV](https://github.com/tsukumijima/KonomiTV) でも、上記のような課題を抱えていました。
49 |
50 | しかも、最近新たに追加された API はその性質に関わらず問答無用で [Secure Contexts が必須になっている](https://developer.mozilla.org/ja/docs/Web/Security/Secure_Contexts/features_restricted_to_secure_contexts) ことが多く、リッチなプライベート Web サイトの開発はかなりやりづらくなってきています。
51 |
52 | さらに、Chrome 94 から適用された [Private Network Access](https://developer.chrome.com/blog/private-network-access-update/) という仕様のおかげで、**HTTP の公開 Web サイトからプライベート Web サイトにアクセスできなくなりました。** CORS ヘッダーで明示的に許可していても、です。
53 |
54 | 以前より HTTPS の公開 Web サイトから HTTP のプライベート Web サイトへのアクセスは、Mixed Content として禁止されています (localhost を除く) 。そのため、公開 Web サイトも HTTP (Public (HTTP) -> Private (HTTP)) の構成にせざるを得なかったのですが、それすらも禁止されてしまいました。
55 |
56 | こうした変更は、公開 Web サイトからローカル LAN 上にあるデバイスを操作する類のアプリケーションにとって、かなり厳しい制約になります。
57 |
58 | > [!NOTE]
59 | > Chrome 105 以降では、Public (HTTPS) -> Private (HTTPS) のアクセスには、さらにプライベート Web サイト側のレスポンスに `Access-Control-Allow-Private-Network` ヘッダーを付与する必要があるようです ([参考](https://developer.chrome.com/blog/private-network-access-preflight/))。
60 | > Chrome 105 以降も公開 Web サイトからプライベート Web サイトにアクセスするには両方の HTTPS 化が必須で、加えて Preflight リクエストが飛んできたときに `Access-Control-Allow-Private-Network: true` を返せる必要が出てきます。
61 |
62 | ### プライベート Web サイトの証明書取得の困難さ
63 |
64 | 一般的な公開 Web サイトなら、Let's Encrypt を使うことで無料で簡単に HTTPS 化できます。無料で HTTPS 証明書を取れるようになったこともあり、ブラウザによる HTTPS 化の圧力は年々強まっています。
65 |
66 | しかし、プライベート Web サイトの場合、**正攻法での HTTPS 化は困難を極めます。**
67 | 当然インターネット上からは Web サーバーにアクセスできないため、Let's Encrypt の HTTP-01 チャレンジが通りません。
68 | …それ以前に Let's Encrypt は元々 IP アドレス宛には証明書を発行できませんし、グローバル IP ならまだしも、世界各地で山ほど被りまくっているプライベート IP の所有権を主張するのには無理があります。
69 |
70 | そこでよく利用されるのが、**自己署名証明書(オレオレ証明書)を使った HTTPS 化**です。
71 |
72 | 自分で HTTPS 証明書を作ってしまう方法で、プライベート IP アドレスだろうが関係なく、自由に証明書を作成できます。
73 | 最近では [mkcert](https://github.com/FiloSottile/mkcert) のような、オレオレ証明書をかんたんに生成するツールも出てきています。
74 |
75 | 自分で作った証明書なので当然ブラウザには信頼されず、そのままではアクセスすると警告が表示されてしまいます。
76 | ブラウザに証明書を信頼させ「この接続ではプライバシーが保護されません」の警告をなくすには、**生成したオレオレ証明書を OS の証明書ストアに「信頼されたルート証明機関」としてインストールする必要があります。**
77 |
78 | mkcert はそのあたりも自動化してくれますが、それはあくまで開発時の話。
79 | mkcert をインストールした PC 以外のデバイスには手動でインストールしないといけませんし、インストール方法もわりと面倒です。開発者ならともかく、一般ユーザーには難易度が高い作業だと思います。
80 | しかも、プライベート Web サイトを閲覧するデバイスすべてにインストールしなければならず、閲覧デバイスが多ければ多いほど大変です。
81 |
82 | …こうした背景から、**一般ユーザーに配布するアプリケーションでは、事実上オレオレ証明書は使えない状態です。**
83 | もちろんユーザー体験を犠牲にすれば使えなくはありませんが、より多くの方に簡単に使っていただくためにも、できるだけそうした状態は避けたいです。
84 |
85 | ### Let's Encrypt の DNS 認証 + ワイルドカード DNS という選択肢
86 |
87 | 閑話休題。オレオレ証明書に押されてあまり知られていないのですが、**実はプライベート Web サイトでも、Let's Encrypt の DNS 認証 (DNS-01 チャレンジ) を使えば、正規の HTTPS 証明書を取ることができます。**
88 | 詳細は [この記事](https://blog.jxck.io/entries/2020-06-29/https-for-localhost.html) が詳しいですが、軽く説明します。
89 |
90 | 通常、DNS 上の A レコードにはグローバル IP アドレスを指定します。ですが、とくにグローバル IP アドレスでないといけない制約があるわけではありません。`127.0.0.1` や `192.168.1.1` を入れることだって可能です。
91 |
92 | たとえば、`local.example.com` の A レコードを `127.0.0.1` に設定したとします。もちろんループバックアドレスなのでインターネット上からはアクセスできませんし、Let's Encrypt の HTTP 認証は通りません。
93 |
94 | そこで、**Let's Encrypt の DNS 認証 (DNS-01 チャレンジ) で HTTPS 証明書を取得します。**
95 | DNS 認証は、例でいう `local.example.com` の DNS を変更できる権限(≒ドメインの所有権)を証明することで、HTTPS 証明書を取得する方法です。
96 | DNS 認証ならインターネットからアクセスできる必要はなく、**DNS 認証時に `_acme-challenge.local.example.com` の TXT レコードにトークンを設定できれば、あっさり HTTPS 証明書が取得できます。**
97 |
98 | ……一見万事解決のように見えます。が、この方法はイントラネット上のサイトなどでプライベート IP アドレスが固定されている場合にはぴったりですが、**不特定多数の環境にインストールされるプライベート Web サイトでは、インストールされる PC のプライベート IP アドレスが環境ごとにバラバラなため、そのままでは使えません。**
99 |
100 | **そこで登場するのがワイルドカード DNS サービスです。**[nip.io](https://nip.io/) や [sslip.io](https://sslip.io/) がよく知られています。
101 | これらは **`http://192-168-1-11.sslip.io` のようなサブドメインを `192.168.1.11` に名前解決してくれる特殊な DNS サーバー**で、sslip.io の方は自分が保有するドメインをワイルドカード DNS サーバーにすることもできます。
102 |
103 | また、**実は Let's Encrypt ではワイルドカード証明書を取得できます。** ドメインの所有権を証明できれば、`hoge.local.example.com`・`fuga.local.example.com`・`piyo.local.example.com` いずれでも使える証明書を発行できます。
104 |
105 | このワイルドカード DNS サービスと取得したワイルドカード証明書を組み合わせれば、**`http://192.168.1.11:3000/` の代わりに `https://192-168-1-11.local.example.com:3000/` にアクセスするだけで、魔法のように正規の証明書でリッスンされるプライベート HTTPS サイトができあがります!**
106 |
107 | > [!NOTE]
108 | > 『ワイルドカード DNS と Let's Encrypt のワイルドカード証明書を組み合わせてローカル LAN で HTTPS サーバーを実現する』というアイデアは、[Corollarium](https://github.com/Corollarium) 社開発の [localtls](https://github.com/Corollarium/localtls) から得たものです。
109 |
110 | ### 証明書と秘密鍵の扱い
111 |
112 | 経緯の説明がたいへん長くなってしまいましたが、ここからが本番です。
113 |
114 | 上記の手順を踏むことで、プライベート Web サイトでも HTTPS 化できる道筋はつきました。
115 | ですが、不特定多数の環境にインストールされるプライベート Web サイト(そう多くはないが、著名な例だと Plex Media Server などの一般ユーザーに配布されるアプリケーションが該当する)では、**HTTPS 証明書・秘密鍵の扱いをどうするかが問題になります。**
116 |
117 | アプリケーション自体を配布しなければならないので、当然証明書と秘密鍵もアプリケーションに同梱しなければなりません。ですが、このうち秘密鍵が漏洩すると、別のアプリケーションがなりすましできたり、通信を盗聴できたりしてしまいます(中間者攻撃)。
118 |
119 | もっとも今回はブラウザへの建前として形式上 HTTPS にしたいだけなのでその点は正直どうでもいいのですが、それよりも **「証明書と秘密鍵があれば誰でも HTTPS 証明書を失効できてしまう」「秘密鍵の公開は Let's Encrypt の利用規約で禁止されている」点が厄介です。**
120 |
121 | アプリケーションの内部に秘密鍵を隠すこともできますが、所詮は DRM のようなもので抜本的とはいえないほか、OSS の場合は隠すこと自体が難しくなります。
122 | また、Let's Encrypt 発行の HTTPS 証明書は3ヶ月で有効期限が切れるため、各環境にある証明書・秘密鍵をどうアップデートするかも問題になります。
123 |
124 | **この「秘密鍵の扱いをどうするか」問題を、TLS ハンドシェイクの内部処理をハックし秘密鍵をリモートサーバーに隠蔽することで解決させた点が、Akebi HTTPS Server の最大の特徴です。**
125 |
126 | > [!NOTE]
127 | > 証明書も TLS ハンドシェイク毎に Keyless Server からダウンロードするため、保存した証明書の更新に悩む必要がありません。
128 |
129 | 秘密鍵をリモートサーバーに隠蔽するためには、TLS ハンドシェイク上で秘密鍵を使う処理を、サーバー上で代わりに行う API サーバーが必要になります。
130 | **どのみち API サーバーが要るなら、sslip.io スタイルのワイルドカード DNS と Let's Encrypt の証明書自動更新までまとめてやってくれる方が良いよね?ということで開発されたのが、[ncruces](https://github.com/ncruces) さん開発の [keyless](https://github.com/ncruces/keyless) です。**
131 |
132 | 私がこの keyless をもとに若干改良したものが Akebi Keyless Server で、Akebi HTTPS Server とペアで1つのシステムを構成しています。
133 |
134 | > [!NOTE]
135 | > HTTPS リバースプロキシの形になっているのは、**HTTPS 化対象のアプリケーションがどんな言語で書かれていようと HTTP サーバーのリバースプロキシとして挟むだけで HTTPS 化できる汎用性の高さ**と、**そもそも TLS ハンドシェイクの深い部分の処理に介入できるのが Golang くらいしかなかった**のが理由です。
136 | > 詳細は [HTTPS リバースプロキシというアプローチ](#https-リバースプロキシというアプローチ) の項目で説明しています。
137 |
138 | ## 導入
139 |
140 | ### 必要なもの
141 |
142 | - Linux サーバー (VM・VPS)
143 | - Keyless Server を動かすために必要です。
144 | - Keyless Server は UDP 53 ポート (DNS) と TCP 443 ポート (HTTPS) を使用します。
145 | - それぞれ外部ネットワークからアクセスできるようにファイアウォールを設定してください。
146 | - Keyless Server がダウンしてしまうと、その Keyless Server に依存する HTTPS Server も起動できなくなります。安定稼働のためにも、Keyless Server は他のサイトと同居させないことをおすすめします。
147 | - サーバーは低スペックなものでも大丈夫です。私は [Oracle Cloud Free Tier](https://www.oracle.com/jp/cloud/free/) の AMD インスタンスで動かしています。
148 | - Ubuntu 20.04 LTS で動作を確認しています。
149 | - 自分が所有するドメイン
150 | - Keyless Server のワイルドカード DNS 機能と、API サーバーのドメインに利用します。
151 | - ワイルドカード DNS 機能用のドメインは、たとえば `example.net` を所有している場合、`local.example.net` や `ip.example.net` などのサブドメインにすると良いでしょう。
152 | - IP → ドメインのための専用のドメインを用意できるなら、必ずしもサブドメインである必要はありません。
153 | - この例の場合、`192-168-1-11.local.example.net` が 192.168.1.11 に名前解決されるようになります。
154 | - もちろん、所有しているドメインの DNS 設定を変更できることが前提です。
155 |
156 | ### Keyless Server のセットアップ
157 |
158 | 以下は Ubuntu 20.04 LTS でのインストール手順です。
159 |
160 | #### Golang のインストール
161 |
162 | Go 1.18 で開発しています。
163 |
164 | ```bash
165 | $ sudo add-apt-repository ppa:longsleep/golang-backports
166 | $ sudo apt install golang
167 | ```
168 |
169 | #### systemd-resolved を止める
170 |
171 | ワイルドカード DNS サーバーを動かすのに必要です(53番ポートがバッティングするため)。
172 | 他にもっとスマートな回避策があるかもしれないので、参考程度に…。
173 |
174 | ```bash
175 | $ sudo systemctl disable systemd-resolved
176 | $ sudo systemctl stop systemd-resolved
177 | $ sudo mv /etc/resolv.conf /etc/resolv.conf.old # オリジナルの resolv.conf をバックアップ
178 | $ sudo nano /etc/resolv.conf
179 | ---------------------------------------------
180 | nameserver 1.1.1.1 1.0.0.1 # ← nameserver を 127.0.0.53 から変更する
181 | (以下略)
182 | ---------------------------------------------
183 | ```
184 |
185 | #### DNS 設定の変更
186 |
187 | ここからは、Keyless Server を立てたサーバーに割り当てるドメインを **`akebi.example.com`** 、ワイルドカード DNS で使うドメインを **`local.example.com`** として説明します。
188 |
189 | `example.com` の DNS 設定で、`akebi.example.com` の A レコードに、Keyless Server を立てたサーバーの IP アドレスを設定します。IPv6 用の AAAA レコードを設定してもいいでしょう。
190 |
191 | 次に、`local.example.com` の NS レコードに、ネームサーバー(DNSサーバー)として `akebi.example.com` を指定します。
192 | この設定により、`192-168-1-11.local.example.com` を `192.168.1.11` に名前解決するために、`akebi.example.com` の DNS サーバー (UDP 53 番ポート) に DNS クエリが飛ぶようになります。
193 |
194 | #### インストール
195 |
196 | ```bash
197 | $ sudo apt install make # make が必要
198 | $ git clone git@github.com:tsukumijima/Akebi.git
199 | $ cd Akebi
200 | $ make build-keyless-server # Keyless Server をビルド
201 | $ cp ./example/akebi-keyless-server.json ./akebi-keyless-server.json # 設定ファイルをコピー
202 | ```
203 |
204 | `akebi-keyless-server.json` が設定ファイルです。JSONC (JSON with comments) で書かれています。
205 | 実際に変更が必要な設定は4つだけです。
206 |
207 | - `domain`: ワイルドカード DNS で使うドメイン(この例では `local.example.com`)を設定します。
208 | - `nameserver`: `local.example.com` の NS レコードに設定したネームサーバー(この例では `akebi.example.com`)を設定します。
209 | - `is_private_ip_ranges_only`: ワイルドカード DNS の名前解決範囲をプライベート IP アドレスに限定するかを設定します。
210 | - この設定が true のとき、たとえば `192-168-1-11.local.example.com` や `10-8-0-1.local.example.com` は名前解決されますが、`142-251-42-163.local.example.com` は名前解決されず、ドメインが存在しない扱いになります。
211 | - プライベート IP アドレスの範囲には [Tailscale](https://tailscale.com/) の IP アドレス (100.64.0.0/10, fd7a:115c:a1e0:ab12::/64) も含まれます。
212 | - グローバル IP に解決できてしまうと万が一フィッシングサイトに使われないとも限らない上、用途上グローバル IP に解決できる必要性がないため、個人的には true にしておくことをおすすめします。
213 | - `keyless_api.handler`: Keyless API サーバーの URL(https:// のような URL スキームは除外する)を設定します。
214 | - `akebi.example.com/` のように指定します。末尾のスラッシュは必須です。
215 |
216 | #### セットアップ
217 |
218 | ```bash
219 | $ sudo ./akebi-keyless-server setup
220 | ```
221 |
222 | セットアップスクリプトを実行します。
223 | セットアップ途中で DNS サーバーと HTTP サーバーを起動しますが、1024 番未満のポートでのリッスンには root 権限が必要なため、sudo をつけて実行します。
224 |
225 | ```
226 | Running setup...
227 |
228 | Creating a new Let's Encrypt account...
229 | Creating a new account private key...
230 |
231 | Accept Let's Encrypt ToS? [y/n]: y
232 | Use the Let's Encrypt production API? [y/n]: y
233 | Enter an email address: yourmailaddress@example.com
234 |
235 | Creating a new master private key...
236 |
237 | Starting DNS server for domain validation...
238 | Please, ensure that:
239 | - NS records for local.example.com point to akebi.example.com
240 | - akebi-keyless-server is reachable from the internet on UDP akebi.example.com:53
241 | Continue? y
242 |
243 | Obtaining a certificate for *.local.example.com...
244 | Creating a new Keyless API private key...
245 |
246 | Starting HTTPS server for hostname validation...
247 | Please, ensure that:
248 | - akebi-keyless-server is reachable from the internet on TCP akebi.example.com:443
249 | Continue?
250 | Obtaining a certificate for akebi.example.com...
251 |
252 | Done!
253 | ```
254 |
255 | ```bash
256 | $ sudo chown -R $USER:$USER ./
257 | ```
258 |
259 | 終わったら、root 権限で作られたファイル類の所有者を、ログイン中の一般ユーザーに設定しておきましょう。
260 | **これで Keyless Server を起動できる状態になりました!**
261 |
262 | certificates/ フォルダには、Let's Encrypt から取得した HTTPS ワイルドカード証明書/秘密鍵と、API サーバーの HTTPS 証明書/秘密鍵が格納されています。
263 | letsencrypt/ フォルダには、Let's Encrypt のアカウント情報が格納されています。
264 |
265 | #### Systemd サービスの設定
266 |
267 | Keyless Server は Systemd サービスとして動作します。
268 | Systemd に Keyless Server サービスをインストールし、有効化します。
269 |
270 | ```bash
271 | # サービスファイルをコピー
272 | $ sudo cp ./example/akebi-keyless-server.service /etc/systemd/system/akebi-keyless-server.service
273 |
274 | # /home/ubuntu/Akebi の部分を Akebi を配置したディレクトリのパスに変更する
275 | $ sudo nano /etc/systemd/system/akebi-keyless-server.service
276 |
277 | # ソケットファイルをコピー
278 | $ sudo cp ./example/akebi-keyless-server.socket /etc/systemd/system/akebi-keyless-server.socket
279 |
280 | # サービスを有効化
281 | $ sudo systemctl daemon-reload
282 | $ sudo systemctl enable akebi-keyless-server.service
283 | $ sudo systemctl enable akebi-keyless-server.socket
284 |
285 | # サービスを起動
286 | # akebi-keyless-server.socket は自動で起動される
287 | $ sudo systemctl start akebi-keyless-server.service
288 | ```
289 |
290 | **`https://akebi.example.com` にアクセスして 404 ページが表示されれば、Keyless Server のセットアップは完了です!** お疲れ様でした。
291 |
292 | **Keyless Server が起動している間、Let's Encrypt から取得した HTTPS 証明書は自動的に更新されます。** 一度セットアップすれば、基本的にメンテナンスフリーで動作します。
293 |
294 | ```
295 | ● akebi-keyless-server.service - Akebi Keyless Server Service
296 | Loaded: loaded (/etc/systemd/system/akebi-keyless-server.service; enabled; vendor preset: enabled)
297 | Active: active (running) since Sat 2022-05-21 07:31:34 UTC; 2h 59min ago
298 | TriggeredBy: ● akebi-keyless-server.socket
299 | Main PID: 767 (akebi-keyless-s)
300 | Tasks: 7 (limit: 1112)
301 | Memory: 7.8M
302 | CGroup: /system.slice/akebi-keyless-server.service
303 | └─767 /home/ubuntu/Akebi/akebi-keyless-server
304 | ```
305 |
306 | `systemctl status akebi-keyless-server.service` がこのようになっていれば、正しく Keyless Server を起動できています。
307 |
308 | ```
309 | $ sudo systemctl stop akebi-keyless-server.service
310 | $ sudo systemctl stop akebi-keyless-server.socket
311 | ```
312 |
313 | Keyless Server サービスを終了したい際は、以上のコマンドを実行してください。
314 |
315 | ### HTTPS Server のセットアップ
316 |
317 | #### ビルド
318 |
319 | HTTPS Server のビルドには、Go 1.18 と make がインストールされている環境が必要です。ここではすでにインストールされているものとして説明します。
320 |
321 | > [!NOTE]
322 | > Windows 版の make は [こちら](http://gnuwin32.sourceforge.net/packages/make.htm) からインストールできます。
323 | > 2006 年から更新されていませんが、Windows 10 でも普通に動作します。それだけ完成されたアプリケーションなのでしょう。
324 |
325 | ```bash
326 | $ git clone git@github.com:tsukumijima/Akebi.git
327 | $ cd Akebi
328 |
329 | # 現在のプラットフォーム向けにビルド
330 | $ make build-https-server
331 |
332 | # すべてのプラットフォーム向けにビルド
333 | # Windows (64bit), Linux (x64), Linux (arm64) 向けの実行ファイルを一度にクロスコンパイルする
334 | $ make build-https-server-all-platforms
335 | ```
336 |
337 | - Windows: `akebi-keyless-server.exe`
338 | - Linux (x64): `akebi-keyless-server` (拡張子なし)
339 | - Linux (arm64): `akebi-keyless-server-arm` (拡張子なし)
340 |
341 | ビルドされた実行ファイルは、それぞれ Makefile と同じフォルダに出力されます。
342 | 出力されるファイル名は上記の通りです。適宜リネームしても構いません。
343 |
344 | #### HTTPS Server の設定
345 |
346 | HTTPS Server は、設定を実行ファイルと同じフォルダにある `akebi-keyless-server.json` から読み込みます。Keyless Server 同様、JSONC (JSON with comments) で書かれています。
347 |
348 | 設定はコマンドライン引数からも行えます。引数はそれぞれ設定ファイルの項目に対応しています。
349 | 設定ファイルが配置されているときにコマンドライン引数を指定した場合は、コマンドライン引数の方の設定が優先されます。
350 |
351 | - `listen_address`: HTTPS リバースプロキシをリッスンするアドレスを指定します。
352 | - コマンドライン引数では `--listen-address` に対応します。
353 | - 基本的には `0.0.0.0:(ポート番号)` のようにしておけば OK です。
354 | - `proxy_pass_url`: リバースプロキシする HTTP サーバーの URL を指定します。
355 | - コマンドライン引数では `--proxy-pass-url` に対応します。
356 | - `keyless_server_url`: Keyless Server の URL を指定します。
357 | - コマンドライン引数では `--keyless-server-url` に対応します。
358 | - `custom_certificate`: Keyless Server を使わず、カスタムの HTTPS 証明書/秘密鍵を使う場合に設定します。
359 | - コマンドライン引数では `--custom-certificate` `--custom-private-key` に対応します。
360 | - 普通に HTTPS でリッスンするのと変わりませんが、Keyless Server を使うときと HTTPS サーバーを共通化できること、HTTP/2 に対応できることがメリットです。
361 |
362 | #### HTTPS リバースプロキシの起動
363 |
364 | HTTPS Server は実行ファイル単体で動作します。
365 | `akebi-keyless-server.json` を実行ファイルと同じフォルダに配置しない場合は、実行時にコマンドライン引数を指定する必要があります。
366 |
367 | ```bash
368 | $ ./akebi-https-server
369 | 2022/05/22 03:49:36 Info: Starting HTTPS reverse proxy server...
370 | 2022/05/22 03:49:36 Info: Listening on 0.0.0.0:3000, Proxing http://your-http-server-url:8080/.
371 | ```
372 |
373 | **この状態で https://local.local.example.com:3000/ にアクセスしてプロキシ元のサイトが表示されれば、正しく HTTPS 化できています!!**
374 |
375 | もちろん、たとえば PC のローカル IP が 192.168.1.11 なら、https://192-168-1-11.local.example.com:3000/ でもアクセスできるはずです。
376 |
377 | HTTPS Server は Ctrl + C で終了できます。
378 | 設定内容にエラーがあるときはログが表示されるので、それを確認してみてください。
379 |
380 | > [!NOTE]
381 | > ドメインの本来 IP アドレスを入れる部分に **`my` / `local` / `localhost` と入れると、特別に 127.0.0.1(ループバックアドレス)に名前解決されるように設定しています。**
382 | `127-0-0-1.local.example.com` よりもわかりやすいと思います。ローカルで開発する際にお使いください。
383 |
384 | **HTTPS Server は HTTP/2 に対応しています。** HTTP/2 は HTTPS でしか使えませんが、サイトを HTTPS 化することで、同時に HTTP/2 に対応できます。
385 |
386 | > [!NOTE]
387 | > どちらかと言えば、Golang の標準 HTTP サーバー ([http.Server](https://pkg.go.dev/net/http#Server)) が何も設定しなくても HTTP/2 に標準対応していることによるものです。
388 |
389 | カスタムの証明書/秘密鍵を指定できるのも、Keyless Server を使わずに各自用意した証明書で HTTPS 化するケースと実装を共通化できるのもありますが、**HTTPS Server を間に挟むだけでかんたんに HTTP/2 に対応できる**のが大きいです。
390 |
391 | [Uvicorn](https://github.com/encode/uvicorn) など、HTTP/2 に対応していないアプリケーションサーバーはそれなりにあります。本来は NGINX などを挟むべきでしょうけど、一般ユーザーに配布するアプリケーションでは、簡易な HTTP サーバーにせざるを得ないことも多々あります。
392 | そうした場合でも、**アプリケーション本体の実装に手を加えることなく、アプリケーション本体の起動と同時に HTTPS Server を起動するだけで、HTTPS 化と HTTP/2 対応を同時に行えます。**
393 |
394 | ```bash
395 | $ ./akebi-https-server --listen-address 0.0.0.0:8080 --proxy-pass-url http://192.168.1.11:8000
396 | 2022/05/22 03:56:50 Info: Starting HTTPS reverse proxy server...
397 | 2022/05/22 03:56:50 Info: Listening on 0.0.0.0:8080, Proxing http://192.168.1.11:8000.
398 | ```
399 |
400 | `--listen-address` や `--proxy-pass-url` オプションを指定して、リッスンポートやプロキシ対象の HTTP サーバーの URL を上書きできます。
401 |
402 | ```bash
403 | $ ./akebi-https-server -h
404 | Usage of C:\Develop\Akebi\akebi-https-server.exe:
405 | -custom-certificate string
406 | Optional: Use your own HTTPS certificate instead of Akebi Keyless Server.
407 | -custom-private-key string
408 | Optional: Use your own HTTPS private key instead of Akebi Keyless Server.
409 | -keyless-server-url string
410 | URL of HTTP server to reverse proxy.
411 | -listen-address string
412 | Address that HTTPS server listens on.
413 | Specify 0.0.0.0:port to listen on all interfaces.
414 | -mtls-client-certificate string
415 | Optional: Client certificate of mTLS for akebi.example.com (Keyless API).
416 | -mtls-client-certificate-key string
417 | Optional: Client private key of mTLS for akebi.example.com (Keyless API).
418 | -proxy-pass-url string
419 | URL of HTTP server to reverse proxy.
420 | ```
421 |
422 | `-h` オプションでヘルプが表示されます。
423 |
424 | ## 技術解説と注意
425 |
426 | ### Keyless の仕組み
427 |
428 | 
429 |
430 | **秘密鍵をユーザーに公開せずに正規の HTTPS サーバーを立てられる**というトリックには(”Keyless” の由来)、Cloudflare の [Keyless SSL](https://blog.cloudflare.com/announcing-keyless-ssl-all-the-benefits-of-cloudflare-without-having-to-turn-over-your-private-ssl-keys/) と同様の手法が用いられています。
431 |
432 | サイトを Cloudflare にキャッシュさせる場合、通常は Cloudflare 発行の証明書を利用できます。一方、企業によっては、EV 証明書を使いたいなどの理由でカスタム証明書を使うケースがあるようです。
433 | Cloudflare の仕組み上、カスタム証明書を利用する際は、その証明書と秘密鍵を Cloudflare に預ける必要があります。Keyless SSL は、Cloudflare でカスタム証明書を使いたいが、コンプライアンス上の理由でカスタム証明書の秘密鍵を社外に預けられない企業に向けたサービスです。
434 |
435 | Keyless SSL では、秘密鍵を社外に出せない企業側が「Key Server」をホストします。Key Server は、**TLS ハンドシェイクのフローのうち、秘密鍵を必要とする処理を Cloudflare の Web サーバーに代わって行う** API サーバーです。
436 |
437 | 具体的には、鍵交換アルゴリズムが RSA 法のときは、(ブラウザから送られてきた)公開鍵で暗号化された Premaster Secret を秘密鍵で復号し、それを Cloudflare のサーバーに返します。
438 | 鍵交換アルゴリズムが DHE (Diffie-Hellman) 法のときはもう少し複雑で、Client Random・Server Random・Server DH Parameter をハッシュ化したものに秘密鍵でデジタル署名を行い、それを Cloudflare のサーバーに返します。
439 | 複雑で難解なこともあり私も正しく説明できているか自信がないので、詳細は [公式の解説記事](https://blog.cloudflare.com/keyless-ssl-the-nitty-gritty-technical-details/) に譲ります…。
440 |
441 | -----
442 |
443 | この Keyless SSL の **「秘密鍵がなくても、証明書と Key Server さえあれば HTTPS 化できる」** という特徴を、同じく秘密鍵を公開できない今回のユースケースに適用したものが、ncruces 氏が開発された [keyless](https://github.com/ncruces/keyless) です。
444 |
445 | > [!NOTE]
446 | > 前述しましたが、Akebi Keyless Server は keyless のサーバー部分のコードのフォークです。
447 |
448 | **Keyless SSL の「Key Server」に相当するものが、Keyless Server がリッスンしている API サーバーです。**(以下、Keyless API と呼称)
449 | `/certificate` エンドポイントは、Keyless Server が保管しているワイルドカード証明書をそのまま返します。
450 | `/sign` エンドポイントは、HTTPS Server からワイルドカード証明書の SHA-256 ハッシュとClient Random・Server Random・Server DH Parameter のハッシュを送り、送られた証明書のハッシュに紐づく秘密鍵で署名された、デジタル署名を返します。
451 |
452 | keyless の作者の [ncruces 氏によれば](https://github.com/cunnie/sslip.io/issues/6#issuecomment-778914231)、Keyless SSL と異なり、「問題を単純化するため」鍵交換アルゴリズムは DHE 法 (ECDHE)、公開鍵/秘密鍵は ECDSA 鍵のみに対応しているとのこと。
453 | Keyless Server のセットアップで生成された秘密鍵のサイズが小さいのはそのためです(ECDSA は RSA よりも鍵長が短い特徴があります)。
454 |
455 | > [!NOTE]
456 | > 図だけを見れば RSA 鍵交換アルゴリズムの方が単純に見えますが、ECDHE with ECDSA の方が新しく安全で速いそうなので、それを加味して選定したのかもしれません。
457 |
458 | Keyless SSL とは手法こそ同様ですが、**Key Server との通信プロトコルは異なるため(keyless では大幅に簡略化されている)、Keyless SSL と互換性があるわけではありません。**
459 |
460 | ### 中間者攻撃のリスクと mTLS (TLS相互認証)
461 |
462 | この手法は非常に優れていますが、**中間者攻撃 (MitM) のリスクは残ります。**
463 | 証明書と秘密鍵がそのまま公開されている状態と比較すれば、攻撃の難易度は高くなるでしょう。とはいえ、Keyless API にはどこからでもアクセスできるため、やろうと思えば中間者攻撃できてしまうかもしれません(セキュリティエンジニアではないので詳しいことはわからない…)。
464 |
465 | そこで、ncruces 氏は Keyless API を [mTLS (TLS相互認証)](https://e-words.jp/w/mTLS.html) で保護し、**正しいクライアント証明書/秘密鍵を持っている Keyless API クライアントのみ Keyless API にアクセスできるようにする**ことを提案しています。
466 |
467 | 正しいクライアント証明書/秘密鍵がなければ Keyless API にアクセスできないため、中間者攻撃のリスクを減らせます。
468 | とはいえ、**クライアント証明書/秘密鍵が盗まれてしまっては意味がありません。** ncruces 氏自身も[「最終的には、難読化や DRM のような方法になります」](https://github.com/cunnie/sslip.io/issues/6#issuecomment-778914231)とコメントしています。
469 |
470 | なお、私のユースケースでは **『ローカル LAN 上のサイトをブラウザに形式上 HTTPS と認識させられれば正直中間者攻撃のリスクはどうでもいい』** というものだったため、mTLS は利用していません。
471 |
472 | > だいたい、もし通信内容を中間者攻撃されるようなローカル LAN があるのなら、そのネットワークはいろいろな意味で終わってると思う…。
473 |
474 | …とは言ったものの、一応 Akebi でも mTLS に対応しています。正確には keyless で対応されていたので HTTPS Server でも使えるようにした程度のものですが…。
475 |
476 | ```bash
477 | openssl req -newkey rsa:2048 -nodes -x509 -days 365 -out client_ca_cert.pem -keyout client_ca_private_key.pem
478 | openssl genrsa -out client_private_key.pem 2048
479 | openssl req -new -key client_private_key.pem -days 365 -out client_cert.csr
480 | openssl x509 -req -in client_cert.csr -CA client_ca_cert.pem -CAkey client_ca_private_key.pem -out client_cert.pem -days 365 -sha256 -CAcreateserial
481 | rm client_ca_cert.srl
482 | rm client_cert.csr
483 | ```
484 |
485 | mTLS のクライアントCA証明書とクライアント証明書を作成するには、上記のコマンドを実行します。
486 |
487 | `client_ca_cert.pem`・`client_ca_private_key.pem` がクライアント CA 証明書/秘密鍵、`client_cert.pem`・`client_private_key.pem` がクライアント証明書/秘密鍵です。
488 |
489 | Keyless Server の設定では、`keyless_api.client_ca` に mTLS のクライアント CA 証明書 (`client_ca_cert.pem`) へのパスを指定します。
490 | 設定の反映には Keyless Server サービスの再起動が必要です。
491 |
492 | HTTPS Server の設定では、`mtls.client_certificate`・`mtls.client_certificate_key` に mTLS のクライアント証明書/秘密鍵 (`client_cert.pem`・`client_private_key.pem`) へのパスを指定します。
493 |
494 | **この状態で HTTPS Server がリッスンしているサイトにアクセスできれば、mTLS を有効化できています。**
495 | Keyless Server にクライアント CA 証明書を設定したまま HTTPS Server の mTLS 周りの設定を外すと、Keyless API にアクセスできなくなっているはずです。
496 |
497 | ### HTTPS リバースプロキシというアプローチ
498 |
499 | Akebi では、Keyless Server を使い HTTPS 化するためのアプローチとして、HTTPS サーバーを背後の HTTP サーバーのリバースプロキシとして立てる、という方法を採用しています。
500 |
501 | 一方、フォーク元の keyless は、Golang で書かれた Web サーバーの TLS 設定に、Keyless のクライアントライブラリの関数 ([GetCertificate()](https://github.com/ncruces/keyless/blob/main/keyless.go#L21)) をセットすることで、「直接」HTTPS 化するユースケースを想定して書かれています。
502 |
503 | このアプローチは、確かにアプリケーションサーバーが Golang で書かれているケースではぴったりな一方で、**アプリケーションサーバーが Golang 以外の言語で書かれている場合は使えません。**
504 | とはいえ、他の言語で書かれたアプリケーションサーバーを、HTTPS 化するためだけに Golang で書き直すのは非現実的です。それぞれの言語の利点もありますし。
505 |
506 | -----
507 |
508 | そうなると、一見 keyless のクライアントライブラリを Python や Node.js など、ほかの言語に移植すれば良いように見えます。ところが、**ほとんどの言語において、ライブラリの移植は不可能なことがわかりました。**
509 |
510 | 実際に keyless クライアントに相当する実装を Python に移植できないか試したのですが、実は **Python は TLS 周りの実装を OpenSSL に丸投げしています。** 標準モジュールの `ssl` も、その実態は OpenSSL のネイティブライブラリのラッパーにすぎません。
511 | さらに、`ssl` モジュールでは、**TLS ハンドシェイクを行う処理が [`SSLContext.do_handshake()`](https://docs.python.org/ja/3.10/library/ssl.html#ssl.SSLSocket.do_handshake) の中に隠蔽されているため、TLS ハンドシェイクの内部処理に介入できないことが分かりました。**
512 | Golang では TLS ハンドシェイクの細かい設定を行う [struct](https://pkg.go.dev/crypto/tls#Config) が用意されていますが、Python ではそれに相当する API を見つけられませんでした。おそらくないんだと思います…。
513 |
514 | Node.js の [TLS](https://nodejs.org/api/tls.htm) ライブラリも軽く調べてみましたが、Python と比べると API もきれいでより低レベルなカスタマイズができるものの、TLS ハンドシェイクそのものに介入するための API は見つけられませんでした。
515 | 複雑で難解な上にフローが決まりきっている TLS ハンドシェイクの内部処理にわざわざ割り込むユースケースが(こうした特殊なケースを除いて)ほぼ皆無なことは火を見るより明らかですし、仕方ないとは思います。
516 |
517 | > TLS 周りの実装は下手すれば脆弱性になりかねませんし、専門知識のない一般のプログラマーがいじれるとかえってセキュリティリスクが高まる、という考えからなのかもしれません(実際そうだとは思います)。
518 |
519 | 見つけられていないだけで、keyless クライアントライブラリを移植可能な(TLS ハンドシェイクの深い部分まで介入できる)言語もあるかもしれません。ですが、すでに API の仕様上移植できない言語があるとなっては、直接 Keyless Server を使って HTTPS 化するアプローチは取りづらいです。
520 |
521 | また、一般的な Web サービスではアプリケーションサーバーとインターネットとの間に Apache や NGINX などの Web サーバーを挟むことが多いですが、Apache や NGINX が keyless クライアントに対応していないことは言うまでもありません。Apache や NGINX のソースコードをいじればなんとかなるかもですが、そこまでするかと言われると…。
522 |
523 | -----
524 |
525 | そこで **「直接 keyless クライアントにできないなら、keyless に対応したリバースプロキシを作ればいいのでは?」と逆転の発想で編み出したのが、HTTPS リバースプロキシというアプローチです。**
526 |
527 | この方法であれば、**Keyless で HTTPS 化したい HTTP サーバーがどんな言語や Web サーバーを使っていようと関係なく、かんたんに HTTPS サーバーを立ち上げられます。**
528 |
529 | リバースプロキシをアプリケーションサーバーとは別で起動させないといけない面倒さこそありますが、一度起動してしまえば、明示的に終了するまでリッスンしてくれます。アプリケーションサーバーの起動時に同時に起動し、終了時に同時に終了させるようにしておくと良いでしょう。
530 |
531 | また、HTTPS Server は単一バイナリだけで動作します。引数を指定すれば設定ファイル (`akebi-https-server.json`) がなくても起動できますし、設定ファイルを含めても、必要なのは2ファイルだけです。
532 | Apache や NGINX を一般的な PC に配布するアプリケーションに組み込むのはいささか無理がありますが、これなら配布するアプリケーションにも比較的組み込みやすいのではないでしょうか。
533 |
534 | ### URL 変更について
535 |
536 | HTTPS 化にあたっては、**今までの `http://192.168.1.11:3000/` のような IP アドレス直打ちの URL が使えなくなり、代わりに `https://192-168-1-11.local.example.com:3000/` のような URL でアクセスする必要がある点を、ユーザーに十分に周知させる必要があります。**
537 |
538 | > [!NOTE]
539 | > 一応 `https://192.168.1.11:3000/` でも使えなくはないですが、言うまでもなく証明書エラーが表示されます。
540 |
541 | プライベート IP アドレスや mDNS のようなローカル LAN だけで有効なドメイン (例: `my-computer.local`) には正規の HTTPS 証明書を発行できないため、**プライベート Web サイトで本物の HTTPS 証明書を使うには、いずれにせよインターネット上で有効なドメインにせざるを得ません。**
542 |
543 | そのため、オレオレ証明書を使わずに HTTPS 化したいのであれば、この変更は避けられません。
544 | ただ、**この URL 変更は十分に破壊的な変更になりえます。** 特にユーザーの多いプロダクトであれば、慎重に進めるべきでしょう。
545 | もしこの破壊的な変更を受け入れられないプロダクトであれば、HTTP でのアクセスを並行してサポートするか、正規の HTTPS 証明書を使うのを諦めるほかありません。
546 |
547 | > [!NOTE]
548 | > HTTP・HTTPS を両方サポートできる(HTTP アクセスでは HTTPS を必要とする機能を無効化する)リソースがあるのなら、並行して HTTP アクセスをサポートするのもありです。
549 |
550 | 私のユースケースでは、HTTPS 化によって得られるメリットが URL 変更のデメリットを上回ると判断して、Akebi の採用を決めました。メリットとデメリットを天秤にかけて、採用するかどうかを考えてみてください。
551 | **HTTPS が必要な機能をさほど使っていない/使う予定がないのであれば、ずっと HTTP のまま(現状維持)というのも全然ありだと思います。**
552 |
553 | -----
554 |
555 | また、逸般の誤家庭で使われがちなプロダクトでは、**『自分が所有しているドメインと証明書を使いたい』『開発者側が用意したドメインが気に入らない』『オレオレ証明書でいいから IP アドレス直打ちでアクセスさせろ』** といった声が上がることも想定されます。
556 |
557 | そうした要望に応えるのなら、必然的にカスタムの HTTPS 証明書/秘密鍵を使って HTTPS サーバーを起動することになります。
558 | ただ、一般ユーザー向けには Akebi の HTTPS リバースプロキシを挟み、カスタム証明書を使いたい逸般ユーザー向けには直接アプリケーション側で HTTPS サーバーをリッスンし… と分けていては、実装が煩雑になることは目に見えています。
559 |
560 | そこで、**HTTPS Server 自体に、カスタムの証明書/秘密鍵を使って HTTPS リバースプロキシをリッスンできる設定とコマンドライン引数を用意しました。**
561 | この機能を使うことで、HTTPS サーバーの役目を Akebi HTTPS Server に一元化できます。
562 |
563 | 詳しくは [HTTPS Server の設定](#https-server-の設定) で説明していますが、HTTPS Server では、**設定ファイルに記載の設定よりも、コマンドライン引数に指定した設定の方が優先されます。**
564 | これを利用して、HTTPS Server の起動コマンドに、アプリケーション側の設定でカスタムの証明書/秘密鍵が指定されたときだけ `--custom-certificate` / `--custom-private-key` を指定すれば、**設定ファイルを書き換えることなく、カスタム証明書を使って HTTPS Server を起動できます。**
565 | HTTPS サーバーを別途用意するよりも、はるかにシンプルな実装になるはずです。
566 |
567 | また、カスタム証明書での HTTPS 化を HTTPS Server で行うことで、前述したように HTTP/2 にも対応できます。
568 | HTTP/2 対応によって爆速になる、ということはあまりないとは思いますが、多かれ少なかれパフォーマンスは向上するはずです。
569 |
570 | -----
571 |
572 | カスタム証明書/秘密鍵を使いたい具体的なユースケースとして、**[Tailscale の HTTPS 有効化機能](https://tailscale.com/kb/1153/enabling-https/) を利用するケースが考えられます。**
573 |
574 | > [!NOTE]
575 | > Tailscale は、P2P 型のメッシュ VPN をかんたんに構築できるサービスです。
576 | > Tailscale に接続していれば、どこからでもほかの Tailscale に接続されているデバイスにアクセスできます。
577 |
578 | `tailscale cert` コマンドを実行すると、`[machine-name].[domain-alias].ts.net` のフォーマットのドメインで利用できる、HTTPS 証明書と秘密鍵が発行されます。
579 | この証明書は、ホスト名が `[machine-name].[domain-alias].ts.net` であれば同じ PC 内のどんなプライベート Web サイトでも使える、Let's Encrypt 発行の正規の証明書です。
580 |
581 | **Tailscale から発行されたカスタムの証明書/秘密鍵を HTTPS Server に設定すると、`https://[machine-name].[domain-alias].ts.net:3000/` の URL でアプリケーションに HTTPS でアクセスできるようになります。**
582 | Keyless Server を利用する機能が無効化されるため、`https://192-168-1-11.local.example.com:3000/` の URL でアクセスできなくなる点はトレードオフです。
583 |
584 | Tailscale を常に経由してプライベート Web サイトにアクセスするユーザーにとっては、IP アドレスそのままよりもわかりやすい URL でアクセスできるため、Keyless Server よりも良い選択肢かもしれません。
585 |
586 | ## License
587 |
588 | [MIT License](License.txt)
589 |
--------------------------------------------------------------------------------