├── .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 | ![](https://blog.cloudflare.com/content/images/2014/Sep/cloudflare_keyless_ssl_handshake_diffie_hellman.jpg) 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 | --------------------------------------------------------------------------------