mirror of
https://github.com/dalbodeule/hop-gate.git
synced 2025-12-07 20:35:44 +09:00
[feat](errorpages): add custom templates for HTTP errors and assets
- Implemented custom HTML templates for `400`, `404`, `500`, and `525` error pages with multilingual support. - Added embedded file system for error page templates and assets. - Introduced fallback mechanism to serve minimal plain text for missing error templates. - Integrated TailwindCSS for styling error pages, with a build script in `package.json`.
This commit is contained in:
145
.gitignore
vendored
145
.gitignore
vendored
@@ -1,5 +1,5 @@
|
||||
# Created by https://www.toptal.com/developers/gitignore/api/go,goland+all,dotenv,macos,linux,windows
|
||||
# Edit at https://www.toptal.com/developers/gitignore?templates=go,goland+all,dotenv,macos,linux,windows
|
||||
# Created by https://www.toptal.com/developers/gitignore/api/go,linux,macos,dotenv,windows,goland+all,node
|
||||
# Edit at https://www.toptal.com/developers/gitignore?templates=go,linux,macos,dotenv,windows,goland+all,node
|
||||
|
||||
### dotenv ###
|
||||
.env
|
||||
@@ -163,6 +163,145 @@ Temporary Items
|
||||
# iCloud generated files
|
||||
*.icloud
|
||||
|
||||
### Node ###
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
### Node Patch ###
|
||||
# Serverless Webpack directories
|
||||
.webpack/
|
||||
|
||||
# Optional stylelint cache
|
||||
|
||||
# SvelteKit build / generate output
|
||||
.svelte-kit
|
||||
|
||||
### Windows ###
|
||||
# Windows thumbnail cache files
|
||||
Thumbs.db
|
||||
@@ -189,7 +328,7 @@ $RECYCLE.BIN/
|
||||
# Windows shortcuts
|
||||
*.lnk
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/go,goland+all,dotenv,macos,linux,windows
|
||||
# End of https://www.toptal.com/developers/gitignore/api/go,linux,macos,dotenv,windows,goland+all,node
|
||||
|
||||
# builded binary
|
||||
bin/
|
||||
|
||||
@@ -21,6 +21,9 @@ ARG TARGETARCH=amd64
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
# Node.js + npm 설치 (Tailwind CSS 빌드를 위해 필요)
|
||||
RUN apk add --no-cache nodejs npm
|
||||
|
||||
# 모듈/의존성 캐시를 최대한 활용하기 위해 go.mod, go.sum 먼저 복사
|
||||
COPY go.mod ./
|
||||
COPY go.sum ./
|
||||
@@ -28,9 +31,14 @@ COPY go.sum ./
|
||||
# 의존성 다운로드 (캐시 활용을 위해 소스 전체 복사 전에 실행)
|
||||
RUN go mod download
|
||||
|
||||
# 실제 소스 코드 복사
|
||||
# 실제 소스 코드 및 Tailwind 설정 복사
|
||||
COPY . .
|
||||
|
||||
# Tailwind 기반 에러 페이지 CSS 빌드
|
||||
# - package.json 의 "build:errors-css" 스크립트를 사용해
|
||||
# internal/errorpages/assets/errors.css 를 생성합니다.
|
||||
RUN npm install && npm run build:errors-css
|
||||
|
||||
# 서버 바이너리 빌드 (멀티 아키텍처: TARGETOS/TARGETARCH 기반)
|
||||
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o /out/hop-gate-server ./cmd/server
|
||||
|
||||
|
||||
18
Makefile
18
Makefile
@@ -20,11 +20,25 @@ CLIENT_BIN := $(BIN_DIR)/hop-gate-client
|
||||
|
||||
VERSION ?= $(shell git describe --tags --dirty --always 2>/dev/null || echo dev)
|
||||
|
||||
.PHONY: all server client clean docker-server run-server run-client
|
||||
.PHONY: all server client clean docker-server run-server run-client errors-css
|
||||
|
||||
all: server client
|
||||
|
||||
server:
|
||||
# Build Tailwind-based error page CSS (internal/errorpages/assets/errors.css).
|
||||
# Tailwind 기반 에러 페이지 CSS 빌드 (internal/errorpages/assets/errors.css).
|
||||
errors-css:
|
||||
@if [ -f package.json ]; then \
|
||||
if [ ! -d node_modules ]; then \
|
||||
echo "Installing npm dependencies..."; \
|
||||
npm install; \
|
||||
fi; \
|
||||
echo "Building Tailwind CSS for error pages..."; \
|
||||
npm run build:errors-css; \
|
||||
else \
|
||||
echo "package.json not found; skipping errors-css build"; \
|
||||
fi
|
||||
|
||||
server: errors-css
|
||||
@echo "Building server..."
|
||||
@mkdir -p $(BIN_DIR)
|
||||
$(GO) build -ldflags "-X main.version=$(VERSION)" -o $(SERVER_BIN) $(SERVER_PKG)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
stdfs "io/fs"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -21,6 +22,7 @@ import (
|
||||
"github.com/dalbodeule/hop-gate/internal/admin"
|
||||
"github.com/dalbodeule/hop-gate/internal/config"
|
||||
"github.com/dalbodeule/hop-gate/internal/dtls"
|
||||
"github.com/dalbodeule/hop-gate/internal/errorpages"
|
||||
"github.com/dalbodeule/hop-gate/internal/logging"
|
||||
"github.com/dalbodeule/hop-gate/internal/observability"
|
||||
"github.com/dalbodeule/hop-gate/internal/protocol"
|
||||
@@ -169,6 +171,28 @@ var hopGateOwnedHeaders = map[string]struct{}{
|
||||
"Referrer-Policy": {},
|
||||
}
|
||||
|
||||
// writeErrorPage 는 주요 HTTP 에러 코드(400/404/500/525)에 대해 정적 HTML 에러 페이지를 렌더링합니다. (ko)
|
||||
// writeErrorPage renders static HTML error pages for key HTTP error codes (400/404/500/525). (en)
|
||||
//
|
||||
// 템플릿 로딩 우선순위: (ko)
|
||||
// 1) HOP_ERROR_PAGES_DIR/<status>.html (또는 ./errors/<status>.html) (ko)
|
||||
// 2) go:embed 로 내장된 templates/<status>.html (ko)
|
||||
//
|
||||
// Template loading priority: (en)
|
||||
// 1) HOP_ERROR_PAGES_DIR/<status>.html (or ./errors/<status>.html) (en)
|
||||
// 2) go:embed'ed templates/<status>.html (en)
|
||||
func writeErrorPage(w http.ResponseWriter, r *http.Request, status int) {
|
||||
// 공통 보안/식별 헤더를 best-effort 로 설정합니다. (ko)
|
||||
// Configure common security and identity headers (best-effort). (en)
|
||||
if r != nil {
|
||||
setSecurityAndIdentityHeaders(w, r)
|
||||
}
|
||||
|
||||
// Delegates actual HTML rendering to internal/errorpages. (en)
|
||||
// 실제 HTML 렌더링은 internal/errorpages 패키지에 위임합니다. (ko)
|
||||
errorpages.Render(w, r, status)
|
||||
}
|
||||
|
||||
// setSecurityAndIdentityHeaders 는 HopGate 에서 공통으로 추가하는 보안/식별 헤더를 설정합니다. (ko)
|
||||
// setSecurityAndIdentityHeaders configures common security and identity headers for HopGate. (en)
|
||||
func setSecurityAndIdentityHeaders(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -208,8 +232,9 @@ func hostDomainHandler(allowedDomain string, logger logging.Logger, next http.Ha
|
||||
"request_host": host,
|
||||
"path": r.URL.Path,
|
||||
})
|
||||
// 메트릭/관리용 엔드포인트에 대해 호스트가 다르면 404 로 응답하여 노출을 최소화합니다.
|
||||
http.NotFound(w, r)
|
||||
// 메트릭/관리용 엔드포인트에 대해 호스트가 다르면 404 페이지로 응답하여 노출을 최소화합니다. (ko)
|
||||
// For metrics/admin endpoints, respond with a 404 page when host mismatches to reduce exposure. (en)
|
||||
writeErrorPage(w, r, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -287,7 +312,7 @@ func newHTTPHandler(logger logging.Logger) http.Handler {
|
||||
token := strings.Trim(r.URL.Path, "/")
|
||||
if token == "" {
|
||||
observability.ProxyErrorsTotal.WithLabelValues("acme_http01_error").Inc()
|
||||
http.Error(sr, "invalid acme challenge path", http.StatusBadRequest)
|
||||
writeErrorPage(sr, r, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
filePath := filepath.Join(webroot, token)
|
||||
@@ -307,7 +332,7 @@ func newHTTPHandler(logger logging.Logger) http.Handler {
|
||||
"error": err.Error(),
|
||||
})
|
||||
observability.ProxyErrorsTotal.WithLabelValues("acme_http01_error").Inc()
|
||||
http.NotFound(sr, r)
|
||||
writeErrorPage(sr, r, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
@@ -333,7 +358,7 @@ func newHTTPHandler(logger logging.Logger) http.Handler {
|
||||
"host": r.Host,
|
||||
})
|
||||
observability.ProxyErrorsTotal.WithLabelValues("no_dtls_session").Inc()
|
||||
http.Error(sr, "no backend client available", http.StatusBadGateway)
|
||||
writeErrorPage(sr, r, errorpages.StatusTLSHandshakeFailed)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -370,7 +395,7 @@ func newHTTPHandler(logger logging.Logger) http.Handler {
|
||||
"error": err.Error(),
|
||||
})
|
||||
observability.ProxyErrorsTotal.WithLabelValues("dtls_forward_failed").Inc()
|
||||
http.Error(sr, "dtls forward failed", http.StatusBadGateway)
|
||||
writeErrorPage(sr, r, errorpages.StatusTLSHandshakeFailed)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -594,6 +619,42 @@ func main() {
|
||||
httpMux := http.NewServeMux()
|
||||
allowedDomain := strings.ToLower(strings.TrimSpace(cfg.Domain))
|
||||
|
||||
// __hopgate_assets__ prefix:
|
||||
// HopGate 서버가 직접 Tailwind CSS, 로고 등 정적 에셋을 서빙하기 위한 경로입니다. (ko)
|
||||
// This prefix is used for static assets (Tailwind CSS, logos, etc.) served directly by HopGate. (en)
|
||||
//
|
||||
// 우선순위: (ko)
|
||||
// 1) HOP_ERROR_ASSETS_DIR 가 설정되어 있으면 해당 디렉터리 (디스크 기반)
|
||||
// 2) 없으면 internal/errorpages/assets 에 내장된 go:embed 에셋 사용
|
||||
//
|
||||
// Priority: (en)
|
||||
// 1) HOP_ERROR_ASSETS_DIR if set (disk-based)
|
||||
// 2) Otherwise, use go:embed'ed assets under internal/errorpages/assets
|
||||
assetDir := strings.TrimSpace(os.Getenv("HOP_ERROR_ASSETS_DIR"))
|
||||
if assetDir != "" {
|
||||
fs := http.FileServer(http.Dir(assetDir))
|
||||
httpMux.Handle("/__hopgate_assets/",
|
||||
hostDomainHandler(allowedDomain, logger,
|
||||
http.StripPrefix("/__hopgate_assets/", fs),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
// Embedded assets under internal/errorpages/assets.
|
||||
if sub, err := stdfs.Sub(errorpages.AssetsFS, "assets"); err == nil {
|
||||
staticFS := http.FileServer(http.FS(sub))
|
||||
httpMux.Handle("/__hopgate_assets/",
|
||||
hostDomainHandler(allowedDomain, logger,
|
||||
http.StripPrefix("/__hopgate_assets/", staticFS),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
logger.Warn("failed to init embedded assets filesystem", logging.Fields{
|
||||
"component": "error_assets",
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// /metrics 는 HOP_SERVER_DOMAIN 에 지정된 도메인으로만 접근 가능하도록 제한합니다.
|
||||
httpMux.Handle("/metrics", hostDomainHandler(allowedDomain, logger, promhttp.Handler()))
|
||||
|
||||
|
||||
2
internal/errorpages/assets/errors.css
Normal file
2
internal/errorpages/assets/errors.css
Normal file
@@ -0,0 +1,2 @@
|
||||
/*! tailwindcss v4.1.17 | MIT License | https://tailwindcss.com */
|
||||
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-tracking:initial;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.static{position:static}.contents{display:contents}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.min-h-screen{min-height:100vh}.w-auto{width:auto}.w-full{width:100%}.items-baseline{align-items:baseline}.items-center{align-items:center}.justify-center{justify-content:center}.text-center{text-align:center}.tracking-\[0\.25em\]{--tw-tracking:.25em;letter-spacing:.25em}.uppercase{text-transform:uppercase}.opacity-90{opacity:.9}.filter{filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}
|
||||
BIN
internal/errorpages/assets/hop-gate.png
Normal file
BIN
internal/errorpages/assets/hop-gate.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 499 KiB |
85
internal/errorpages/errorpages.go
Normal file
85
internal/errorpages/errorpages.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package errorpages
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// StatusTLSHandshakeFailed is an HTTP-style status code representing
|
||||
// a TLS/DTLS handshake failure (similar to Cloudflare 525).
|
||||
// TLS/DTLS 핸드셰이크 실패를 나타내는 HTTP 스타일 상태 코드입니다. (예: 525)
|
||||
const StatusTLSHandshakeFailed = 525
|
||||
|
||||
//go:embed templates/*.html
|
||||
var embeddedTemplatesFS embed.FS
|
||||
|
||||
// AssetsFS embeds static assets (CSS, logos, etc.) for error pages.
|
||||
// 에러 페이지용 정적 에셋(CSS, 로고 등)을 바이너리에 포함하는 embed FS 입니다.
|
||||
//
|
||||
// Expected files (by convention):
|
||||
// - assets/errors.css
|
||||
// - assets/logo.svg
|
||||
//go:embed assets/*
|
||||
var AssetsFS embed.FS
|
||||
|
||||
// Render writes an error page HTML for the given HTTP status code to the response writer.
|
||||
// If no matching template is found, it falls back to a minimal plain text response.
|
||||
//
|
||||
// 주어진 HTTP 상태 코드에 대한 에러 페이지 HTML을 응답에 씁니다.
|
||||
// 해당 템플릿이 없으면 최소한의 텍스트 응답으로 폴백합니다.
|
||||
func Render(w http.ResponseWriter, r *http.Request, status int) {
|
||||
html, ok := Load(status)
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
|
||||
if !ok {
|
||||
// Fallback to a minimal plain text response if no template is available.
|
||||
// 템플릿이 없으면 간단한 텍스트 응답으로 대체합니다.
|
||||
_, _ = fmt.Fprintf(w, "%d %s", status, http.StatusText(status))
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = w.Write(html)
|
||||
}
|
||||
|
||||
// Load attempts to load an error page for the given HTTP status code.
|
||||
//
|
||||
// Priority:
|
||||
// 1. $HOP_ERROR_PAGES_DIR/<status>.html (or ./errors/<status>.html if env is empty)
|
||||
// 2. embedded template: templates/<status>.html
|
||||
//
|
||||
// 주어진 HTTP 상태 코드에 대한 에러 페이지를 로드합니다.
|
||||
//
|
||||
// 우선순위:
|
||||
// 1. $HOP_ERROR_PAGES_DIR/<status>.html (env 미설정 시 ./errors/<status>.html)
|
||||
// 2. 내장 템플릿: templates/<status>.html
|
||||
func Load(status int) ([]byte, bool) {
|
||||
name := fmt.Sprintf("%d.html", status)
|
||||
|
||||
// 1. External directory override (HOP_ERROR_PAGES_DIR, default "./errors").
|
||||
// 1. 외부 디렉터리 우선 (HOP_ERROR_PAGES_DIR, 기본값 "./errors").
|
||||
dir := strings.TrimSpace(os.Getenv("HOP_ERROR_PAGES_DIR"))
|
||||
if dir == "" {
|
||||
dir = "./errors"
|
||||
}
|
||||
if dir != "" {
|
||||
p := filepath.Join(dir, name)
|
||||
if data, err := os.ReadFile(p); err == nil {
|
||||
return data, true
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Embedded default templates.
|
||||
// 2. 내장 기본 템플릿.
|
||||
p := filepath.Join("templates", name)
|
||||
if data, err := embeddedTemplatesFS.ReadFile(p); err == nil {
|
||||
return data, true
|
||||
}
|
||||
|
||||
return nil, false
|
||||
}
|
||||
33
internal/errorpages/templates/400.html
Normal file
33
internal/errorpages/templates/400.html
Normal file
@@ -0,0 +1,33 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>400 Bad Request - HopGate</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<!-- Tailwind build output should be bundled into this file -->
|
||||
<link rel="stylesheet" href="/__hopgate_assets__/errors.css">
|
||||
</head>
|
||||
<body class="min-h-screen bg-slate-950 text-slate-50 flex items-center justify-center px-4">
|
||||
<div class="w-full max-w-xl text-center">
|
||||
<div class="inline-flex items-center gap-3 mb-8">
|
||||
<img src="/__hopgate_assets__/hop-gate.png" alt="HopGate" class="h-8 w-auto opacity-90" />
|
||||
<span class="text-sm font-medium tracking-[0.25em] uppercase text-slate-400">HopGate</span>
|
||||
</div>
|
||||
|
||||
<div class="inline-flex items-baseline gap-4 mb-4">
|
||||
<span class="text-6xl md:text-7xl font-extrabold tracking-[0.25em] text-amber-300">400</span>
|
||||
<span class="text-lg md:text-xl font-semibold text-slate-100">Bad Request</span>
|
||||
</div>
|
||||
|
||||
<p class="text-sm md:text-base text-slate-300 leading-relaxed">
|
||||
The request was malformed or invalid.<br>
|
||||
요청 형식이 올바르지 않거나 지원되지 않습니다.
|
||||
</p>
|
||||
|
||||
<div class="mt-8 text-xs md:text-sm text-slate-500">
|
||||
If you typed the URL directly, please check it for spelling.<br>
|
||||
URL을 직접 입력하셨다면 철자를 다시 한 번 확인해 주세요.
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
32
internal/errorpages/templates/404.html
Normal file
32
internal/errorpages/templates/404.html
Normal file
@@ -0,0 +1,32 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>404 Not Found - HopGate</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="/__hopgate_assets__/errors.css">
|
||||
</head>
|
||||
<body class="min-h-screen bg-slate-950 text-slate-50 flex items-center justify-center px-4">
|
||||
<div class="w-full max-w-xl text-center">
|
||||
<div class="inline-flex items-center gap-3 mb-8">
|
||||
<img src="/__hopgate_assets__/hop-gate.png" alt="HopGate" class="h-8 w-auto opacity-90" />
|
||||
<span class="text-sm font-medium tracking-[0.25em] uppercase text-slate-400">HopGate</span>
|
||||
</div>
|
||||
|
||||
<div class="inline-flex items-baseline gap-4 mb-4">
|
||||
<span class="text-6xl md:text-7xl font-extrabold tracking-[0.25em] text-sky-400">404</span>
|
||||
<span class="text-lg md:text-xl font-semibold text-slate-100">Domain or page not found</span>
|
||||
</div>
|
||||
|
||||
<p class="text-sm md:text-base text-slate-300 leading-relaxed">
|
||||
The requested domain or path does not exist on this HopGate edge.<br>
|
||||
요청하신 도메인 또는 경로를 찾을 수 없습니다.
|
||||
</p>
|
||||
|
||||
<div class="mt-8 text-xs md:text-sm text-slate-500">
|
||||
Please check the URL or try again later.<br>
|
||||
URL을 다시 확인하시거나 잠시 후 다시 시도해 주세요.
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
32
internal/errorpages/templates/500.html
Normal file
32
internal/errorpages/templates/500.html
Normal file
@@ -0,0 +1,32 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>500 Internal Server Error - HopGate</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="/__hopgate_assets__/errors.css">
|
||||
</head>
|
||||
<body class="min-h-screen bg-slate-950 text-slate-50 flex items-center justify-center px-4">
|
||||
<div class="w-full max-w-xl text-center">
|
||||
<div class="inline-flex items-center gap-3 mb-8">
|
||||
<img src="/__hopgate_assets__/hop-gate.png" alt="HopGate" class="h-8 w-auto opacity-90" />
|
||||
<span class="text-sm font-medium tracking-[0.25em] uppercase text-slate-400">HopGate</span>
|
||||
</div>
|
||||
|
||||
<div class="inline-flex items-baseline gap-4 mb-4">
|
||||
<span class="text-6xl md:text-7xl font-extrabold tracking-[0.25em] text-rose-400">500</span>
|
||||
<span class="text-lg md:text-xl font-semibold text-slate-100">Internal Server Error</span>
|
||||
</div>
|
||||
|
||||
<p class="text-sm md:text-base text-slate-300 leading-relaxed">
|
||||
Something went wrong while processing your request.<br>
|
||||
요청을 처리하는 중 서버 내부 오류가 발생했습니다.
|
||||
</p>
|
||||
|
||||
<div class="mt-8 text-xs md:text-sm text-slate-500">
|
||||
The issue has been logged and may be investigated by the operator.<br>
|
||||
이 문제는 로그로 기록되었으며, 운영자가 확인할 수 있습니다.
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
32
internal/errorpages/templates/525.html
Normal file
32
internal/errorpages/templates/525.html
Normal file
@@ -0,0 +1,32 @@
|
||||
ㄱ<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>525 TLS Handshake Failed - HopGate</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="/__hopgate_assets__/errors.css">
|
||||
</head>
|
||||
<body class="min-h-screen bg-slate-950 text-slate-50 flex items-center justify-center px-4">
|
||||
<div class="w-full max-w-xl text-center">
|
||||
<div class="inline-flex items-center gap-3 mb-8">
|
||||
<img src="/__hopgate_assets__/hop-gate.png" alt="HopGate" class="h-8 w-auto opacity-90" />
|
||||
<span class="text-sm font-medium tracking-[0.25em] uppercase text-slate-400">HopGate</span>
|
||||
</div>
|
||||
|
||||
<div class="inline-flex items-baseline gap-4 mb-4">
|
||||
<span class="text-6xl md:text-7xl font-extrabold tracking-[0.25em] text-amber-300">525</span>
|
||||
<span class="text-lg md:text-xl font-semibold text-slate-100">TLS/DTLS Handshake Failed</span>
|
||||
</div>
|
||||
|
||||
<p class="text-sm md:text-base text-slate-300 leading-relaxed">
|
||||
The secure tunnel to the backend could not be established.<br>
|
||||
백엔드와의 TLS/DTLS 핸드셰이크에 실패하여 요청을 처리할 수 없습니다.
|
||||
</p>
|
||||
|
||||
<div class="mt-8 text-xs md:text-sm text-slate-500">
|
||||
This is usually a temporary issue between the edge and the origin service.<br>
|
||||
보통 엣지와 백엔드 서비스 간의 일시적인 문제일 수 있습니다.
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
1131
package-lock.json
generated
Normal file
1131
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
package.json
Normal file
25
package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "hop-gate",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"description": "> Korean / English bilingual README. (ko/en 병기 README입니다.)",
|
||||
"homepage": "https://github.com/dalbodeule/hop-gate#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/dalbodeule/hop-gate/issues"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/dalbodeule/hop-gate.git"
|
||||
},
|
||||
"license": "ISC",
|
||||
"author": "",
|
||||
"type": "commonjs",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"build:errors-css": "tailwindcss -c ./tools/tailwind/tailwind.config.cjs -i ./tools/tailwind/input.css -o ./internal/errorpages/assets/errors.css --minify"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/cli": "^4.1.17",
|
||||
"tailwindcss": "^4.1.17"
|
||||
}
|
||||
}
|
||||
43
progress.md
43
progress.md
@@ -140,16 +140,45 @@ This document tracks implementation progress against the HopGate architecture an
|
||||
|
||||
### 2.7 Logging / Build / Docker
|
||||
|
||||
- 구조적 로깅: [`internal/logging/logging.go`](internal/logging/logging.go)
|
||||
- JSON 단일라인 로그, `level`, `ts`, `msg`, `Fields`.
|
||||
- Loki/Promtail + Grafana 스택에 최적화.
|
||||
- 구조적 로깅: [`internal/logging/logging.go`](internal/logging/logging.go)
|
||||
- JSON 단일라인 로그, `level`, `ts`, `msg`, `Fields`.
|
||||
- Loki/Promtail + Grafana 스택에 최적화.
|
||||
|
||||
- 빌드/도커:
|
||||
- [`Makefile`](Makefile) — `make server`, `make client`, `make docker-server`.
|
||||
- [`Dockerfile.server`](Dockerfile.server) — multi-stage build, Alpine runtime.
|
||||
- [`.dockerignore`](.dockerignore) — `images/` 제외.
|
||||
- [`Makefile`](Makefile) — `make server`, `make client`, `make docker-server`.
|
||||
- `server` 타겟은 Tailwind 기반 에러 페이지 CSS 빌드를 위한 `errors-css` 타겟을 선행 실행 (`npm run build:errors-css`).
|
||||
- [`Dockerfile.server`](Dockerfile.server) — multi-stage build, Alpine runtime.
|
||||
- Build stage 에 Node.js + npm 을 설치하고, `npm install && npm run build:errors-css` 를 통해 에러 페이지용 CSS를 빌드한 뒤 Go 서버 바이너리를 생성.
|
||||
- [`.dockerignore`](.dockerignore) — `images/` 제외.
|
||||
|
||||
- 아키텍처 이미지: [`images/architecture.jpeg`](images/architecture.jpeg)
|
||||
- 아키텍처 이미지: [`images/architecture.jpeg`](images/architecture.jpeg)
|
||||
|
||||
---
|
||||
|
||||
### 2.8 Error Pages / 에러 페이지
|
||||
|
||||
- 에러 페이지 템플릿: [`internal/errorpages/templates/*.html`](internal/errorpages/templates/400.html)
|
||||
- HTTP 상태 코드별 HTML:
|
||||
- `400.html`, `404.html`, `500.html`, `525.html`.
|
||||
- TailwindCSS 기반 레이아웃 및 스타일 적용 (영문/한글 메시지 병기).
|
||||
- `go:embed` 로 서버 바이너리에 포함되어 기본값으로 사용.
|
||||
|
||||
- 에러 페이지 정적 에셋: [`internal/errorpages/assets`](internal/errorpages/errorpages.go)
|
||||
- TailwindCSS 빌드 결과: `errors.css` (내장 CSS).
|
||||
- 로고 등 브랜드 리소스: `logo.svg` 등 (내장 가능).
|
||||
- 런타임에서는 `/__hopgate_assets__/...` prefix 로 HopGate 서버가 직접 서빙:
|
||||
- 1순위: `HOP_ERROR_ASSETS_DIR` 가 설정된 경우 해당 디렉터리에서 정적 파일 로드.
|
||||
- 2순위: 설정되지 않은 경우 `internal/errorpages/assets` 에 embed 된 에셋 사용.
|
||||
|
||||
- 에러 페이지 렌더링 로직: [`internal/errorpages/errorpages.go`](internal/errorpages/errorpages.go), [`cmd/server/main.go`](cmd/server/main.go)
|
||||
- `writeErrorPage(w, r, status)` → `errorpages.Render` 호출.
|
||||
- HTML 로딩 우선순위:
|
||||
- 1) `HOP_ERROR_PAGES_DIR/<status>.html` (env 미설정 시 `./errors/<status>.html`)
|
||||
- 2) `internal/errorpages/templates/<status>.html` (go:embed 기본 템플릿)
|
||||
- 주요 사용처:
|
||||
- 잘못된 ACME HTTP-01 요청 (400/404).
|
||||
- 허용되지 않은 Host 요청 (404).
|
||||
- DTLS 세션 부재/포워딩 실패 → 525 TLS/DTLS Handshake Failed 페이지.
|
||||
|
||||
---
|
||||
|
||||
|
||||
4
tools/tailwind/input.css
Normal file
4
tools/tailwind/input.css
Normal file
@@ -0,0 +1,4 @@
|
||||
/* tools/tailwind/input.css */
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
11
tools/tailwind/tailwind.config.cjs
Normal file
11
tools/tailwind/tailwind.config.cjs
Normal file
@@ -0,0 +1,11 @@
|
||||
// tools/tailwind/tailwind.config.cjs
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
"./internal/errorpages/templates/*.html",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
Reference in New Issue
Block a user