Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,10 @@ jobs:
- name: Write .env file
uses: appleboy/ssh-action@v0.1.10
env:
ENV_LIST: RCP_JWT_SECRET,RCP_ADMIN_EMAILS,OS_AUTH_URL,OS_USERNAME,OS_PASSWORD,OS_PROJECT_NAME,OS_USER_DOMAIN_NAME,OS_PROJECT_ID,CF_ACCESS_CLIENT_ID,CF_ACCESS_CLIENT_SECRET,GG_OAUTH_CLIENT,GG_OAUTH_SECRET,GG_REDIRECT_URL,DB_DRIVER,DB_DSN,PORT,RCP_NS_PROXY_SOCK,RCP_FUNCTION_BUILDER_SOCKET,RCP_FUNCTION_DATA_DIR,RCP_WEB_CONSOLE_BASE_URL,RCP_WEB_CONSOLE_ALLOWED_ORIGINS,RCP_VM_KNOWN_HOSTS_PATH,RCP_SSH_GW_KNOWN_HOSTS_PATH,FRONTEND_URL,RCP_FRONTEND_BASE_URL,RCP_ALLOWED_FRONTEND_ORIGIN_PATTERN,RCP_AUTH_COOKIE_SAMESITE,RCP_AUTH_COOKIE_SECURE,RCP_ALLOWED_ORIGINS,RCP_ALLOWED_ORIGIN_PATTERN,RCP_DEFAULT_NETWORK_ID,RCP_SSH_GW_NOTIFY_SOCK,RCP_SSH_GW_NOTIFY_SECRET
ENV_LIST: RCP_JWT_SECRET,RCP_ADMIN_EMAILS,OS_AUTH_URL,OS_USERNAME,OS_PASSWORD,OS_PROJECT_NAME,OS_USER_DOMAIN_NAME,OS_PROJECT_ID,CF_ACCESS_CLIENT_ID,CF_ACCESS_CLIENT_SECRET,GG_OAUTH_CLIENT,GG_OAUTH_SECRET,GG_REDIRECT_URL,DB_DRIVER,DB_DSN,PORT,RCP_NS_PROXY_SOCK,RCP_FUNCTION_BUILDER_SOCKET,RCP_FUNCTION_DATA_DIR,RCP_WEB_CONSOLE_BASE_URL,RCP_WEB_CONSOLE_ALLOWED_ORIGINS,RCP_VM_KNOWN_HOSTS_PATH,RCP_SSH_GW_KNOWN_HOSTS_PATH,FRONTEND_URL,RCP_FRONTEND_BASE_URL,RCP_ALLOWED_FRONTEND_ORIGIN_PATTERN,RCP_AUTH_COOKIE_SAMESITE,RCP_AUTH_COOKIE_SECURE,RCP_ALLOWED_ORIGINS,RCP_ALLOWED_ORIGIN_PATTERN,RCP_DEFAULT_NETWORK_ID,RCP_SSH_GW_NOTIFY_SOCK,RCP_SSH_GW_NOTIFY_SECRET,SENTRY_DSN,SENTRY_ENVIRONMENT,SENTRY_RELEASE
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_ENVIRONMENT: production
SENTRY_RELEASE: ${{ github.sha }}
RCP_FUNCTION_BUILDER_SOCKET: ${{ secrets.RCP_FUNCTION_BUILDER_SOCKET }}
RCP_FUNCTION_DATA_DIR: ${{ secrets.RCP_FUNCTION_DATA_DIR }}
FRONTEND_URL: ${{ secrets.FRONTEND_URL }}
Expand Down
3 changes: 3 additions & 0 deletions GUIDELINE.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,9 @@ main.go → infrastructure 클라이언트 생성 → App 조립 → 라우터
| 변수 | 의무 | 설명 |
|------|------|------|
| `PORT` | optional (기본 `8080`) | HTTP 리스닝 포트 |
| `SENTRY_DSN` | optional | Sentry 오류 수집 DSN. 미설정 시 수집 비활성. 운영 배포는 GitHub Actions Secret에 등록 |
| `SENTRY_ENVIRONMENT` | optional | Sentry 환경 이름. 운영 배포에서는 `production` |
| `SENTRY_RELEASE` | optional | Sentry 릴리스 식별자. 운영 배포에서는 Git commit SHA |
| `RCP_JWT_SECRET` | optional (dev 폴백 있음) | JWT 서명 시크릿. 운영에서는 반드시 설정 |
| `OS_AUTH_URL` | **필수** | OpenStack Identity 엔드포인트 |
| `OS_USERNAME` | **필수** | OpenStack 사용자 이름 |
Expand Down
20 changes: 20 additions & 0 deletions cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import (
"strings"
"time"

"github.com/getsentry/sentry-go"

"github.com/KHU-RETURN/rcp-server/internal/infrastructure/database"
"github.com/KHU-RETURN/rcp-server/internal/infrastructure/google"
"github.com/KHU-RETURN/rcp-server/internal/infrastructure/openstack"
Expand All @@ -21,6 +23,24 @@ func main() {
if err := godotenv.Load(); err != nil && !errors.Is(err, os.ErrNotExist) {
log.Fatalf(".env 로드 실패: %v", err)
}
if dsn := strings.TrimSpace(os.Getenv("SENTRY_DSN")); dsn != "" {
off := &sentry.KeyValueCollectionBehavior{Mode: sentry.CollectionOff}
if err := sentry.Init(sentry.ClientOptions{
Dsn: dsn,
Environment: os.Getenv("SENTRY_ENVIRONMENT"),
Release: os.Getenv("SENTRY_RELEASE"),
DataCollection: &sentry.DataCollection{
UserInfo: sentry.Set(false),
Cookies: off,
HTTPHeaders: &sentry.HeaderCollectionConfig{Request: off, Response: off},
HTTPBodies: []sentry.BodyType{},
QueryParams: off,
},
}); err != nil {
log.Fatalf("Sentry 초기화 실패: %v", err)
}
defer sentry.Flush(2 * time.Second)
}

// 환경변수는 main에서만 읽습니다.
jwtSecret := os.Getenv("RCP_JWT_SECRET")
Expand Down
4 changes: 3 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ go 1.26.6
require (
entgo.io/ent v0.14.6
github.com/coder/websocket v1.8.14
github.com/getsentry/sentry-go v0.49.0
github.com/getsentry/sentry-go/gin v0.49.0
github.com/gin-gonic/gin v1.12.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
Expand Down Expand Up @@ -53,7 +55,7 @@ require (
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
Expand Down
23 changes: 18 additions & 5 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -29,18 +29,25 @@ github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gE
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/getsentry/sentry-go v0.49.0 h1:Ehejknu1l023Ub7QoRBVLAI7g3Jnhqku4oWx4B4Sh5s=
github.com/getsentry/sentry-go v0.49.0/go.mod h1:nuMJAoCfe1u0Bts2ocyNI+TW8HT84vRMqwA5Qq/SKUI=
github.com/getsentry/sentry-go/gin v0.49.0 h1:PGvRtmaQvDTspf5ltbpyAO3f2B3TfHRIlciPd/Bjxt4=
github.com/getsentry/sentry-go/gin v0.49.0/go.mod h1:jSZV8BST6coCpwWUMWJzFy74t1xoKL0kJPWFPCAr3Yo=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
Expand Down Expand Up @@ -101,8 +108,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
Expand All @@ -116,8 +123,13 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
Expand Down Expand Up @@ -169,6 +181,8 @@ go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRk
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
Expand All @@ -188,7 +202,6 @@ golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
Expand Down
26 changes: 26 additions & 0 deletions internal/server/router.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
package server

import (
"fmt"
"net/http"

"github.com/getsentry/sentry-go"
sentrygin "github.com/getsentry/sentry-go/gin"
"github.com/gin-gonic/gin"

"github.com/KHU-RETURN/rcp-server/internal/api"
Expand All @@ -9,6 +14,8 @@ import (

func NewRouter(app *App) *gin.Engine {
r := gin.Default()
r.Use(sentrygin.New(sentrygin.Options{Repanic: true}))
r.Use(sentryServerErrorMiddleware())
r.Use(corsMiddleware())

v1 := r.Group(api.BasePath)
Expand Down Expand Up @@ -43,3 +50,22 @@ func NewRouter(app *App) *gin.Engine {

return r
}

func sentryServerErrorMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
c.Next()
if c.Writer.Status() < http.StatusInternalServerError {
return
}
if hub := sentrygin.GetHubFromContext(c); hub != nil {
path := c.FullPath()
if path == "" {
path = "unmatched route"
}
hub.WithScope(func(scope *sentry.Scope) {
scope.SetLevel(sentry.LevelError)
hub.CaptureMessage(fmt.Sprintf("%s %s returned %d", c.Request.Method, path, c.Writer.Status()))
})
}
}
}
84 changes: 84 additions & 0 deletions internal/server/sentry_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package server

import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"

"github.com/getsentry/sentry-go"
sentrygin "github.com/getsentry/sentry-go/gin"
"github.com/gin-gonic/gin"
)

type recordingTransport struct {
mu sync.Mutex
events []*sentry.Event
}

func (*recordingTransport) Configure(sentry.ClientOptions) {}
func (*recordingTransport) Flush(time.Duration) bool { return true }
func (*recordingTransport) FlushWithContext(context.Context) bool {
return true
}
func (*recordingTransport) Close() {}
func (t *recordingTransport) SendEvent(event *sentry.Event) {
t.mu.Lock()
defer t.mu.Unlock()
t.events = append(t.events, event)
}

func TestSentryMiddlewareCapturesServerErrorsAndPanics(t *testing.T) {
gin.SetMode(gin.TestMode)
transport := &recordingTransport{}
client, err := sentry.NewClient(sentry.ClientOptions{
Dsn: "https://public@example.com/1",
Transport: transport,
})
if err != nil {
t.Fatal(err)
}
hub := sentry.NewHub(client, sentry.NewScope())

router := gin.New()
router.Use(gin.RecoveryWithWriter(io.Discard), sentrygin.New(sentrygin.Options{Repanic: true}), sentryServerErrorMiddleware())
router.GET("/items/:id", func(c *gin.Context) { c.Status(http.StatusInternalServerError) })
router.GET("/panic", func(c *gin.Context) { panic("test panic") })
router.GET("/ok", func(c *gin.Context) { c.Status(http.StatusOK) })

for _, path := range []string{"/items/secret-id?token=secret", "/panic", "/ok"} {
request := httptest.NewRequest(http.MethodGet, path, nil)
request = request.WithContext(sentry.SetHubOnContext(request.Context(), hub))
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
}
client.Flush(2 * time.Second)

transport.mu.Lock()
defer transport.mu.Unlock()
if len(transport.events) != 2 {
t.Fatalf("captured %d events, want 2", len(transport.events))
}
var sawStatus, sawPanic bool
for _, event := range transport.events {
if event.Message == "GET /items/:id returned 500" {
sawStatus = true
if event.Level != sentry.LevelError {
t.Fatalf("5xx event level = %q, want error", event.Level)
}
}
if len(event.Exception) > 0 || strings.Contains(event.Message, "test panic") {
sawPanic = true
}
if strings.Contains(event.Message, "secret") {
t.Fatalf("event message contains request parameter: %q", event.Message)
}
}
if !sawStatus || !sawPanic {
t.Fatalf("missing status or panic event: status=%t panic=%t", sawStatus, sawPanic)
}
}
Loading