├── .dockerignore ├── .github └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── .goreleaser.yml ├── Makefile ├── README.md ├── access_counter.go ├── access_counter_test.go ├── auth.go ├── auth_test.go ├── cmd └── mirage-ecs │ └── main.go ├── config.go ├── config_auth_test.go ├── config_sample.yml ├── config_test.go ├── data └── .gitkeep ├── docker ├── Dockerfile └── example-config.yml ├── docs ├── mirage-ecs-launcher.png └── mirage-ecs-list.png ├── e2e_test.go ├── ecs-task-def.json ├── ecs.go ├── ecs_test.go ├── export_test.go ├── go.mod ├── go.sum ├── html ├── launcher.html ├── layout.html └── list.html ├── local.go ├── log.go ├── mirage.go ├── purge.go ├── purge_test.go ├── reverseproxy.go ├── reverseproxy_test.go ├── route53.go ├── terraform ├── .gitignore ├── .terraform-version ├── .terraform.lock.hcl ├── README.md ├── acm.tf ├── alb.tf ├── config.tf ├── config.yaml ├── ecs-service-def.jsonnet ├── ecs-task-def.jsonnet ├── ecs.tf ├── ecspresso.jsonnet ├── iam.tf ├── logs.tf ├── route53.tf ├── s3.tf ├── sg.tf └── vpc.tf ├── transport_test.go ├── types.go ├── webapi.go └── webapi_test.go /.dockerignore: -------------------------------------------------------------------------------- 1 | pkg 2 | terraform/ 3 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | branches: 6 | - "!**/*" 7 | tags: 8 | - "v*" 9 | 10 | jobs: 11 | goreleaser: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | - name: Set up Go 17 | uses: actions/setup-go@v5 18 | with: 19 | go-version: "1.24" 20 | - name: Run GoReleaser 21 | uses: goreleaser/goreleaser-action@v5 22 | with: 23 | version: latest 24 | args: release --clean 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | - name: Release Image 28 | run: | 29 | echo ${{ secrets.GITHUB_TOKEN }} | docker login ghcr.io -u $GITHUB_ACTOR --password-stdin 30 | make push-image 31 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | on: [push, pull_request] 3 | jobs: 4 | test: 5 | strategy: 6 | matrix: 7 | go: 8 | - "1.22" 9 | - "1.23" 10 | - "1.24" 11 | name: Build 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Set up Go 15 | uses: actions/setup-go@v5 16 | with: 17 | go-version: ${{ matrix.go }} 18 | id: go 19 | 20 | - name: Check out code into the Go module directory 21 | uses: actions/checkout@v4 22 | 23 | - name: Build & Test 24 | run: | 25 | make test 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | data 2 | mirage 3 | ./mirage-ecs 4 | *~ 5 | .envrc 6 | pkg 7 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | # This is an example goreleaser.yaml file with some sane defaults. 2 | # Make sure to check the documentation at http://goreleaser.com 3 | before: 4 | hooks: 5 | - go mod download 6 | builds: 7 | - env: 8 | - CGO_ENABLED=0 9 | main: cmd/mirage-ecs/main.go 10 | goos: 11 | - windows 12 | - darwin 13 | - linux 14 | goarch: 15 | - amd64 16 | - arm64 17 | ldflags: -s -w -X main.Version={{.Version}} -X main.buildDate={{.Date}} 18 | archives: 19 | - files: 20 | - config_sample.yml 21 | - html/* 22 | release: 23 | prerelease: auto 24 | checksum: 25 | name_template: "checksums.txt" 26 | snapshot: 27 | name_template: "{{ .Tag }}-next" 28 | changelog: 29 | sort: asc 30 | filters: 31 | exclude: 32 | - "^docs:" 33 | - "^test:" 34 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | GIT_VER := $(shell git describe --tags) 2 | DATE := $(shell date +%Y-%m-%dT%H:%M:%S%z) 3 | export GO111MODULE := on 4 | 5 | mirage-ecs: *.go cmd/mirage-ecs/*.go go.mod go.sum 6 | CGO_ENABLED=0 go build -ldflags "-X main.Version=$(GIT_VER) -X main.buildDate=$(DATE)" -o mirage-ecs ./cmd/mirage-ecs/main.go 7 | 8 | clean: 9 | rm -rf dist/* mirage-ecs 10 | 11 | run: mirage-ecs 12 | ./mirage-ecs 13 | 14 | packages: 15 | goreleaser release --rm-dist --snapshot --skip-publish 16 | 17 | docker-image: 18 | docker build -t ghcr.io/acidlemon/mirage-ecs:$(GIT_VER) -f docker/Dockerfile . 19 | 20 | push-image: docker-image 21 | docker push ghcr.io/acidlemon/mirage-ecs:$(GIT_VER) 22 | 23 | test: 24 | go test -v ./... 25 | 26 | install: 27 | go install github.com/acidlemon/mirage-ecs/v2/cmd/mirage-ecs 28 | -------------------------------------------------------------------------------- /access_counter.go: -------------------------------------------------------------------------------- 1 | package mirageecs 2 | 3 | import ( 4 | "sync" 5 | "time" 6 | ) 7 | 8 | // accessCount is a map for access count 9 | // key is a time truncated by accessCounter.unit 10 | type accessCount map[time.Time]int64 11 | 12 | // accessCounter is a thread-safe counter for access 13 | type AccessCounter struct { 14 | mu *sync.Mutex 15 | unit time.Duration 16 | count accessCount 17 | } 18 | 19 | // NewAccessCounter returns a new access counter 20 | // unit is the time unit for the counter (default: time.Minute) 21 | func NewAccessCounter(unit time.Duration) *AccessCounter { 22 | if unit == 0 { 23 | unit = time.Minute 24 | } 25 | c := &AccessCounter{ 26 | mu: new(sync.Mutex), 27 | count: make(accessCount, 2), // 2 is enough for most cases 28 | unit: unit, 29 | } 30 | c.fill() 31 | return c 32 | } 33 | 34 | // Add increments the access counter 35 | func (c *AccessCounter) Add() { 36 | c.mu.Lock() 37 | defer c.mu.Unlock() 38 | now := time.Now().Truncate(c.unit) 39 | c.count[now]++ 40 | } 41 | 42 | // Collect returns the access count and resets the counter 43 | func (c *AccessCounter) Collect() accessCount { 44 | c.mu.Lock() 45 | defer c.mu.Unlock() 46 | r := make(accessCount, len(c.count)) 47 | for k, v := range c.count { 48 | r[k] = v 49 | delete(c.count, k) 50 | } 51 | c.fill() 52 | return r 53 | } 54 | 55 | func (c *AccessCounter) fill() { 56 | c.count[time.Now().Truncate(c.unit)] = 0 57 | } 58 | -------------------------------------------------------------------------------- /access_counter_test.go: -------------------------------------------------------------------------------- 1 | package mirageecs_test 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | mirageecs "github.com/acidlemon/mirage-ecs/v2" 8 | ) 9 | 10 | func TestAccessCounter(t *testing.T) { 11 | c := mirageecs.NewAccessCounter(time.Second) 12 | start := time.Now().Truncate(time.Second) 13 | c.Add() 14 | c.Add() 15 | c.Add() 16 | time.Sleep(time.Second) 17 | c.Add() 18 | c.Add() 19 | c.Add() 20 | c.Add() 21 | c.Add() 22 | r := c.Collect() 23 | if len(r) != 2 { 24 | t.Errorf("could not collect access count %#v", r) 25 | } 26 | if r[start] != 3 { 27 | t.Errorf("could not collect access count %#v", r) 28 | } 29 | if r[start.Add(time.Second)] != 5 { 30 | t.Errorf("could not collect access count %#v", r) 31 | } 32 | r2 := c.Collect() 33 | for _, v := range r2 { 34 | if v != 0 { 35 | t.Errorf("counter should be zero %#v", r2) 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /auth.go: -------------------------------------------------------------------------------- 1 | package mirageecs 2 | 3 | import ( 4 | "encoding/base64" 5 | "fmt" 6 | "log/slog" 7 | "net/http" 8 | "strings" 9 | "sync" 10 | "time" 11 | 12 | "github.com/fujiwara/go-amzn-oidc/validator" 13 | "github.com/golang-jwt/jwt/v4" 14 | ) 15 | 16 | type Auth struct { 17 | Basic *AuthMethodBasic `yaml:"basic"` 18 | Token *AuthMethodToken `yaml:"token"` 19 | AmznOIDC *AuthMethodAmznOIDC `yaml:"amzn_oidc"` 20 | CookieSecret string `yaml:"cookie_secret"` 21 | 22 | jwtParser *jwt.Parser 23 | jwtKeyFunc func(*jwt.Token) (interface{}, error) 24 | once sync.Once 25 | } 26 | 27 | type Authorizer func(req *http.Request, res http.ResponseWriter) (bool, error) 28 | 29 | func (a *Auth) ByBasic(req *http.Request, res http.ResponseWriter) (bool, error) { 30 | if a == nil || a.Basic == nil { 31 | return false, nil 32 | } 33 | if ok := a.Basic.Match(req.Header); ok { 34 | slog.Debug("basic auth succeeded") 35 | return ok, nil 36 | } else { 37 | slog.Debug("basic auth failed. set WWW-Authenticate header") 38 | res.Header().Set("WWW-Authenticate", "Basic realm=\"Restricted\"") 39 | } 40 | return false, nil 41 | } 42 | 43 | func (a *Auth) ByToken(req *http.Request, res http.ResponseWriter) (bool, error) { 44 | if a == nil || a.Token == nil { 45 | return false, nil 46 | } 47 | if ok := a.Token.Match(req.Header); ok { 48 | slog.Debug("token auth succeeded") 49 | return ok, nil 50 | } 51 | slog.Debug("token auth failed") 52 | return false, nil 53 | } 54 | 55 | func (a *Auth) ByAmznOIDC(req *http.Request, res http.ResponseWriter) (bool, error) { 56 | if a == nil || a.AmznOIDC == nil { 57 | return false, nil 58 | } 59 | if ok, err := a.AmznOIDC.Match(req.Header); err != nil { 60 | return false, err 61 | } else if ok { 62 | slog.Debug("amzn_oidc auth succeeded") 63 | return true, nil 64 | } 65 | slog.Debug("amzn_oidc auth failed") 66 | return false, nil 67 | } 68 | 69 | func (a *Auth) Do(req *http.Request, res http.ResponseWriter, runs ...Authorizer) (bool, error) { 70 | if a == nil { 71 | // no auth 72 | return true, nil 73 | } 74 | for _, run := range runs { 75 | if ok, err := run(req, res); err != nil { 76 | return false, fmt.Errorf("authorizer %v errored: %w", run, err) 77 | } else if ok { 78 | return true, nil 79 | } 80 | } 81 | return false, nil 82 | } 83 | 84 | func (a *Auth) NewAuthCookie(expire time.Duration, domain string) (*http.Cookie, error) { 85 | expireAt := time.Now().Add(expire) 86 | 87 | if a == nil || a.CookieSecret == "" { 88 | return &http.Cookie{}, nil 89 | } 90 | 91 | token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ 92 | "expire_at": expireAt.Unix(), 93 | }) 94 | tokenStr, err := token.SignedString([]byte(a.CookieSecret)) 95 | if err != nil { 96 | return nil, fmt.Errorf("failed to sign cookie: %w", err) 97 | } 98 | return &http.Cookie{ 99 | Name: AuthCookieName, 100 | Value: tokenStr, 101 | Expires: expireAt, 102 | Domain: domain, 103 | HttpOnly: true, 104 | SameSite: http.SameSiteLaxMode, 105 | Secure: true, 106 | }, nil 107 | } 108 | 109 | func (a *Auth) ValidateAuthCookie(c *http.Cookie) error { 110 | if a == nil || a.CookieSecret == "" { 111 | return fmt.Errorf("cookie_secret is not set") 112 | } 113 | a.once.Do(func() { 114 | a.jwtParser = jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name})) 115 | a.jwtKeyFunc = func(token *jwt.Token) (interface{}, error) { 116 | return []byte(a.CookieSecret), nil 117 | } 118 | }) 119 | token, err := a.jwtParser.Parse(c.Value, a.jwtKeyFunc) 120 | if err != nil { 121 | return fmt.Errorf("failed to parse cookie: %w", err) 122 | } 123 | if !token.Valid { 124 | return fmt.Errorf("invalid cookie: %v", token) 125 | } 126 | claims, ok := token.Claims.(jwt.MapClaims) 127 | if !ok { 128 | return fmt.Errorf("invalid claims: %v", token.Claims) 129 | } 130 | expireAt, ok := claims["expire_at"].(float64) 131 | if !ok { 132 | return fmt.Errorf("invalid expire_at: %v", claims["expire_at"]) 133 | } 134 | if time.Now().Unix() >= int64(expireAt) { 135 | return fmt.Errorf("already expired: %v", expireAt) 136 | } 137 | return nil 138 | } 139 | 140 | type AuthMethodBasic struct { 141 | Username string `yaml:"username"` 142 | Password string `yaml:"password"` 143 | 144 | gen sync.Once 145 | expected string 146 | } 147 | 148 | func (b *AuthMethodBasic) Match(h http.Header) bool { 149 | if b == nil { 150 | return false 151 | } 152 | slog.Debug(f("auth basic %s %s", b.Username, b.Password)) 153 | if b.Username == "" || b.Password == "" || h.Get("Authorization") == "" { 154 | return false 155 | } 156 | b.gen.Do(func() { 157 | b.expected = "Basic " + base64.StdEncoding.EncodeToString([]byte(b.Username+":"+b.Password)) 158 | }) 159 | slog.Debug(f("auth basic comparing %s %s %s", b.Username, b.Password, h.Get("Authorization"))) 160 | if h.Get("Authorization") == b.expected { 161 | slog.Debug(f("auth basic succeeded")) 162 | return true 163 | } 164 | slog.Warn("auth basic failed") 165 | return false 166 | } 167 | 168 | type AuthMethodToken struct { 169 | Token string `yaml:"token"` 170 | Header string `yaml:"header"` 171 | } 172 | 173 | func (b *AuthMethodToken) Match(h http.Header) bool { 174 | if b == nil { 175 | return false 176 | } 177 | sent := h.Get(b.Header) 178 | if b.Token == "" || sent == "" { 179 | return false 180 | } 181 | slog.Debug(f("auth token comparing %s %s %s", b.Header, b.Token, sent)) 182 | if b.Token == sent { 183 | slog.Debug("auth token succeeded") 184 | return true 185 | } 186 | slog.Warn(f("auth token (header=%s) does not match", b.Header)) 187 | return false 188 | } 189 | 190 | type AuthMethodAmznOIDC struct { 191 | Claim string `yaml:"claim"` // e.g. "email" see alsohttps://openid.net/specs/openid-connect-core-1_0.html#StandardClaims 192 | Matchers []*ClaimMatcher `yaml:"matchers"` 193 | } 194 | 195 | func (a *AuthMethodAmznOIDC) Match(h http.Header) (bool, error) { 196 | if a == nil { 197 | return false, nil 198 | } 199 | if a.Claim == "" { 200 | return false, nil 201 | } 202 | slog.Debug(f("auth amzn_oidc comparing %s with %s", a.Claim, h.Get("x-amzn-oidc-data"))) 203 | claims, err := validator.Validate(h.Get("x-amzn-oidc-data")) 204 | if err != nil { 205 | return false, fmt.Errorf("failed to validate x-amzn-oidc-data: %s", err) 206 | } 207 | return a.MatchClaims(claims), nil 208 | } 209 | 210 | func (a *AuthMethodAmznOIDC) MatchClaims(claims map[string]interface{}) bool { 211 | v, ok := claims[a.Claim] 212 | if !ok { 213 | slog.Warn(f("auth amzn_oidc claim[%s] not found in claims", a.Claim)) 214 | return false 215 | } 216 | vs, ok := v.(string) 217 | if !ok { 218 | slog.Warn(f("auth amzn_oidc claim[%s] is not a string: %v", a.Claim, v)) 219 | return false 220 | } 221 | for _, m := range a.Matchers { 222 | if m.Match(vs) { 223 | slog.Debug(f("auth amzn_oidc claim[%s]=%s matches %#v", a.Claim, v, m)) 224 | return true 225 | } 226 | slog.Debug(f("auth amzn_oidc claim[%s]=%s does not match %#v", a.Claim, v, m)) 227 | } 228 | slog.Warn(f("auth amzn_oidc claim[%s]=%s does not match any matchers", a.Claim, vs)) 229 | return false 230 | } 231 | 232 | type ClaimMatcher struct { 233 | Exact string `yaml:"exact"` 234 | Suffix string `yaml:"suffix"` 235 | } 236 | 237 | func (m *ClaimMatcher) Match(s string) bool { 238 | if m.Exact != "" { 239 | return m.Exact == s 240 | } else if m.Suffix != "" { 241 | return strings.HasSuffix(s, m.Suffix) 242 | } else { 243 | return false 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /auth_test.go: -------------------------------------------------------------------------------- 1 | package mirageecs_test 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | "time" 7 | 8 | mirageecs "github.com/acidlemon/mirage-ecs/v2" 9 | ) 10 | 11 | func TestAuthMethodToken_Match(t *testing.T) { 12 | type fields struct { 13 | Token string 14 | Header string 15 | } 16 | type args struct { 17 | h http.Header 18 | } 19 | tests := []struct { 20 | name string 21 | fields fields 22 | args args 23 | want bool 24 | }{ 25 | { 26 | name: "Token matches", 27 | fields: fields{ 28 | Token: "mytoken", 29 | Header: "Authorization", 30 | }, 31 | args: args{ 32 | h: http.Header{ 33 | "Authorization": []string{"mytoken"}, 34 | }, 35 | }, 36 | want: true, 37 | }, 38 | { 39 | name: "Token does not match", 40 | fields: fields{ 41 | Token: "mytoken", 42 | Header: "Authorization", 43 | }, 44 | args: args{ 45 | h: http.Header{ 46 | "Authorization": []string{"othertoken"}, 47 | }, 48 | }, 49 | want: false, 50 | }, 51 | { 52 | name: "Token is empty", 53 | fields: fields{ 54 | Token: "", 55 | Header: "Authorization", 56 | }, 57 | args: args{ 58 | h: http.Header{ 59 | "Authorization": []string{"mytoken"}, 60 | }, 61 | }, 62 | want: false, 63 | }, 64 | { 65 | name: "Header is empty", 66 | fields: fields{ 67 | Token: "mytoken", 68 | Header: "", 69 | }, 70 | args: args{ 71 | h: http.Header{ 72 | "Authorization": []string{"mytoken"}, 73 | }, 74 | }, 75 | want: false, 76 | }, 77 | { 78 | name: "Header does not exist", 79 | fields: fields{ 80 | Token: "mytoken", 81 | Header: "Authorization", 82 | }, 83 | args: args{ 84 | h: http.Header{}, 85 | }, 86 | want: false, 87 | }, 88 | { 89 | name: "Nil AuthMethodToken", 90 | fields: fields{ 91 | Token: "", 92 | Header: "", 93 | }, 94 | args: args{ 95 | h: http.Header{}, 96 | }, 97 | want: false, 98 | }, 99 | } 100 | for _, tt := range tests { 101 | t.Run(tt.name, func(t *testing.T) { 102 | b := &mirageecs.AuthMethodToken{ 103 | Token: tt.fields.Token, 104 | Header: tt.fields.Header, 105 | } 106 | if got := b.Match(tt.args.h); got != tt.want { 107 | t.Errorf("mirageecs.mirageecs.AuthMethodToken.Match() = %v, want %v", got, tt.want) 108 | } 109 | }) 110 | } 111 | } 112 | 113 | func TestAuthMethodAmznOIDC_Match(t *testing.T) { 114 | type fields struct { 115 | Claim string 116 | Matchers []*mirageecs.ClaimMatcher 117 | } 118 | type args struct { 119 | Claims map[string]any 120 | } 121 | tests := []struct { 122 | name string 123 | fields fields 124 | args args 125 | want bool 126 | }{ 127 | { 128 | name: "Claim matches", 129 | fields: fields{ 130 | Claim: "email", 131 | Matchers: []*mirageecs.ClaimMatcher{ 132 | { 133 | Exact: "user@example.com", 134 | }, 135 | }, 136 | }, 137 | args: args{ 138 | Claims: map[string]any{ 139 | "email": "user@example.com", 140 | }, 141 | }, 142 | want: true, 143 | }, 144 | { 145 | name: "Claim does not match", 146 | fields: fields{ 147 | Claim: "email", 148 | Matchers: []*mirageecs.ClaimMatcher{ 149 | { 150 | Exact: "user@example.com", 151 | }, 152 | }, 153 | }, 154 | args: args{ 155 | Claims: map[string]any{ 156 | "email": "xxx@example.com", 157 | }, 158 | }, 159 | want: false, 160 | }, 161 | { 162 | name: "Claim is empty", 163 | fields: fields{ 164 | Claim: "", 165 | Matchers: []*mirageecs.ClaimMatcher{ 166 | { 167 | Exact: "user@example.com", 168 | }, 169 | }, 170 | }, 171 | args: args{ 172 | Claims: map[string]any{ 173 | "email": "user@example.com", 174 | }, 175 | }, 176 | want: false, 177 | }, 178 | { 179 | name: "Claim matches suffix", 180 | fields: fields{ 181 | Claim: "email", 182 | Matchers: []*mirageecs.ClaimMatcher{ 183 | { 184 | Suffix: "@example.com", 185 | }, 186 | }, 187 | }, 188 | args: args{ 189 | Claims: map[string]any{ 190 | "email": "user@example.com", 191 | }, 192 | }, 193 | want: true, 194 | }, 195 | { 196 | name: "Claim does not match suffix", 197 | fields: fields{ 198 | Claim: "email", 199 | Matchers: []*mirageecs.ClaimMatcher{ 200 | { 201 | Suffix: "@example.com", 202 | }, 203 | }, 204 | }, 205 | args: args{ 206 | Claims: map[string]any{ 207 | "email": "user@example.net", 208 | }, 209 | }, 210 | want: false, 211 | }, 212 | { 213 | name: "Claim matches any suffix", 214 | fields: fields{ 215 | Claim: "email", 216 | Matchers: []*mirageecs.ClaimMatcher{ 217 | { 218 | Suffix: "@example.com", 219 | }, 220 | { 221 | Suffix: "@example.net", 222 | }, 223 | }, 224 | }, 225 | args: args{ 226 | Claims: map[string]any{ 227 | "email": "user@example.net", 228 | }, 229 | }, 230 | want: true, 231 | }, 232 | { 233 | name: "Claim matches any exact", 234 | fields: fields{ 235 | Claim: "email", 236 | Matchers: []*mirageecs.ClaimMatcher{ 237 | { 238 | Exact: "foo@example.com", 239 | }, 240 | { 241 | Exact: "bar@example.net", 242 | }, 243 | }, 244 | }, 245 | args: args{ 246 | Claims: map[string]any{ 247 | "email": "bar@example.net", 248 | }, 249 | }, 250 | want: true, 251 | }, 252 | { 253 | name: "Claim match both", 254 | fields: fields{ 255 | Claim: "email", 256 | Matchers: []*mirageecs.ClaimMatcher{ 257 | { 258 | Suffix: "@example.com", 259 | }, 260 | { 261 | Exact: "user@example.com", 262 | }, 263 | }, 264 | }, 265 | args: args{ 266 | Claims: map[string]any{ 267 | "email": "user@example.com", 268 | }, 269 | }, 270 | want: true, 271 | }, 272 | { 273 | name: "Claim does not match both", 274 | fields: fields{ 275 | Claim: "email", 276 | Matchers: []*mirageecs.ClaimMatcher{ 277 | { 278 | Suffix: "@example.net", 279 | }, 280 | { 281 | Exact: "user@example.net", 282 | }, 283 | }, 284 | }, 285 | args: args{ 286 | Claims: map[string]any{ 287 | "email": "user@example.com", 288 | }, 289 | }, 290 | want: false, 291 | }, 292 | } 293 | for _, tt := range tests { 294 | t.Run(tt.name, func(t *testing.T) { 295 | a := &mirageecs.AuthMethodAmznOIDC{ 296 | Claim: tt.fields.Claim, 297 | Matchers: tt.fields.Matchers, 298 | } 299 | got := a.MatchClaims(tt.args.Claims) 300 | if got != tt.want { 301 | t.Errorf("AuthMethodAmznOIDC.MatchClaims() = %v, want %v", got, tt.want) 302 | } 303 | }) 304 | } 305 | } 306 | 307 | func TestAuthMethodBasic_Match(t *testing.T) { 308 | type fields struct { 309 | Username string 310 | Password string 311 | } 312 | type args struct { 313 | h http.Header 314 | } 315 | tests := []struct { 316 | name string 317 | fields fields 318 | args args 319 | want bool 320 | }{ 321 | { 322 | name: "Username and password match", 323 | fields: fields{ 324 | Username: "user", 325 | Password: "pass", 326 | }, 327 | args: args{ 328 | h: http.Header{ 329 | "Authorization": []string{"Basic dXNlcjpwYXNz"}, 330 | }, 331 | }, 332 | want: true, 333 | }, 334 | { 335 | name: "Username and password do not match", 336 | fields: fields{ 337 | Username: "user", 338 | Password: "pass", 339 | }, 340 | args: args{ 341 | h: http.Header{ 342 | "Authorization": []string{"Basic dXNlcnM6cGFzcw=="}, 343 | }, 344 | }, 345 | want: false, 346 | }, 347 | { 348 | name: "Username is empty", 349 | fields: fields{ 350 | Username: "", 351 | Password: "pass", 352 | }, 353 | args: args{ 354 | h: http.Header{ 355 | "Authorization": []string{"Basic OnBhc3M="}, 356 | }, 357 | }, 358 | want: false, 359 | }, 360 | { 361 | name: "Password is empty", 362 | fields: fields{ 363 | Username: "user", 364 | Password: "", 365 | }, 366 | args: args{ 367 | h: http.Header{ 368 | "Authorization": []string{"Basic dXNlcjo="}, 369 | }, 370 | }, 371 | want: false, 372 | }, 373 | { 374 | name: "Authorization header is empty", 375 | fields: fields{ 376 | Username: "user", 377 | Password: "pass", 378 | }, 379 | args: args{ 380 | h: http.Header{}, 381 | }, 382 | want: false, 383 | }, 384 | { 385 | name: "Nil AuthMethodBasic", 386 | fields: fields{ 387 | Username: "", 388 | Password: "", 389 | }, 390 | args: args{ 391 | h: http.Header{}, 392 | }, 393 | want: false, 394 | }, 395 | } 396 | for _, tt := range tests { 397 | t.Run(tt.name, func(t *testing.T) { 398 | b := &mirageecs.AuthMethodBasic{ 399 | Username: tt.fields.Username, 400 | Password: tt.fields.Password, 401 | } 402 | if got := b.Match(tt.args.h); got != tt.want { 403 | t.Errorf("AuthMethodBasic.Match() = %v, want %v", got, tt.want) 404 | } 405 | }) 406 | } 407 | } 408 | 409 | func TestAuthCookie(t *testing.T) { 410 | auth := mirageecs.Auth{ 411 | CookieSecret: "secret", 412 | } 413 | cookie, err := auth.NewAuthCookie(time.Second, ".example.com") 414 | if err != nil { 415 | t.Error(err) 416 | } 417 | if cookie.Name != "mirage-ecs-auth" { 418 | t.Errorf("invalid cookie name: %s", cookie.Name) 419 | } 420 | if cookie.Value == "" { 421 | t.Errorf("invalid cookie value: %s", cookie.Value) 422 | } 423 | if cookie.Domain != ".example.com" { 424 | t.Errorf("invalid cookie domain: %s", cookie.Domain) 425 | } 426 | if cookie.HttpOnly != true { 427 | t.Errorf("invalid cookie httponly: %v", cookie.HttpOnly) 428 | } 429 | if cookie.Expires.IsZero() { 430 | t.Errorf("invalid cookie expires: %v", cookie.Expires) 431 | } 432 | if err := auth.ValidateAuthCookie(cookie); err != nil { 433 | t.Error(err) 434 | } 435 | 436 | // invalid cookie 437 | orig := cookie.Value 438 | cookie.Value = cookie.Value + "xxx" 439 | if err := auth.ValidateAuthCookie(cookie); err == nil { 440 | t.Error("should be invalid") 441 | } 442 | // restore 443 | cookie.Value = orig 444 | t.Log(cookie.Value) 445 | 446 | // expired 447 | time.Sleep(2 * time.Second) 448 | if err := auth.ValidateAuthCookie(cookie); err == nil { 449 | t.Error("should be expired") 450 | } 451 | } 452 | -------------------------------------------------------------------------------- /cmd/mirage-ecs/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "log/slog" 8 | "os" 9 | "os/signal" 10 | "strings" 11 | "syscall" 12 | 13 | mirageecs "github.com/acidlemon/mirage-ecs/v2" 14 | "gopkg.in/yaml.v2" 15 | ) 16 | 17 | var ( 18 | Version string 19 | buildDate string 20 | ) 21 | 22 | func main() { 23 | confFile := flag.String("conf", "", "specify config file or S3 URL") 24 | domain := flag.String("domain", ".local", "reverse proxy suffix") 25 | var showVersion, showConfig, localMode, compatV1 bool 26 | var defaultPort int 27 | var logFormat, logLevel string 28 | flag.BoolVar(&showVersion, "version", false, "show version") 29 | flag.BoolVar(&showVersion, "v", false, "show version") 30 | flag.BoolVar(&showConfig, "x", false, "show config") 31 | flag.BoolVar(&localMode, "local", false, "local mode (for development)") 32 | flag.BoolVar(&compatV1, "compat-v1", false, "compatibility mode for v1") 33 | flag.IntVar(&defaultPort, "default-port", 80, "default port number") 34 | flag.StringVar(&logFormat, "log-format", "text", "log format (text, json)") 35 | flag.StringVar(&logLevel, "log-level", "info", "log level (debug, info, warn, error)") 36 | flag.VisitAll(overrideWithEnv) 37 | flag.Parse() 38 | 39 | mirageecs.SetLogLevel(logLevel) 40 | 41 | if showVersion { 42 | fmt.Printf("mirage-ecs %s (%s)\n", Version, buildDate) 43 | return 44 | } 45 | 46 | ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) 47 | defer stop() 48 | 49 | cfg, err := mirageecs.NewConfig(ctx, &mirageecs.ConfigParams{ 50 | Path: *confFile, 51 | LocalMode: localMode, 52 | Domain: *domain, 53 | DefaultPort: defaultPort, 54 | CompatV1: compatV1, 55 | LogFormat: logFormat, 56 | }) 57 | if err != nil { 58 | slog.Error(err.Error()) 59 | os.Exit(1) 60 | } 61 | if showConfig { 62 | yaml.NewEncoder(os.Stdout).Encode(cfg) 63 | return 64 | } 65 | mirageecs.Version = Version 66 | app := mirageecs.New(ctx, cfg) 67 | if err := app.Run(ctx); err != nil { 68 | slog.Error(err.Error()) 69 | os.Exit(1) 70 | } 71 | } 72 | 73 | func overrideWithEnv(f *flag.Flag) { 74 | name := strings.ToUpper(f.Name) 75 | name = strings.Replace(name, "-", "_", -1) 76 | if s := os.Getenv("MIRAGE_" + name); s != "" { 77 | f.Value.Set(s) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package mirageecs 2 | 3 | import ( 4 | "context" 5 | "encoding/base64" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "log/slog" 10 | "net" 11 | "net/http" 12 | "net/url" 13 | "os" 14 | "path" 15 | "path/filepath" 16 | "regexp" 17 | "strings" 18 | "time" 19 | 20 | "github.com/aws/aws-sdk-go-v2/aws" 21 | awsv2Config "github.com/aws/aws-sdk-go-v2/config" 22 | "github.com/aws/aws-sdk-go-v2/service/ecs" 23 | "github.com/aws/aws-sdk-go-v2/service/ecs/types" 24 | "github.com/aws/aws-sdk-go-v2/service/s3" 25 | metadata "github.com/brunoscheufler/aws-ecs-metadata-go" 26 | config "github.com/kayac/go-config" 27 | "github.com/labstack/echo/v4" 28 | ) 29 | 30 | var DefaultParameter = &Parameter{ 31 | Name: "branch", 32 | Env: "GIT_BRANCH", 33 | Rule: "", 34 | Required: true, 35 | Default: "", 36 | } 37 | 38 | type Config struct { 39 | Host Host `yaml:"host"` 40 | Listen Listen `yaml:"listen"` 41 | Network Network `yaml:"network"` 42 | HtmlDir string `yaml:"htmldir"` 43 | Parameter Parameters `yaml:"parameters"` 44 | ECS ECSCfg `yaml:"ecs"` 45 | Link Link `yaml:"link"` 46 | Auth *Auth `yaml:"auth"` 47 | Purge *Purge `yaml:"purge"` 48 | 49 | compatV1 bool 50 | localMode bool 51 | awscfg *aws.Config 52 | cleanups []func() error 53 | } 54 | 55 | type ECSCfg struct { 56 | Region string `yaml:"region"` 57 | Cluster string `yaml:"cluster"` 58 | CapacityProviderStrategy CapacityProviderStrategy `yaml:"capacity_provider_strategy"` 59 | LaunchType *string `yaml:"launch_type"` 60 | NetworkConfiguration *NetworkConfiguration `yaml:"network_configuration"` 61 | DefaultTaskDefinition string `yaml:"default_task_definition"` 62 | EnableExecuteCommand *bool `yaml:"enable_execute_command"` 63 | 64 | capacityProviderStrategy []types.CapacityProviderStrategyItem `yaml:"-"` 65 | networkConfiguration *types.NetworkConfiguration `yaml:"-"` 66 | } 67 | 68 | func (c ECSCfg) String() string { 69 | m := map[string]interface{}{ 70 | "region": c.Region, 71 | "cluster": c.Cluster, 72 | "capacity_provider_strategy": c.capacityProviderStrategy, 73 | "launch_type": c.LaunchType, 74 | "network_configuration": c.networkConfiguration, 75 | "default_task_definition": c.DefaultTaskDefinition, 76 | "enable_execute_command": c.EnableExecuteCommand, 77 | } 78 | b, _ := json.Marshal(m) 79 | return string(b) 80 | } 81 | 82 | func (c ECSCfg) validate() error { 83 | if c.Region == "" { 84 | return fmt.Errorf("region is required") 85 | } 86 | if c.Cluster == "" { 87 | return fmt.Errorf("cluster is required") 88 | } 89 | if c.LaunchType == nil && c.capacityProviderStrategy == nil { 90 | return fmt.Errorf("launch_type or capacity_provider_strategy is required") 91 | } 92 | if c.networkConfiguration == nil { 93 | return fmt.Errorf("network_configuration is required") 94 | } 95 | return nil 96 | } 97 | 98 | type CapacityProviderStrategy []*CapacityProviderStrategyItem 99 | 100 | func (s CapacityProviderStrategy) toSDK() []types.CapacityProviderStrategyItem { 101 | if len(s) == 0 { 102 | return nil 103 | } 104 | var items []types.CapacityProviderStrategyItem 105 | for _, item := range s { 106 | items = append(items, item.toSDK()) 107 | } 108 | return items 109 | } 110 | 111 | type CapacityProviderStrategyItem struct { 112 | CapacityProvider *string `yaml:"capacity_provider"` 113 | Weight int32 `yaml:"weight"` 114 | Base int32 `yaml:"base"` 115 | } 116 | 117 | func (i CapacityProviderStrategyItem) toSDK() types.CapacityProviderStrategyItem { 118 | return types.CapacityProviderStrategyItem{ 119 | CapacityProvider: i.CapacityProvider, 120 | Weight: i.Weight, 121 | Base: i.Base, 122 | } 123 | } 124 | 125 | type NetworkConfiguration struct { 126 | AwsVpcConfiguration *AwsVpcConfiguration `yaml:"awsvpc_configuration"` 127 | } 128 | 129 | func (c *NetworkConfiguration) toSDK() *types.NetworkConfiguration { 130 | if c == nil { 131 | return nil 132 | } 133 | return &types.NetworkConfiguration{ 134 | AwsvpcConfiguration: c.AwsVpcConfiguration.toSDK(), 135 | } 136 | } 137 | 138 | type AwsVpcConfiguration struct { 139 | AssignPublicIp string `yaml:"assign_public_ip"` 140 | SecurityGroups []string `yaml:"security_groups"` 141 | Subnets []string `yaml:"subnets"` 142 | } 143 | 144 | func (c *AwsVpcConfiguration) toSDK() *types.AwsVpcConfiguration { 145 | return &types.AwsVpcConfiguration{ 146 | AssignPublicIp: types.AssignPublicIp(c.AssignPublicIp), 147 | Subnets: c.Subnets, 148 | SecurityGroups: c.SecurityGroups, 149 | } 150 | } 151 | 152 | type Host struct { 153 | WebApi string `yaml:"webapi"` 154 | ReverseProxySuffix string `yaml:"reverse_proxy_suffix"` 155 | } 156 | 157 | type Link struct { 158 | HostedZoneID string `yaml:"hosted_zone_id"` 159 | DefaultTaskDefinitions []string `yaml:"default_task_definitions"` 160 | } 161 | 162 | type Listen struct { 163 | ForeignAddress string `yaml:"foreign_address,omitempty"` 164 | HTTP []PortMap `yaml:"http,omitempty"` 165 | HTTPS []PortMap `yaml:"https,omitempty"` 166 | } 167 | 168 | type PortMap struct { 169 | ListenPort int `yaml:"listen"` 170 | TargetPort int `yaml:"target"` 171 | RequireAuthCookie bool `yaml:"require_auth_cookie"` 172 | } 173 | 174 | type Parameter struct { 175 | Name string `yaml:"name"` 176 | Env string `yaml:"env"` 177 | Rule string `yaml:"rule"` 178 | Required bool `yaml:"required"` 179 | Regexp regexp.Regexp `yaml:"-"` 180 | Default string `yaml:"default"` 181 | Description string `yaml:"description"` 182 | Options []ParameterOption `yaml:"options"` 183 | } 184 | 185 | type ParameterOption struct { 186 | Label string `yaml:"label"` 187 | Value string `yaml:"value"` 188 | } 189 | 190 | type Parameters []*Parameter 191 | 192 | type ConfigParams struct { 193 | Path string 194 | Domain string 195 | LocalMode bool 196 | DefaultPort int 197 | CompatV1 bool 198 | LogFormat string 199 | } 200 | 201 | type Network struct { 202 | ProxyTimeout time.Duration `yaml:"proxy_timeout"` 203 | } 204 | 205 | const DefaultPort = 80 206 | const DefaultProxyTimeout = 0 207 | const AuthCookieName = "mirage-ecs-auth" 208 | const AuthCookieExpire = 24 * time.Hour 209 | 210 | func NewConfig(ctx context.Context, p *ConfigParams) (*Config, error) { 211 | domain := p.Domain 212 | if !strings.HasPrefix(domain, ".") { 213 | domain = "." + domain 214 | } 215 | if p.DefaultPort == 0 { 216 | p.DefaultPort = DefaultPort 217 | } 218 | // default config 219 | cfg := &Config{ 220 | Host: Host{ 221 | WebApi: "mirage" + domain, 222 | ReverseProxySuffix: domain, 223 | }, 224 | Listen: Listen{ 225 | ForeignAddress: "0.0.0.0", 226 | HTTP: []PortMap{ 227 | {ListenPort: p.DefaultPort, TargetPort: p.DefaultPort}, 228 | }, 229 | HTTPS: nil, 230 | }, 231 | Network: Network{ 232 | ProxyTimeout: DefaultProxyTimeout, 233 | }, 234 | HtmlDir: "./html", 235 | ECS: ECSCfg{ 236 | Region: os.Getenv("AWS_REGION"), 237 | }, 238 | Auth: nil, 239 | Purge: nil, 240 | 241 | localMode: p.LocalMode, 242 | compatV1: p.CompatV1, 243 | } 244 | opt := &slog.HandlerOptions{ 245 | Level: LogLevel, 246 | AddSource: true, 247 | } 248 | 249 | switch p.LogFormat { 250 | case "text", "": 251 | slog.SetDefault(slog.New(NewLogHandler(os.Stderr, opt))) 252 | case "json": 253 | slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stderr, opt))) 254 | default: 255 | return nil, fmt.Errorf("invalid log format (text or json): %s", p.LogFormat) 256 | } 257 | 258 | if awscfg, err := awsv2Config.LoadDefaultConfig(ctx, awsv2Config.WithRegion(cfg.ECS.Region)); err != nil { 259 | return nil, err 260 | } else { 261 | cfg.awscfg = &awscfg 262 | } 263 | 264 | if p.Path == "" { 265 | slog.Info(f("no config file specified, using default config with domain suffix: %s", domain)) 266 | } else { 267 | var content []byte 268 | var err error 269 | if strings.HasPrefix(p.Path, "s3://") { 270 | content, err = loadFromS3(ctx, cfg.awscfg, p.Path) 271 | } else { 272 | content, err = loadFromFile(p.Path) 273 | } 274 | if err != nil { 275 | return nil, fmt.Errorf("cannot load config: %s: %w", p.Path, err) 276 | } 277 | slog.Info(f("loading config file: %s", p.Path)) 278 | if err := config.LoadWithEnvBytes(&cfg, content); err != nil { 279 | return nil, fmt.Errorf("cannot load config: %s: %w", p.Path, err) 280 | } 281 | } 282 | 283 | addDefaultParameter := true 284 | for _, v := range cfg.Parameter { 285 | if v.Name == DefaultParameter.Name { 286 | addDefaultParameter = false 287 | break 288 | } 289 | } 290 | if addDefaultParameter { 291 | cfg.Parameter = append(cfg.Parameter, DefaultParameter) 292 | } 293 | 294 | for _, v := range cfg.Parameter { 295 | if v.Rule != "" { 296 | paramRegex, err := regexp.Compile(v.Rule) 297 | if err != nil { 298 | return nil, fmt.Errorf("invalid parameter rule: %s: %w", v.Rule, err) 299 | } 300 | v.Regexp = *paramRegex 301 | } 302 | } 303 | 304 | if strings.HasPrefix(cfg.HtmlDir, "s3://") { 305 | if err := cfg.downloadHTMLFromS3(ctx); err != nil { 306 | return nil, err 307 | } 308 | } 309 | 310 | if cfg.localMode { 311 | slog.Info("local mode: setting host suffix to .localtest.me") 312 | cfg.Host.ReverseProxySuffix = ".localtest.me" 313 | cfg.Host.WebApi = "mirage.localtest.me" 314 | slog.Info(f("You can access to http://mirage.localtest.me:%d/", cfg.Listen.HTTP[0].ListenPort)) 315 | } 316 | 317 | cfg.ECS.capacityProviderStrategy = cfg.ECS.CapacityProviderStrategy.toSDK() 318 | cfg.ECS.networkConfiguration = cfg.ECS.NetworkConfiguration.toSDK() 319 | 320 | if err := cfg.fillECSDefaults(ctx); err != nil { 321 | slog.Warn(f("failed to fill ECS defaults: %s", err)) 322 | } 323 | 324 | if cfg.Purge != nil { 325 | if err := cfg.Purge.Validate(); err != nil { 326 | return nil, fmt.Errorf("invalid purge config: %w", err) 327 | } 328 | } 329 | return cfg, nil 330 | } 331 | 332 | func (c *Config) Cleanup() { 333 | for _, fn := range c.cleanups { 334 | if err := fn(); err != nil { 335 | slog.Warn(f("failed to cleanup %s", err)) 336 | } 337 | } 338 | } 339 | 340 | func (c *Config) NewTaskRunner() TaskRunner { 341 | if c.localMode { 342 | return NewLocalTaskRunner(c) 343 | } else { 344 | return NewECSTaskRunner(c) 345 | } 346 | } 347 | 348 | func (c *Config) fillECSDefaults(ctx context.Context) error { 349 | if c.localMode { 350 | slog.Info("ECS config is not used in local mode") 351 | return nil 352 | } 353 | defer func() { 354 | if err := c.ECS.validate(); err != nil { 355 | slog.Error(f("invalid ECS config: %s", c.ECS)) 356 | slog.Error(f("ECS config is invalid '%s', so you may not be able to launch ECS tasks", err)) 357 | } else { 358 | slog.Info(f("built ECS config: %s", c.ECS)) 359 | } 360 | }() 361 | if c.ECS.Region == "" { 362 | c.ECS.Region = os.Getenv("AWS_REGION") 363 | slog.Info(f("AWS_REGION is not set, using region=%s", c.ECS.Region)) 364 | } 365 | if c.ECS.LaunchType == nil && c.ECS.CapacityProviderStrategy == nil { 366 | launchType := "FARGATE" 367 | c.ECS.LaunchType = &launchType 368 | slog.Info(f("launch_type and capacity_provider_strategy are not set, using launch_type=%s", *c.ECS.LaunchType)) 369 | } 370 | if c.ECS.EnableExecuteCommand == nil { 371 | c.ECS.EnableExecuteCommand = aws.Bool(true) 372 | slog.Info(f("enable_execute_command is not set, using enable_execute_command=%t", *c.ECS.EnableExecuteCommand)) 373 | } 374 | 375 | meta, err := metadata.Get(ctx, &http.Client{}) 376 | if err != nil { 377 | return err 378 | /* 379 | for local debugging 380 | meta = &metadata.TaskMetadataV4{ 381 | Cluster: "your test cluster", 382 | TaskARN: "your test task arn running on the cluster", 383 | } 384 | */ 385 | } 386 | slog.Debug(f("task metadata: %v", meta)) 387 | var cluster, taskArn, service string 388 | switch m := meta.(type) { 389 | case *metadata.TaskMetadataV3: 390 | cluster = m.Cluster 391 | taskArn = m.TaskARN 392 | case *metadata.TaskMetadataV4: 393 | cluster = m.Cluster 394 | taskArn = m.TaskARN 395 | } 396 | if c.ECS.Cluster == "" && cluster != "" { 397 | slog.Info(f("ECS cluster is set from task metadata: %s", cluster)) 398 | c.ECS.Cluster = cluster 399 | } 400 | 401 | svc := ecs.NewFromConfig(*c.awscfg) 402 | if out, err := svc.DescribeTasks(ctx, &ecs.DescribeTasksInput{ 403 | Cluster: aws.String(cluster), 404 | Tasks: []string{taskArn}, 405 | }); err != nil { 406 | return err 407 | } else { 408 | if len(out.Tasks) == 0 { 409 | return fmt.Errorf("cannot find task: %s", taskArn) 410 | } 411 | group := aws.ToString(out.Tasks[0].Group) 412 | if strings.HasPrefix(group, "service:") { 413 | service = group[8:] 414 | } 415 | } 416 | if out, err := svc.DescribeServices(ctx, &ecs.DescribeServicesInput{ 417 | Cluster: aws.String(cluster), 418 | Services: []string{service}, 419 | }); err != nil { 420 | return err 421 | } else { 422 | if len(out.Services) == 0 { 423 | return fmt.Errorf("cannot find service: %s", service) 424 | } 425 | if c.ECS.networkConfiguration == nil { 426 | c.ECS.networkConfiguration = out.Services[0].NetworkConfiguration 427 | slog.Info(f("network_configuration is not set, using network_configuration=%v", c.ECS.networkConfiguration)) 428 | } 429 | } 430 | return nil 431 | } 432 | 433 | func (cfg *Config) AuthMiddlewareForWeb(next echo.HandlerFunc) echo.HandlerFunc { 434 | return func(c echo.Context) error { 435 | req := c.Request() 436 | ok, err := cfg.Auth.Do(req, c.Response(), 437 | cfg.Auth.ByToken, cfg.Auth.ByAmznOIDC, cfg.Auth.ByBasic, 438 | ) 439 | if err != nil { 440 | slog.Error(f("auth error: %s", err)) 441 | return echo.ErrInternalServerError 442 | } 443 | if !ok { 444 | slog.Warn("all auth methods failed") 445 | return echo.ErrUnauthorized 446 | } 447 | 448 | // check origin header 449 | if req.Method == http.MethodPost { 450 | origin := c.Request().Header.Get("Origin") 451 | if origin == "" { 452 | slog.Error("missing origin header") 453 | return echo.ErrBadRequest 454 | } 455 | u, err := url.Parse(origin) 456 | if err != nil { 457 | slog.Error(f("invalid origin header: %s", origin)) 458 | return echo.ErrBadRequest 459 | } 460 | host, _, err := net.SplitHostPort(u.Host) 461 | if err != nil { 462 | host = u.Host // missing port 463 | } 464 | if host != cfg.Host.WebApi { 465 | slog.Error(f("invalid origin host: %s", u.Host)) 466 | return echo.ErrBadRequest 467 | } 468 | } 469 | 470 | cookie, err := cfg.Auth.NewAuthCookie(AuthCookieExpire, cfg.Host.ReverseProxySuffix) 471 | if err != nil { 472 | slog.Error(f("failed to create auth cookie: %s", err)) 473 | return echo.ErrInternalServerError 474 | } 475 | if cookie.Value != "" { 476 | c.SetCookie(cookie) 477 | } 478 | return next(c) 479 | } 480 | } 481 | 482 | func (cfg *Config) AuthMiddlewareForAPI(next echo.HandlerFunc) echo.HandlerFunc { 483 | return func(c echo.Context) error { 484 | // API allows only token auth 485 | ok, err := cfg.Auth.Do(c.Request(), c.Response(), cfg.Auth.ByToken) 486 | if err != nil { 487 | slog.Error(f("auth error: %s", err)) 488 | return echo.ErrInternalServerError 489 | } 490 | if !ok { 491 | slog.Warn(f("all auth methods failed")) 492 | return echo.ErrUnauthorized 493 | } 494 | return next(c) 495 | } 496 | } 497 | 498 | func (cfg *Config) CompatMiddlewareForAPI(next echo.HandlerFunc) echo.HandlerFunc { 499 | return func(c echo.Context) error { 500 | req := c.Request() 501 | contentType := req.Header.Get("Content-Type") 502 | switch cfg.compatV1 { 503 | case true: 504 | // allows any content type 505 | case false: 506 | if req.Method == http.MethodPost && !strings.HasPrefix(contentType, "application/json") { 507 | slog.Error(f("invalid content type: %s for %s %s", contentType, req.Method, req.URL.Path)) 508 | return echo.ErrBadRequest 509 | } 510 | } 511 | return next(c) 512 | } 513 | } 514 | 515 | func loadFromFile(p string) ([]byte, error) { 516 | f, err := os.Open(p) 517 | if err != nil { 518 | return nil, err 519 | } 520 | defer f.Close() 521 | return io.ReadAll(f) 522 | } 523 | 524 | func loadFromS3(ctx context.Context, awscfg *aws.Config, u string) ([]byte, error) { 525 | svc := s3.NewFromConfig(*awscfg) 526 | parsed, err := url.Parse(u) 527 | if err != nil { 528 | return nil, err 529 | } 530 | if parsed.Scheme != "s3" { 531 | return nil, fmt.Errorf("invalid scheme: %s", parsed.Scheme) 532 | } 533 | bucket := parsed.Host 534 | key := strings.TrimPrefix(parsed.Path, "/") 535 | out, err := svc.GetObject(ctx, &s3.GetObjectInput{ 536 | Bucket: aws.String(bucket), 537 | Key: aws.String(key), 538 | }) 539 | if err != nil { 540 | return nil, err 541 | } 542 | defer out.Body.Close() 543 | return io.ReadAll(out.Body) 544 | } 545 | 546 | func (c *Config) downloadHTMLFromS3(ctx context.Context) error { 547 | slog.Info(f("downloading html files from %s", c.HtmlDir)) 548 | tmpdir, err := os.MkdirTemp("", "mirage-ecs-htmldir-") 549 | if err != nil { 550 | return err 551 | } 552 | svc := s3.NewFromConfig(*c.awscfg) 553 | parsed, err := url.Parse(c.HtmlDir) 554 | if err != nil { 555 | return err 556 | } 557 | if parsed.Scheme != "s3" { 558 | return fmt.Errorf("invalid scheme: %s", parsed.Scheme) 559 | } 560 | bucket := parsed.Host 561 | keyPrefix := strings.TrimPrefix(parsed.Path, "/") 562 | if !strings.HasSuffix(keyPrefix, "/") { 563 | keyPrefix += "/" 564 | } 565 | slog.Debug(f("bucket: %s keyPrefix: %s", bucket, keyPrefix)) 566 | res, err := svc.ListObjectsV2(ctx, &s3.ListObjectsV2Input{ 567 | Bucket: aws.String(bucket), 568 | Prefix: aws.String(keyPrefix), 569 | Delimiter: aws.String("/"), 570 | MaxKeys: 100, // sufficient for html template files 571 | }) 572 | if err != nil { 573 | return err 574 | } 575 | if len(res.Contents) == 0 { 576 | return fmt.Errorf("no objects found in %s", c.HtmlDir) 577 | } 578 | files := 0 579 | for _, obj := range res.Contents { 580 | slog.Info(f("downloading %s", aws.ToString(obj.Key))) 581 | r, err := svc.GetObject(ctx, &s3.GetObjectInput{ 582 | Bucket: aws.String(bucket), 583 | Key: obj.Key, 584 | }) 585 | if err != nil { 586 | return err 587 | } 588 | defer r.Body.Close() 589 | filename := path.Base(aws.ToString(obj.Key)) 590 | file := filepath.Join(tmpdir, filename) 591 | if size, err := copyToFile(r.Body, file); err != nil { 592 | return err 593 | } else { 594 | files++ 595 | slog.Info(f("downloaded %s (%d bytes)", file, size)) 596 | } 597 | } 598 | slog.Info(f("downloaded %d files from %s", files, c.HtmlDir)) 599 | c.HtmlDir = tmpdir 600 | c.cleanups = append(c.cleanups, func() error { 601 | slog.Info(f("removing %s", tmpdir)) 602 | return os.RemoveAll(tmpdir) 603 | }) 604 | slog.Info(f("setting html dir: %s", c.HtmlDir)) 605 | return nil 606 | } 607 | 608 | func copyToFile(src io.Reader, dst string) (int64, error) { 609 | f, err := os.Create(dst) 610 | if err != nil { 611 | return 0, err 612 | } 613 | defer f.Close() 614 | return io.Copy(f, src) 615 | } 616 | 617 | func (cfg *Config) ValidateOriginMiddleware(next echo.HandlerFunc) echo.HandlerFunc { 618 | return func(c echo.Context) error { 619 | return next(c) 620 | } 621 | } 622 | 623 | func (cfg *Config) EncodeSubdomain(subdomain string) string { 624 | if cfg.compatV1 { 625 | return base64.URLEncoding.EncodeToString([]byte(subdomain)) 626 | } else { 627 | return subdomain 628 | } 629 | } 630 | -------------------------------------------------------------------------------- /config_auth_test.go: -------------------------------------------------------------------------------- 1 | package mirageecs_test 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/http/httptest" 7 | "strings" 8 | "testing" 9 | 10 | mirageecs "github.com/acidlemon/mirage-ecs/v2" 11 | "github.com/labstack/echo/v4" 12 | ) 13 | 14 | func TestAuthMiddleware(t *testing.T) { 15 | config := &mirageecs.Config{ 16 | Host: mirageecs.Host{ 17 | WebApi: "mirage.localtest.me", 18 | ReverseProxySuffix: ".localtest.me", 19 | }, 20 | Auth: &mirageecs.Auth{ 21 | CookieSecret: "cookie-secret", 22 | Token: &mirageecs.AuthMethodToken{ 23 | Header: "x-mirage-token", 24 | Token: "mytoken", 25 | }, 26 | Basic: &mirageecs.AuthMethodBasic{ 27 | Username: "user", 28 | Password: "pass", 29 | }, 30 | }, 31 | } 32 | 33 | var validCookie *http.Cookie 34 | validateCookie := func(cookies []*http.Cookie) error { 35 | for _, c := range cookies { 36 | if c.Name == mirageecs.AuthCookieName && c.HttpOnly && c.Secure && c.Domain == "localtest.me" { 37 | validCookie = c 38 | return nil 39 | } 40 | } 41 | return fmt.Errorf("cookie is not set correctly %v", cookies) 42 | } 43 | 44 | cases := []struct { 45 | Name string 46 | Request func() *http.Request 47 | ExpectStatus int 48 | Expect func(*http.Response) error 49 | BodyContains string 50 | }{ 51 | { 52 | Name: "Token matches", 53 | Request: func() *http.Request { 54 | req := httptest.NewRequest(http.MethodGet, "/", nil) 55 | req.Header.Set("x-mirage-token", "mytoken") 56 | return req 57 | }, 58 | ExpectStatus: 200, 59 | Expect: func(res *http.Response) error { 60 | if err := validateCookie(res.Cookies()); err != nil { 61 | return err 62 | } 63 | return nil 64 | }, 65 | BodyContains: "ok", 66 | }, 67 | { 68 | Name: "Token does not match", 69 | Request: func() *http.Request { 70 | req := httptest.NewRequest(http.MethodGet, "/", nil) 71 | req.Header.Set("x-mirage-token", "othertoken") 72 | return req 73 | }, 74 | ExpectStatus: 401, 75 | Expect: func(res *http.Response) error { 76 | if res.Header.Get("WWW-Authenticate") != `Basic realm="Restricted"` { 77 | return fmt.Errorf("WWW-Authenticate header is not set") 78 | } 79 | if len(res.Cookies()) != 0 { 80 | return fmt.Errorf("cookie should not be set %v", res.Cookies()) 81 | } 82 | return nil 83 | }, 84 | }, 85 | { 86 | Name: "Basic matches", 87 | Request: func() *http.Request { 88 | req := httptest.NewRequest(http.MethodGet, "/", nil) 89 | req.SetBasicAuth("user", "pass") 90 | return req 91 | }, 92 | ExpectStatus: 200, 93 | Expect: func(res *http.Response) error { 94 | if err := validateCookie(res.Cookies()); err != nil { 95 | return err 96 | } 97 | return nil 98 | }, 99 | BodyContains: "ok", 100 | }, 101 | { 102 | Name: "Basic does not match", 103 | Request: func() *http.Request { 104 | req := httptest.NewRequest(http.MethodGet, "/", nil) 105 | req.SetBasicAuth("userx", "pass") 106 | return req 107 | }, 108 | ExpectStatus: 401, 109 | Expect: func(res *http.Response) error { 110 | if len(res.Cookies()) != 0 { 111 | return fmt.Errorf("cookie should not be set %v", res.Cookies()) 112 | } 113 | return nil 114 | }, 115 | }, 116 | { 117 | Name: "token matches, basic does not match", 118 | Request: func() *http.Request { 119 | req := httptest.NewRequest(http.MethodGet, "/", nil) 120 | req.Header.Set("x-mirage-token", "mytoken") 121 | req.SetBasicAuth("userx", "pass") 122 | return req 123 | }, 124 | ExpectStatus: 200, 125 | Expect: func(res *http.Response) error { 126 | if err := validateCookie(res.Cookies()); err != nil { 127 | return err 128 | } 129 | return nil 130 | }, 131 | BodyContains: "ok", 132 | }, 133 | { 134 | Name: "/api/* don't allow basic auth", 135 | Request: func() *http.Request { 136 | req := httptest.NewRequest(http.MethodGet, "/api/list", nil) 137 | req.SetBasicAuth("user", "pass") 138 | return req 139 | }, 140 | ExpectStatus: 401, 141 | Expect: func(res *http.Response) error { 142 | if len(res.Cookies()) != 0 { 143 | return fmt.Errorf("cookie should not be set %v", res.Cookies()) 144 | } 145 | return nil 146 | }, 147 | }, 148 | { 149 | Name: "POST /launch requries origin header", 150 | Request: func() *http.Request { 151 | req := httptest.NewRequest(http.MethodPost, "/launch", nil) 152 | req.SetBasicAuth("user", "pass") 153 | return req 154 | }, 155 | ExpectStatus: 400, 156 | }, 157 | { 158 | Name: "POST /launch succeeds with valid origin header with basic auth", 159 | Request: func() *http.Request { 160 | req := httptest.NewRequest(http.MethodPost, "/launch", nil) 161 | req.SetBasicAuth("user", "pass") 162 | req.Header.Set("Origin", "https://mirage.localtest.me:8000") 163 | return req 164 | }, 165 | ExpectStatus: 200, 166 | BodyContains: "launched", 167 | }, 168 | { 169 | Name: "POST /launch succeeds with valid origin header(without port) with basic auth", 170 | Request: func() *http.Request { 171 | req := httptest.NewRequest(http.MethodPost, "/launch", nil) 172 | req.SetBasicAuth("user", "pass") 173 | req.Header.Set("Origin", "https://mirage.localtest.me") 174 | return req 175 | }, 176 | ExpectStatus: 200, 177 | BodyContains: "launched", 178 | }, 179 | { 180 | Name: "POST /launch fails with valid origin header with valid cookie only", 181 | Request: func() *http.Request { 182 | req := httptest.NewRequest(http.MethodPost, "/launch", nil) 183 | req.AddCookie(validCookie) 184 | req.Header.Set("Origin", "https://mirage.localtest.me:8000") 185 | return req 186 | }, 187 | ExpectStatus: 401, 188 | }, 189 | { 190 | Name: "webapi dosen't allow cookie auth", 191 | Request: func() *http.Request { 192 | req := httptest.NewRequest(http.MethodGet, "/api/list", nil) 193 | req.AddCookie(validCookie) 194 | return req 195 | }, 196 | ExpectStatus: 401, 197 | }, 198 | } 199 | 200 | for _, tc := range cases { 201 | t.Run(tc.Name, func(t *testing.T) { 202 | e := echo.New() 203 | handler := func(c echo.Context) error { 204 | p := c.Request().URL.Path 205 | switch p { 206 | case "/": 207 | return c.String(http.StatusOK, "ok") 208 | case "/api/list": 209 | return c.JSON(http.StatusOK, []int{}) 210 | case "/launch": 211 | return c.String(http.StatusOK, "launched") 212 | default: 213 | return c.String(http.StatusNotFound, "not found") 214 | } 215 | } 216 | mdForAPI := config.AuthMiddlewareForAPI(handler) 217 | mdForWeb := config.AuthMiddlewareForWeb(handler) 218 | middleware := func(c echo.Context) error { 219 | if strings.HasPrefix(c.Request().URL.Path, "/api/") { 220 | return mdForAPI(c) 221 | } else { 222 | return mdForWeb(c) 223 | } 224 | } 225 | rec := httptest.NewRecorder() 226 | c := e.NewContext(tc.Request(), rec) 227 | err := middleware(c) 228 | if err == nil { 229 | if rec.Code != tc.ExpectStatus { 230 | t.Errorf("unexpected status code: %d", rec.Code) 231 | } 232 | } else { 233 | if code := err.(*echo.HTTPError).Code; code != tc.ExpectStatus { 234 | t.Errorf("unexpected status code: %d", code) 235 | } 236 | } 237 | if tc.Expect != nil { 238 | if err := tc.Expect(rec.Result()); err != nil { 239 | t.Error(err) 240 | } 241 | } 242 | if tc.BodyContains != "" { 243 | if !strings.Contains(rec.Body.String(), tc.BodyContains) { 244 | t.Errorf("body does not contain %s got: %s", tc.BodyContains, rec.Body.String()) 245 | } 246 | } 247 | }) 248 | } 249 | } 250 | -------------------------------------------------------------------------------- /config_sample.yml: -------------------------------------------------------------------------------- 1 | host: 2 | # web api host 3 | # you can use API and Web interface through this host 4 | # webapi: docker.dev.example.net 5 | webapi: localhost 6 | 7 | listen: 8 | # listen address 9 | # default is only listen from localhost 10 | foreign_address: 127.0.0.1 11 | 12 | # listen port and reverse proxy port 13 | http: 14 | # listen 8080 and transport to container's 5000 port 15 | - listen: 8080 16 | target: 5000 17 | 18 | htmldir: ./html 19 | 20 | parameters: 21 | - name: branch 22 | env: GIT_BRANCH 23 | rule: "" 24 | required: true 25 | # add your custom parameters here! 26 | # name is parameter name (passed by HTTP parameter) 27 | # env is environment variable for docker container 28 | # rule is constraint of value using regexp. 29 | # required means required or optional parameter (boolean value) 30 | 31 | ecs: 32 | region: ap-northeast-1 33 | cluster: mirage 34 | launch_type: FARGATE 35 | network_configuration: 36 | awsvpc_configuration: 37 | subnets: 38 | - '{{ env "SUBNET_A" "subnet-aaaaaa" }}' 39 | - '{{ env "SUBNET_B" "subnet-bbbbbb" }}' 40 | security_groups: 41 | - '{{ env "SECURITY_GROUP_1" "sg-111111" }}' 42 | assign_public_ip: ENABLED 43 | default_task_definition: '{{ env "DEFAULT_TASKDEF" "arn:aws:ecs:ap-northeast-1:123456789012:task-definition/myapp" }}' 44 | # # enable link feature 45 | # link: 46 | # hosted_zone_id: '{{ env "LINK_ZONE_ID" "Z00000000000000000000" }}' 47 | # # overwrite ecs.default_task_definition 48 | # default_task_definitions: 49 | # - '{{ env "DEFAULT_TASKDEF" "arn:aws:ecs:ap-northeast-1:123456789012:task-definition/myapp" }}' 50 | # - '{{ env "DEFAULT_TASKDEF_LINK" "arn:aws:ecs:ap-northeast-1:123456789012:task-definition/myapp-link" }}' 51 | -------------------------------------------------------------------------------- /config_test.go: -------------------------------------------------------------------------------- 1 | package mirageecs_test 2 | 3 | import ( 4 | "context" 5 | "io/ioutil" 6 | "os" 7 | "testing" 8 | 9 | mirageecs "github.com/acidlemon/mirage-ecs/v2" 10 | ) 11 | 12 | func TestNewConfig(t *testing.T) { 13 | f, err := ioutil.TempFile("", "") 14 | if err != nil { 15 | t.Error(err) 16 | } 17 | defer func() { 18 | f.Close() 19 | os.Remove(f.Name()) 20 | }() 21 | 22 | data := `--- 23 | host: 24 | webapi: localhost 25 | reverse_proxy_suffix: .dev.example.net 26 | listen: 27 | foreign_address: 127.0.0.1 28 | http: 29 | - listen: 8080 30 | target: 5000 31 | ecs: 32 | region: ap-northeast-1 33 | cluster: test-cluster 34 | default_task_definition: test-task-definition 35 | capacity_provider_strategy: 36 | - capacity_provider: test-strategy 37 | base: 1 38 | weight: 1 39 | enable_execute_command: true 40 | network_configuration: 41 | awsvpc_configuration: 42 | subnets: 43 | - subnet-aaaa 44 | - subnet-bbbb 45 | - subnet-cccc 46 | security_groups: 47 | - sg-gggg 48 | assign_public_ip: ENABLED 49 | 50 | htmldir: ./html 51 | parameters: 52 | - name: branch 53 | env: GIT_BRANCH 54 | rule: "[0-9a-z-]{32}" 55 | required: true 56 | - name: nick 57 | env: NICK 58 | rule: "[0-9A-Za-z]{10}" 59 | required: false 60 | 61 | link: 62 | hosted_zone_id: Z00000000000000000000 63 | default_task_definitions: 64 | - test-task-definition 65 | - test-task-definition-link 66 | ` 67 | 68 | if err := ioutil.WriteFile(f.Name(), []byte(data), 0644); err != nil { 69 | t.Error(err) 70 | } 71 | ctx := context.Background() 72 | cfg, err := mirageecs.NewConfig(ctx, &mirageecs.ConfigParams{Path: f.Name()}) 73 | if err != nil { 74 | t.Error(err) 75 | } 76 | 77 | if cfg.Parameter[0].Name != "branch" { 78 | t.Error("could not parse parameter") 79 | } 80 | 81 | if cfg.Parameter[1].Env != "NICK" { 82 | t.Error("could not parse parameter") 83 | } 84 | 85 | if cfg.Parameter[0].Required != true { 86 | t.Error("could not parse parameter") 87 | } 88 | 89 | if cfg.ECS.Region != "ap-northeast-1" { 90 | t.Error("could not parse region") 91 | } 92 | if cfg.ECS.Cluster != "test-cluster" { 93 | t.Error("could not parse cluster") 94 | } 95 | if cfg.ECS.DefaultTaskDefinition != "test-task-definition" { 96 | t.Error("could not parse default_task_definition") 97 | } 98 | provider := cfg.ECS.CapacityProviderStrategy[0] 99 | if *provider.CapacityProvider != "test-strategy" { 100 | t.Error("could not parse capacity provider strategy") 101 | } 102 | if provider.Base != 1 { 103 | t.Error("could not parse capacity provider strategy") 104 | } 105 | nc := cfg.ECS.NetworkConfiguration 106 | if nc.AwsVpcConfiguration.AssignPublicIp != "ENABLED" { 107 | t.Error("could not parse network configuration") 108 | } 109 | if nc.AwsVpcConfiguration.SecurityGroups[0] != "sg-gggg" { 110 | t.Error("could not parse network configuration") 111 | } 112 | if nc.AwsVpcConfiguration.Subnets[0] != "subnet-aaaa" { 113 | t.Error("could not parse network configuration") 114 | } 115 | if !*cfg.ECS.EnableExecuteCommand { 116 | t.Error("could not parse enable execute command") 117 | } 118 | if cfg.Link.HostedZoneID != "Z00000000000000000000" { 119 | t.Error("could not parse link hosted zone") 120 | } 121 | if cfg.Link.DefaultTaskDefinitions[0] != "test-task-definition" { 122 | t.Error("could not parse link default task definitions") 123 | } 124 | if cfg.Link.DefaultTaskDefinitions[1] != "test-task-definition-link" { 125 | t.Error("could not parse link default task definitions") 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /data/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acidlemon/mirage-ecs/facf1b9d27f138f73fa34445ae8743532dc56c28/data/.gitkeep -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.23 AS builder 2 | 3 | ADD . /stash/src/github.com/acidlemon/mirage-ecs 4 | WORKDIR /stash/src/github.com/acidlemon/mirage-ecs 5 | ENV GOPATH=/stash 6 | 7 | RUN make clean && make && mv mirage-ecs /stash/ 8 | RUN cp -a html /stash/ 9 | RUN cp docker/example-config.yml /stash/ 10 | 11 | FROM debian:bookworm-slim 12 | 13 | RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* 14 | RUN mkdir -p /opt/mirage/html 15 | COPY --from=builder /stash/mirage-ecs /opt/mirage/ 16 | COPY --from=builder /stash/example-config.yml /opt/mirage/ 17 | COPY --from=builder /stash/html/* /opt/mirage/html/ 18 | WORKDIR /opt/mirage 19 | ENV MIRAGE_LOG_LEVEL info 20 | ENV MIRAGE_CONF "" 21 | RUN /opt/mirage/mirage-ecs -version 22 | ENTRYPOINT ["/opt/mirage/mirage-ecs"] 23 | -------------------------------------------------------------------------------- /docker/example-config.yml: -------------------------------------------------------------------------------- 1 | host: 2 | webapi: '{{ env "MIRAGE_WEBAPI_HOST" "localhost" }}' 3 | reverse_proxy_suffix: '{{ env "MIRAGE_REVERSEPROXY_SUFFIX" ".dev.example.net" }}' 4 | 5 | listen: 6 | foreign_address: 0.0.0.0 7 | 8 | http: 9 | - listen: 80 10 | target: 80 11 | 12 | htmldir: ./html 13 | parameters: 14 | - name: branch 15 | env: GIT_BRANCH 16 | rule: "" 17 | required: true 18 | 19 | ecs: 20 | region: '{{ env "AWS_REGION" "us-east-1" }}' 21 | cluster: '{{ env "MIRAGE_ECS_CLUSTER" "default" }}' 22 | launch_type: '{{ env "MIRAGE_ECS_LAUNCH_TYPE" "FARGATE" }}' 23 | default_task_definition: '{{ env "MIRAGE_DEFAULT_TASKDEF" "myapp" }}' 24 | network_configuration: 25 | awsvpc_configuration: 26 | subnets: 27 | - '{{ env "MIRAGE_SUBNET_1" "subnet-aaaaaa" }}' 28 | - '{{ env "MIRAGE_SUBNET_2" "subnet-bbbbbb" }}' 29 | security_groups: 30 | - '{{ env "MIRAGE_SECURITY_GROUP" "sg-111111" }}' 31 | assign_public_ip: '{{ env "MIRAGE_ECS_ASSIGN_PUBLIC_IP" "ENABLED" }}' 32 | -------------------------------------------------------------------------------- /docs/mirage-ecs-launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acidlemon/mirage-ecs/facf1b9d27f138f73fa34445ae8743532dc56c28/docs/mirage-ecs-launcher.png -------------------------------------------------------------------------------- /docs/mirage-ecs-list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acidlemon/mirage-ecs/facf1b9d27f138f73fa34445ae8743532dc56c28/docs/mirage-ecs-list.png -------------------------------------------------------------------------------- /e2e_test.go: -------------------------------------------------------------------------------- 1 | package mirageecs_test 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "io" 7 | "net/http" 8 | "net/http/httptest" 9 | "net/url" 10 | "strings" 11 | "testing" 12 | 13 | mirageecs "github.com/acidlemon/mirage-ecs/v2" 14 | ) 15 | 16 | var e2eRequestsForm = map[string]string{ 17 | "/api/launch": url.Values{ 18 | "subdomain": []string{"mytask"}, 19 | "taskdef": []string{"dummy"}, 20 | "branch": []string{"develop"}, 21 | "env": []string{"test"}, 22 | }.Encode(), 23 | "/api/purge": url.Values{ 24 | "duration": []string{"300"}, 25 | }.Encode(), 26 | "/api/terminate": url.Values{ 27 | "subdomain": []string{"mytask"}, 28 | }.Encode(), 29 | } 30 | 31 | var e2eRequestsJSON = map[string]string{ 32 | "/api/launch": `{"subdomain":"mytask","taskdef":["dummy"],"branch":"develop","parameters":{"env":"test"}}`, 33 | "/api/purge": `{"duration":"300"}`, 34 | "/api/terminate": `{"subdomain":"mytask"}`, 35 | } 36 | 37 | func TestE2EAPI(t *testing.T) { 38 | t.Run("form v1", func(t *testing.T) { 39 | testE2EAPI(t, e2eRequestsForm, "application/x-www-form-urlencoded", true) 40 | }) 41 | t.Run("json v1", func(t *testing.T) { 42 | testE2EAPI(t, e2eRequestsJSON, "application/json", true) 43 | }) 44 | 45 | t.Run("json v2", func(t *testing.T) { 46 | testE2EAPI(t, e2eRequestsJSON, "application/json", false) 47 | }) 48 | } 49 | 50 | func testE2EAPI(t *testing.T, reqs map[string]string, contentType string, compatV1 bool) { 51 | ctx := context.Background() 52 | cfg, err := mirageecs.NewConfig(ctx, &mirageecs.ConfigParams{ 53 | LocalMode: true, 54 | Domain: "localtest.me", 55 | CompatV1: compatV1, 56 | }) 57 | cfg.Parameter = append(cfg.Parameter, &mirageecs.Parameter{ 58 | Name: "env", 59 | Env: "ENV", 60 | Required: true, 61 | }) 62 | 63 | if err != nil { 64 | t.Error(err) 65 | } 66 | m := mirageecs.New(context.Background(), cfg) 67 | ts := httptest.NewServer(m.WebApi) 68 | defer ts.Close() 69 | client := ts.Client() 70 | 71 | t.Run("/api/list at first", func(t *testing.T) { 72 | res, err := client.Get(ts.URL + "/api/list") 73 | if err != nil { 74 | t.Error(err) 75 | } 76 | defer res.Body.Close() 77 | if res.StatusCode != 200 { 78 | t.Errorf("status code should be 200: %d", res.StatusCode) 79 | } 80 | var r mirageecs.APIListResponse 81 | json.NewDecoder(res.Body).Decode(&r) 82 | if len(r.Result) != 0 { 83 | t.Errorf("result should be empty %#v", r) 84 | } 85 | }) 86 | 87 | t.Run("/api/launch", func(t *testing.T) { 88 | req, _ := http.NewRequest("POST", ts.URL+"/api/launch", strings.NewReader(reqs["/api/launch"])) 89 | req.Header.Set("Content-Type", contentType) 90 | res, err := client.Do(req) 91 | if err != nil { 92 | t.Error(err) 93 | } 94 | defer res.Body.Close() 95 | if res.StatusCode != 200 { 96 | body, _ := io.ReadAll(res.Body) 97 | t.Errorf("status code should be 200: %d", res.StatusCode) 98 | t.Errorf("body: %s", body) 99 | return 100 | } 101 | var r mirageecs.APICommonResponse 102 | json.NewDecoder(res.Body).Decode(&r) 103 | if r.Result != "ok" { 104 | t.Errorf("result should be ok %#v", r) 105 | } 106 | }) 107 | 108 | t.Run("/api/list after launched", func(t *testing.T) { 109 | res, err := client.Get(ts.URL + "/api/list") 110 | if err != nil { 111 | t.Error(err) 112 | } 113 | defer res.Body.Close() 114 | if res.StatusCode != 200 { 115 | t.Errorf("status code should be 200: %d", res.StatusCode) 116 | } 117 | var r mirageecs.APIListResponse 118 | json.NewDecoder(res.Body).Decode(&r) 119 | if len(r.Result) != 1 { 120 | t.Errorf("result should be empty %#v", r) 121 | } 122 | if r.Result[0].SubDomain != "mytask" { 123 | t.Errorf("subdomain should be mytask %#v", r) 124 | } 125 | if r.Result[0].TaskDef != "dummy" { 126 | t.Errorf("taskdef should be dummy %#v", r) 127 | } 128 | if r.Result[0].GitBranch != "develop" { 129 | t.Errorf("branch should be develop %#v", r) 130 | } 131 | }) 132 | 133 | t.Run("/api/access", func(t *testing.T) { 134 | res, err := client.Get(ts.URL + "/api/access?subdomain=mytask&duration=300") 135 | if err != nil { 136 | t.Error(err) 137 | } 138 | defer res.Body.Close() 139 | if res.StatusCode != 200 { 140 | t.Errorf("status code should be 200: %d", res.StatusCode) 141 | } 142 | var r mirageecs.APIAccessResponse 143 | json.NewDecoder(res.Body).Decode(&r) 144 | if r.Result != "ok" { 145 | t.Errorf("result should be ok %#v", r) 146 | } 147 | if r.Duration != 300 { 148 | t.Errorf("duration should be 300 %#v", r) 149 | } 150 | }) 151 | 152 | t.Run("/api/purge", func(t *testing.T) { 153 | req, _ := http.NewRequest("POST", ts.URL+"/api/purge", strings.NewReader(reqs["/api/purge"])) 154 | req.Header.Set("Content-Type", contentType) 155 | res, err := client.Do(req) 156 | if err != nil { 157 | t.Error(err) 158 | } 159 | defer res.Body.Close() 160 | if res.StatusCode != 200 { 161 | body, _ := io.ReadAll(res.Body) 162 | t.Errorf("status code should be 200: %d", res.StatusCode) 163 | t.Errorf("body: %s", body) 164 | return 165 | } 166 | var r mirageecs.APICommonResponse 167 | json.NewDecoder(res.Body).Decode(&r) 168 | if r.Result != "accepted" { 169 | t.Errorf("result should be ok %#v", r) 170 | } 171 | }) 172 | 173 | t.Run("/api/terminate", func(t *testing.T) { 174 | req, _ := http.NewRequest("POST", ts.URL+"/api/terminate", strings.NewReader(reqs["/api/terminate"])) 175 | req.Header.Set("Content-Type", contentType) 176 | res, err := client.Do(req) 177 | if err != nil { 178 | t.Error(err) 179 | } 180 | defer res.Body.Close() 181 | if res.StatusCode != 200 { 182 | body, _ := io.ReadAll(res.Body) 183 | t.Errorf("status code should be 200: %d", res.StatusCode) 184 | t.Errorf("body: %s", body) 185 | return 186 | } 187 | var r mirageecs.APICommonResponse 188 | json.NewDecoder(res.Body).Decode(&r) 189 | if r.Result != "ok" { 190 | t.Errorf("result should be ok %#v", r) 191 | } 192 | }) 193 | 194 | t.Run("/api/list after terminate", func(t *testing.T) { 195 | res, err := client.Get(ts.URL + "/api/list") 196 | if err != nil { 197 | t.Error(err) 198 | } 199 | defer res.Body.Close() 200 | if res.StatusCode != 200 { 201 | t.Errorf("status code should be 200: %d", res.StatusCode) 202 | } 203 | var r mirageecs.APIListResponse 204 | json.NewDecoder(res.Body).Decode(&r) 205 | if len(r.Result) != 0 { 206 | t.Errorf("result should be empty %#v", r) 207 | } 208 | }) 209 | 210 | t.Run("/api/launch with form", func(t *testing.T) { 211 | req, _ := http.NewRequest("POST", ts.URL+"/api/launch", strings.NewReader(e2eRequestsForm["/api/launch"])) 212 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 213 | res, err := client.Do(req) 214 | if err != nil { 215 | t.Error(err) 216 | } 217 | defer res.Body.Close() 218 | expectStatus := 400 // v2 219 | if compatV1 { 220 | expectStatus = 200 // v1 221 | } 222 | if res.StatusCode != expectStatus { 223 | t.Errorf("status code should be %d: %d", expectStatus, res.StatusCode) 224 | } 225 | }) 226 | } 227 | -------------------------------------------------------------------------------- /ecs-task-def.json: -------------------------------------------------------------------------------- 1 | { 2 | "cpu": "256", 3 | "memory": "512", 4 | "containerDefinitions": [ 5 | { 6 | "name": "mirage-ecs", 7 | "image": "ghcr.io/acidlemon/mirage-ecs:v2.0.0", 8 | "portMappings": [ 9 | { 10 | "containerPort": 80, 11 | "hostPort": 80, 12 | "protocol": "tcp" 13 | } 14 | ], 15 | "essential": true, 16 | "environment": [ 17 | { 18 | "name": "MIRAGE_DOMAIN", 19 | "value": ".dev.example.net" 20 | }, 21 | { 22 | "name": "MIRAGE_LOG_LEVEL", 23 | "value": "info" 24 | } 25 | ], 26 | "logConfiguration": { 27 | "logDriver": "awslogs", 28 | "options": { 29 | "awslogs-create-group": "true", 30 | "awslogs-group": "/ecs/mirage-ecs", 31 | "awslogs-region": "ap-northeast-1", 32 | "awslogs-stream-prefix": "ecs" 33 | } 34 | } 35 | } 36 | ], 37 | "family": "mirage-ecs", 38 | "taskRoleArn": "arn:aws:iam::123456789012:role/ecs-task", 39 | "executionRoleArn": "arn:aws:iam::123456789012:role/ecs-task-execution", 40 | "networkMode": "awsvpc", 41 | "requiresCompatibilities": [ 42 | "EC2", 43 | "FARGATE" 44 | ] 45 | } 46 | -------------------------------------------------------------------------------- /ecs.go: -------------------------------------------------------------------------------- 1 | package mirageecs 2 | 3 | import ( 4 | "context" 5 | "encoding/base64" 6 | "fmt" 7 | "log/slog" 8 | "sort" 9 | "strings" 10 | "sync" 11 | "time" 12 | 13 | ttlcache "github.com/ReneKroon/ttlcache/v2" 14 | "github.com/fujiwara/tracer" 15 | "github.com/samber/lo" 16 | 17 | "github.com/aws/aws-sdk-go-v2/aws" 18 | cw "github.com/aws/aws-sdk-go-v2/service/cloudwatch" 19 | cwTypes "github.com/aws/aws-sdk-go-v2/service/cloudwatch/types" 20 | cwlogs "github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs" 21 | "github.com/aws/aws-sdk-go-v2/service/ecs" 22 | "github.com/aws/aws-sdk-go-v2/service/ecs/types" 23 | 24 | "golang.org/x/sync/errgroup" 25 | ) 26 | 27 | var taskDefinitionCache = ttlcache.NewCache() // no need to expire because taskdef is immutable. 28 | 29 | type Information struct { 30 | ID string `json:"id"` 31 | ShortID string `json:"short_id"` 32 | SubDomain string `json:"subdomain"` 33 | GitBranch string `json:"branch"` 34 | TaskDef string `json:"taskdef"` 35 | IPAddress string `json:"ipaddress"` 36 | Created time.Time `json:"created"` 37 | LastStatus string `json:"last_status"` 38 | PortMap map[string]int `json:"port_map"` 39 | Env map[string]string `json:"env"` 40 | Tags []types.Tag `json:"tags"` 41 | 42 | task *types.Task 43 | } 44 | 45 | func (info Information) ShouldBePurged(p *PurgeParams) bool { 46 | if info.LastStatus != statusRunning { 47 | slog.Info(f("skip not running task: %s subdomain: %s", info.LastStatus, info.SubDomain)) 48 | return false 49 | } 50 | if _, ok := p.excludesMap[info.SubDomain]; ok { 51 | slog.Info(f("skip exclude subdomain: %s", info.SubDomain)) 52 | return false 53 | } 54 | for _, t := range info.Tags { 55 | k, v := aws.ToString(t.Key), aws.ToString(t.Value) 56 | if ev, ok := p.excludeTagsMap[k]; ok && ev == v { 57 | slog.Info(f("skip exclude tag: %s=%s subdomain: %s", k, v, info.SubDomain)) 58 | return false 59 | } 60 | } 61 | if p.ExcludeRegexp != nil && p.ExcludeRegexp.MatchString(info.SubDomain) { 62 | slog.Info(f("skip exclude regexp: %s subdomain: %s", p.ExcludeRegexp.String(), info.SubDomain)) 63 | return false 64 | } 65 | 66 | begin := time.Now().Add(-p.Duration) 67 | if info.Created.After(begin) { 68 | slog.Info(f("skip recent created: %s subdomain: %s", info.Created.Format(time.RFC3339), info.SubDomain)) 69 | return false 70 | } 71 | return true 72 | } 73 | 74 | type TaskParameter map[string]string 75 | 76 | func (p TaskParameter) ToECSKeyValuePairs(subdomain string, configParams Parameters, enc func(string) string) []types.KeyValuePair { 77 | kvp := make([]types.KeyValuePair, 0, len(p)+2) 78 | kvp = append(kvp, 79 | types.KeyValuePair{ 80 | Name: aws.String(EnvSubdomain), 81 | Value: aws.String(enc(subdomain)), 82 | }, 83 | types.KeyValuePair{ 84 | Name: aws.String(EnvSubdomainRaw), 85 | Value: aws.String(subdomain), 86 | }, 87 | ) 88 | for _, v := range configParams { 89 | v := v 90 | if p[v.Name] == "" { 91 | continue 92 | } 93 | kvp = append(kvp, types.KeyValuePair{ 94 | Name: aws.String(v.Env), 95 | Value: aws.String(p[v.Name]), 96 | }) 97 | } 98 | return kvp 99 | } 100 | 101 | func (p TaskParameter) ToECSTags(subdomain string, configParams Parameters) []types.Tag { 102 | tags := make([]types.Tag, 0, len(p)+3) 103 | tags = append(tags, 104 | types.Tag{ 105 | Key: aws.String(TagSubdomain), 106 | Value: aws.String(encodeTagValue(subdomain)), 107 | }, 108 | types.Tag{ 109 | Key: aws.String(TagManagedBy), 110 | Value: aws.String(TagValueMirage), 111 | }, 112 | ) 113 | for _, v := range configParams { 114 | v := v 115 | if p[v.Name] == "" { 116 | continue 117 | } 118 | tags = append(tags, types.Tag{ 119 | Key: aws.String(v.Name), 120 | Value: aws.String(p[v.Name]), 121 | }) 122 | } 123 | return tags 124 | } 125 | 126 | func (p TaskParameter) ToEnv(subdomain string, configParams Parameters, enc func(string) string) map[string]string { 127 | env := make(map[string]string, len(p)+1) 128 | env[EnvSubdomain] = enc(subdomain) 129 | env[EnvSubdomainRaw] = subdomain 130 | for _, v := range configParams { 131 | v := v 132 | if p[v.Name] == "" { 133 | continue 134 | } 135 | env[strings.ToUpper(v.Env)] = p[v.Name] 136 | } 137 | return env 138 | } 139 | 140 | const ( 141 | TagManagedBy = "ManagedBy" 142 | TagSubdomain = "Subdomain" 143 | TagValueMirage = "Mirage" 144 | 145 | EnvSubdomain = "SUBDOMAIN" 146 | EnvSubdomainRaw = "SUBDOMAINRAW" 147 | 148 | statusRunning = string(types.DesiredStatusRunning) 149 | statusStopped = string(types.DesiredStatusStopped) 150 | ) 151 | 152 | type TaskRunner interface { 153 | Launch(ctx context.Context, subdomain string, param TaskParameter, taskdefs ...string) error 154 | Logs(ctx context.Context, subdomain string, since time.Time, tail int) ([]string, error) 155 | Trace(ctx context.Context, id string) (string, error) 156 | Terminate(ctx context.Context, subdomain string) error 157 | TerminateBySubdomain(ctx context.Context, subdomain string) error 158 | List(ctx context.Context, status string) ([]*Information, error) 159 | SetProxyControlChannel(ch chan *proxyControl) 160 | GetAccessCount(ctx context.Context, subdomain string, duration time.Duration) (int64, error) 161 | PutAccessCounts(context.Context, map[string]accessCount) error 162 | } 163 | 164 | type ECS struct { 165 | cfg *Config 166 | svc *ecs.Client 167 | logsSvc *cwlogs.Client 168 | cwSvc *cw.Client 169 | proxyControlCh chan *proxyControl 170 | } 171 | 172 | func NewECSTaskRunner(cfg *Config) TaskRunner { 173 | e := &ECS{ 174 | cfg: cfg, 175 | svc: ecs.NewFromConfig(*cfg.awscfg), 176 | logsSvc: cwlogs.NewFromConfig(*cfg.awscfg), 177 | cwSvc: cw.NewFromConfig(*cfg.awscfg), 178 | } 179 | return e 180 | } 181 | 182 | func (e *ECS) SetProxyControlChannel(ch chan *proxyControl) { 183 | e.proxyControlCh = ch 184 | } 185 | 186 | func (e *ECS) launchTask(ctx context.Context, subdomain string, taskdef string, option TaskParameter) error { 187 | cfg := e.cfg 188 | 189 | slog.Info(f("launching task subdomain:%s taskdef:%s", subdomain, taskdef)) 190 | tdOut, err := e.svc.DescribeTaskDefinition(ctx, &ecs.DescribeTaskDefinitionInput{ 191 | TaskDefinition: aws.String(taskdef), 192 | }) 193 | if err != nil { 194 | return fmt.Errorf("failed to describe task definition: %w", err) 195 | } 196 | 197 | // override envs for each container in taskdef 198 | ov := &types.TaskOverride{} 199 | env := option.ToECSKeyValuePairs(subdomain, cfg.Parameter, cfg.EncodeSubdomain) 200 | 201 | for _, c := range tdOut.TaskDefinition.ContainerDefinitions { 202 | name := *c.Name 203 | ov.ContainerOverrides = append( 204 | ov.ContainerOverrides, 205 | types.ContainerOverride{ 206 | Name: aws.String(name), 207 | Environment: env, 208 | }, 209 | ) 210 | } 211 | slog.Debug(f("Task Override: %v", ov)) 212 | 213 | tags := option.ToECSTags(subdomain, cfg.Parameter) 214 | runtaskInput := &ecs.RunTaskInput{ 215 | CapacityProviderStrategy: cfg.ECS.capacityProviderStrategy, 216 | Cluster: aws.String(cfg.ECS.Cluster), 217 | TaskDefinition: aws.String(taskdef), 218 | NetworkConfiguration: cfg.ECS.networkConfiguration, 219 | Overrides: ov, 220 | Count: aws.Int32(1), 221 | Tags: tags, 222 | EnableExecuteCommand: aws.ToBool(cfg.ECS.EnableExecuteCommand), 223 | } 224 | if lt := cfg.ECS.LaunchType; lt != nil { 225 | runtaskInput.LaunchType = types.LaunchType(*lt) 226 | } 227 | 228 | slog.Debug(f("RunTaskInput: %v", runtaskInput)) 229 | out, err := e.svc.RunTask(ctx, runtaskInput) 230 | if err != nil { 231 | return err 232 | } 233 | if len(out.Failures) > 0 { 234 | f := out.Failures[0] 235 | reason := "(unknown)" 236 | if f.Reason != nil { 237 | reason = *f.Reason 238 | } 239 | arn := "(unknown)" 240 | if f.Arn != nil { 241 | arn = *f.Arn 242 | } 243 | return fmt.Errorf( 244 | "run task failed. reason:%s arn:%s", reason, arn, 245 | ) 246 | } 247 | task := out.Tasks[0] 248 | slog.Info(f("launced task ARN: %s", *task.TaskArn)) 249 | return nil 250 | } 251 | 252 | func (e *ECS) Launch(ctx context.Context, subdomain string, option TaskParameter, taskdefs ...string) error { 253 | if infos, err := e.find(ctx, subdomain); err != nil { 254 | return fmt.Errorf("failed to get subdomain %s: %w", subdomain, err) 255 | } else if len(infos) > 0 { 256 | slog.Info(f("subdomain %s is already running %d tasks. Terminating...", subdomain, len(infos))) 257 | err := e.TerminateBySubdomain(ctx, subdomain) 258 | if err != nil { 259 | return err 260 | } 261 | } 262 | 263 | slog.Info(f("launching subdomain:%s taskdefs:%v", subdomain, taskdefs)) 264 | 265 | var eg errgroup.Group 266 | for _, taskdef := range taskdefs { 267 | taskdef := taskdef 268 | eg.Go(func() error { 269 | return e.launchTask(ctx, subdomain, taskdef, option) 270 | }) 271 | } 272 | return eg.Wait() 273 | } 274 | 275 | func (e *ECS) Trace(ctx context.Context, id string) (string, error) { 276 | tr, err := tracer.NewWithConfig(*e.cfg.awscfg) 277 | if err != nil { 278 | return "", err 279 | } 280 | tracerOpt := &tracer.RunOption{ 281 | Stdout: true, 282 | Duration: 5 * time.Minute, 283 | } 284 | buf := &strings.Builder{} 285 | tr.SetOutput(buf) 286 | if err := tr.Run(ctx, e.cfg.ECS.Cluster, id, tracerOpt); err != nil { 287 | return "", err 288 | } 289 | return buf.String(), nil 290 | } 291 | 292 | func (e *ECS) Logs(ctx context.Context, subdomain string, since time.Time, tail int) ([]string, error) { 293 | infos, err := e.find(ctx, subdomain) 294 | if err != nil { 295 | return nil, err 296 | } 297 | if len(infos) == 0 { 298 | return nil, fmt.Errorf("subdomain %s is not found", subdomain) 299 | } 300 | 301 | var logs []string 302 | var eg errgroup.Group 303 | var mu sync.Mutex 304 | for _, info := range infos { 305 | info := info 306 | eg.Go(func() error { 307 | l, err := e.logs(ctx, info, since, tail) 308 | mu.Lock() 309 | defer mu.Unlock() 310 | logs = append(logs, l...) 311 | return err 312 | }) 313 | } 314 | return logs, eg.Wait() 315 | } 316 | 317 | func (e *ECS) logs(ctx context.Context, info *Information, since time.Time, tail int) ([]string, error) { 318 | task := info.task 319 | taskdefOut, err := e.svc.DescribeTaskDefinition(ctx, &ecs.DescribeTaskDefinitionInput{ 320 | TaskDefinition: task.TaskDefinitionArn, 321 | Include: []types.TaskDefinitionField{types.TaskDefinitionFieldTags}, 322 | }) 323 | if err != nil { 324 | return nil, fmt.Errorf("failed to describe task definition: %w", err) 325 | } 326 | 327 | streams := make(map[string][]string) 328 | for _, c := range taskdefOut.TaskDefinition.ContainerDefinitions { 329 | c := c 330 | logConf := c.LogConfiguration 331 | if logConf == nil { 332 | continue 333 | } 334 | if logConf.LogDriver != types.LogDriverAwslogs { 335 | slog.Warn(f("LogDriver %s is not supported", logConf.LogDriver)) 336 | continue 337 | } 338 | group := logConf.Options["awslogs-group"] 339 | streamPrefix := logConf.Options["awslogs-stream-prefix"] 340 | if group == "" || streamPrefix == "" { 341 | slog.Warn(f("invalid options. awslogs-group %s awslogs-stream-prefix %s", group, streamPrefix)) 342 | continue 343 | } 344 | // streamName: prefix/containerName/taskID 345 | streams[group] = append( 346 | streams[group], 347 | fmt.Sprintf("%s/%s/%s", streamPrefix, *c.Name, info.ShortID), 348 | ) 349 | } 350 | 351 | logs := []string{} 352 | for group, streamNames := range streams { 353 | group := group 354 | for _, stream := range streamNames { 355 | stream := stream 356 | slog.Debug(f("get log events from group:%s stream:%s start:%s", group, stream, since)) 357 | in := &cwlogs.GetLogEventsInput{ 358 | LogGroupName: aws.String(group), 359 | LogStreamName: aws.String(stream), 360 | } 361 | if !since.IsZero() { 362 | in.StartTime = aws.Int64(since.Unix() * 1000) 363 | } 364 | eventsOut, err := e.logsSvc.GetLogEvents(ctx, in) 365 | if err != nil { 366 | slog.Warn(f("failed to get log events from group %s stream %s: %s", group, stream, err)) 367 | continue 368 | } 369 | slog.Debug(f("%d log events", len(eventsOut.Events))) 370 | for _, ev := range eventsOut.Events { 371 | logs = append(logs, *ev.Message) 372 | } 373 | } 374 | } 375 | if tail > 0 && len(logs) >= tail { 376 | return logs[len(logs)-tail:], nil 377 | } 378 | return logs, nil 379 | } 380 | 381 | func (e *ECS) Terminate(ctx context.Context, taskArn string) error { 382 | slog.Info(f("stop task %s", taskArn)) 383 | _, err := e.svc.StopTask(ctx, &ecs.StopTaskInput{ 384 | Cluster: aws.String(e.cfg.ECS.Cluster), 385 | Task: aws.String(taskArn), 386 | Reason: aws.String("Terminate requested by Mirage"), 387 | }) 388 | return err 389 | } 390 | 391 | func (e *ECS) TerminateBySubdomain(ctx context.Context, subdomain string) error { 392 | infos, err := e.find(ctx, subdomain) 393 | if err != nil { 394 | return err 395 | } 396 | var eg errgroup.Group 397 | eg.Go(func() error { 398 | e.proxyControlCh <- &proxyControl{ 399 | Action: proxyRemove, 400 | Subdomain: subdomain, 401 | } 402 | return nil 403 | }) 404 | for _, info := range infos { 405 | info := info 406 | eg.Go(func() error { 407 | return e.Terminate(ctx, info.ID) 408 | }) 409 | } 410 | return eg.Wait() 411 | } 412 | 413 | func (e *ECS) find(ctx context.Context, subdomain string) ([]*Information, error) { 414 | var results []*Information 415 | 416 | infos, err := e.List(ctx, statusRunning) 417 | if err != nil { 418 | return nil, err 419 | } 420 | for _, info := range infos { 421 | if info.SubDomain == subdomain { 422 | results = append(results, info) 423 | } 424 | } 425 | return results, nil 426 | } 427 | 428 | func (e *ECS) List(ctx context.Context, desiredStatus string) ([]*Information, error) { 429 | slog.Debug(f("call ecs.List(%s)", desiredStatus)) 430 | infos := []*Information{} 431 | var nextToken *string 432 | cluster := aws.String(e.cfg.ECS.Cluster) 433 | include := []types.TaskField{types.TaskFieldTags} 434 | for { 435 | listOut, err := e.svc.ListTasks(ctx, &ecs.ListTasksInput{ 436 | Cluster: cluster, 437 | NextToken: nextToken, 438 | DesiredStatus: types.DesiredStatus(desiredStatus), 439 | }) 440 | if err != nil { 441 | return nil, fmt.Errorf("failed to list tasks: %w", err) 442 | } 443 | if len(listOut.TaskArns) == 0 { 444 | return []*Information{}, nil 445 | } 446 | 447 | tasksOut, err := e.svc.DescribeTasks(ctx, &ecs.DescribeTasksInput{ 448 | Cluster: cluster, 449 | Tasks: listOut.TaskArns, 450 | Include: include, 451 | }) 452 | if err != nil { 453 | return infos, fmt.Errorf("failed to describe tasks: %w", err) 454 | } 455 | 456 | for _, task := range tasksOut.Tasks { 457 | task := task 458 | if getTagsFromTask(&task, TagManagedBy) != TagValueMirage { 459 | // task is not managed by Mirage 460 | continue 461 | } 462 | info := &Information{ 463 | ID: *task.TaskArn, 464 | ShortID: shortenArn(*task.TaskArn), 465 | SubDomain: decodeTagValue(getTagsFromTask(&task, "Subdomain")), 466 | GitBranch: getEnvironmentFromTask(&task, "GIT_BRANCH"), 467 | TaskDef: shortenArn(*task.TaskDefinitionArn), 468 | IPAddress: getIPV4AddressFromTask(&task), 469 | LastStatus: *task.LastStatus, 470 | Env: getEnvironmentsFromTask(&task), 471 | Tags: task.Tags, 472 | task: &task, 473 | } 474 | if portMap, err := e.portMapInTask(ctx, &task); err != nil { 475 | slog.Warn(f("failed to get portMap in task %s %s", *task.TaskArn, err)) 476 | } else { 477 | info.PortMap = portMap 478 | } 479 | if task.StartedAt != nil { 480 | info.Created = (*task.StartedAt).In(time.Local) 481 | } 482 | infos = append(infos, info) 483 | } 484 | 485 | nextToken = listOut.NextToken 486 | if nextToken == nil { 487 | break 488 | } 489 | } 490 | 491 | sort.Slice(infos, func(i, j int) bool { 492 | return infos[i].SubDomain < infos[j].SubDomain 493 | }) 494 | 495 | return infos, nil 496 | } 497 | 498 | func shortenArn(arn string) string { 499 | p := strings.SplitN(arn, ":", 6) 500 | if len(p) != 6 { 501 | return "" 502 | } 503 | ps := strings.Split(p[5], "/") 504 | if len(ps) == 0 { 505 | return "" 506 | } 507 | return ps[len(ps)-1] 508 | } 509 | 510 | func getIPV4AddressFromTask(task *types.Task) string { 511 | if len(task.Attachments) == 0 { 512 | return "" 513 | } 514 | for _, d := range task.Attachments[0].Details { 515 | if *d.Name == "privateIPv4Address" { 516 | return *d.Value 517 | } 518 | } 519 | return "" 520 | } 521 | 522 | func getTagsFromTask(task *types.Task, name string) string { 523 | for _, t := range task.Tags { 524 | if *t.Key == name { 525 | return *t.Value 526 | } 527 | } 528 | return "" 529 | } 530 | 531 | func getEnvironmentFromTask(task *types.Task, name string) string { 532 | if len(task.Overrides.ContainerOverrides) == 0 { 533 | return "" 534 | } 535 | ov := task.Overrides.ContainerOverrides[0] 536 | for _, env := range ov.Environment { 537 | if *env.Name == name { 538 | return *env.Value 539 | } 540 | } 541 | return "" 542 | } 543 | 544 | func getEnvironmentsFromTask(task *types.Task) map[string]string { 545 | env := map[string]string{} 546 | if len(task.Overrides.ContainerOverrides) == 0 { 547 | return env 548 | } 549 | ov := task.Overrides.ContainerOverrides[0] 550 | for _, e := range ov.Environment { 551 | env[*e.Name] = *e.Value 552 | } 553 | return env 554 | } 555 | 556 | func encodeTagValue(s string) string { 557 | return base64.URLEncoding.EncodeToString([]byte(s)) 558 | } 559 | 560 | func decodeTagValue(s string) string { 561 | d, err := base64.URLEncoding.DecodeString(s) 562 | if err != nil { 563 | slog.Warn(f("failed to decode tag value %s %s", s, err)) 564 | return s 565 | } 566 | return string(d) 567 | } 568 | 569 | func (e *ECS) portMapInTask(ctx context.Context, task *types.Task) (map[string]int, error) { 570 | portMap := make(map[string]int) 571 | tdArn := *task.TaskDefinitionArn 572 | td, err := taskDefinitionCache.Get(tdArn) 573 | if err != nil && err == ttlcache.ErrNotFound { 574 | slog.Debug(f("cache miss for %s", tdArn)) 575 | out, err := e.svc.DescribeTaskDefinition(ctx, &ecs.DescribeTaskDefinitionInput{ 576 | TaskDefinition: &tdArn, 577 | }) 578 | if err != nil { 579 | return nil, err 580 | } 581 | taskDefinitionCache.Set(tdArn, out.TaskDefinition) 582 | td = out.TaskDefinition 583 | } else { 584 | slog.Debug(f("cache hit for %s", tdArn)) 585 | } 586 | if _td, ok := td.(*types.TaskDefinition); ok { 587 | for _, c := range _td.ContainerDefinitions { 588 | for _, m := range c.PortMappings { 589 | if m.HostPort == nil { 590 | continue 591 | } 592 | portMap[*c.Name] = int(*m.HostPort) 593 | } 594 | } 595 | } else { 596 | slog.Warn(f("invalid type %s", td)) 597 | } 598 | return portMap, nil 599 | } 600 | 601 | func (e *ECS) GetAccessCount(ctx context.Context, subdomain string, duration time.Duration) (int64, error) { 602 | // truncate to minute 603 | // Period must be a multiple of 60 604 | // https://docs.aws.amazon.com/AmazonCloudWatch/latest/APIReference/API_GetMetricData.html 605 | duration = duration.Truncate(time.Minute) 606 | 607 | ctx, cancel := context.WithTimeout(ctx, APICallTimeout) 608 | defer cancel() 609 | res, err := e.cwSvc.GetMetricData(ctx, &cw.GetMetricDataInput{ 610 | StartTime: aws.Time(time.Now().Add(-duration)), 611 | EndTime: aws.Time(time.Now()), 612 | MetricDataQueries: []cwTypes.MetricDataQuery{ 613 | { 614 | Id: aws.String("request_count"), 615 | MetricStat: &cwTypes.MetricStat{ 616 | Metric: &cwTypes.Metric{ 617 | Dimensions: []cwTypes.Dimension{ 618 | { 619 | Name: aws.String(CloudWatchDimensionName), 620 | Value: aws.String(subdomain), 621 | }, 622 | }, 623 | MetricName: aws.String(CloudWatchMetricName), 624 | Namespace: aws.String(CloudWatchMetricNameSpace), 625 | }, 626 | Period: aws.Int32(int32(duration.Seconds())), 627 | Stat: aws.String("Sum"), 628 | }, 629 | }, 630 | }, 631 | }) 632 | if err != nil { 633 | return 0, err 634 | } 635 | var sum int64 636 | for _, v := range res.MetricDataResults { 637 | for _, vv := range v.Values { 638 | sum += int64(vv) 639 | } 640 | } 641 | return sum, nil 642 | } 643 | 644 | func (e *ECS) PutAccessCounts(ctx context.Context, all map[string]accessCount) error { 645 | metricData := make([]cwTypes.MetricDatum, 0, len(all)) 646 | for subdomain, counters := range all { 647 | for ts, count := range counters { 648 | slog.Debug(f("access for %s %s %d", subdomain, ts.Format(time.RFC3339), count)) 649 | metricData = append(metricData, cwTypes.MetricDatum{ 650 | MetricName: aws.String(CloudWatchMetricName), 651 | Timestamp: aws.Time(ts), 652 | Value: aws.Float64(float64(count)), 653 | Dimensions: []cwTypes.Dimension{ 654 | { 655 | Name: aws.String(CloudWatchDimensionName), 656 | Value: aws.String(subdomain), 657 | }, 658 | }, 659 | }) 660 | } 661 | } 662 | // CloudWatch API has a limit of 20 metric data per request 663 | var eg errgroup.Group 664 | for _, chunk := range lo.Chunk(metricData, 20) { 665 | chunk := chunk 666 | eg.Go(func() error { 667 | ctx, cancel := context.WithTimeout(ctx, APICallTimeout) 668 | defer cancel() 669 | pmInput := cw.PutMetricDataInput{ 670 | Namespace: aws.String(CloudWatchMetricNameSpace), 671 | MetricData: chunk, 672 | } 673 | _, err := e.cwSvc.PutMetricData(ctx, &pmInput) 674 | return err 675 | }) 676 | } 677 | return eg.Wait() 678 | } 679 | -------------------------------------------------------------------------------- /ecs_test.go: -------------------------------------------------------------------------------- 1 | package mirageecs_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/aws/aws-sdk-go-v2/aws" 9 | "github.com/aws/aws-sdk-go-v2/service/ecs/types" 10 | "github.com/google/go-cmp/cmp" 11 | "github.com/google/go-cmp/cmp/cmpopts" 12 | 13 | mirageecs "github.com/acidlemon/mirage-ecs/v2" 14 | ) 15 | 16 | func TestToECSKeyValuePairsAndTags(t *testing.T) { 17 | tests := []struct { 18 | name string 19 | taskParam mirageecs.TaskParameter 20 | configParams mirageecs.Parameters 21 | subdomain string 22 | expectedKVP []types.KeyValuePair 23 | expectedTags []types.Tag 24 | expectedEnv map[string]string 25 | compatV1 bool 26 | }{ 27 | { 28 | name: "Basic Test v1", 29 | taskParam: mirageecs.TaskParameter{ 30 | "Param1": "Value1", 31 | "Param2": "Value2", 32 | }, 33 | configParams: mirageecs.Parameters{ 34 | &mirageecs.Parameter{Name: "Param1", Env: "ENV1"}, 35 | &mirageecs.Parameter{Name: "Param2", Env: "ENV2"}, 36 | &mirageecs.Parameter{Name: "Param3", Env: "ENV3"}, 37 | }, 38 | subdomain: "testsubdomain", 39 | expectedKVP: []types.KeyValuePair{ 40 | {Name: aws.String("SUBDOMAIN"), Value: aws.String("dGVzdHN1YmRvbWFpbg==")}, 41 | {Name: aws.String("SUBDOMAINRAW"), Value: aws.String("testsubdomain")}, 42 | {Name: aws.String("ENV1"), Value: aws.String("Value1")}, 43 | {Name: aws.String("ENV2"), Value: aws.String("Value2")}, 44 | }, 45 | expectedTags: []types.Tag{ 46 | {Key: aws.String("Subdomain"), Value: aws.String("dGVzdHN1YmRvbWFpbg==")}, 47 | {Key: aws.String("ManagedBy"), Value: aws.String(mirageecs.TagValueMirage)}, 48 | {Key: aws.String("Param1"), Value: aws.String("Value1")}, 49 | {Key: aws.String("Param2"), Value: aws.String("Value2")}, 50 | }, 51 | expectedEnv: map[string]string{ 52 | "SUBDOMAIN": "dGVzdHN1YmRvbWFpbg==", 53 | "SUBDOMAINRAW": "testsubdomain", 54 | "ENV1": "Value1", 55 | "ENV2": "Value2", 56 | }, 57 | compatV1: true, 58 | }, 59 | { 60 | name: "Basic Test v2", 61 | taskParam: mirageecs.TaskParameter{ 62 | "Param1": "Value1", 63 | "Param2": "Value2", 64 | }, 65 | configParams: mirageecs.Parameters{ 66 | &mirageecs.Parameter{Name: "Param1", Env: "ENV1"}, 67 | &mirageecs.Parameter{Name: "Param2", Env: "ENV2"}, 68 | &mirageecs.Parameter{Name: "Param3", Env: "ENV3"}, 69 | }, 70 | subdomain: "testsubdomain", 71 | expectedKVP: []types.KeyValuePair{ 72 | {Name: aws.String("SUBDOMAIN"), Value: aws.String("testsubdomain")}, 73 | {Name: aws.String("SUBDOMAINRAW"), Value: aws.String("testsubdomain")}, 74 | {Name: aws.String("ENV1"), Value: aws.String("Value1")}, 75 | {Name: aws.String("ENV2"), Value: aws.String("Value2")}, 76 | }, 77 | expectedTags: []types.Tag{ 78 | {Key: aws.String("Subdomain"), Value: aws.String("dGVzdHN1YmRvbWFpbg==")}, 79 | {Key: aws.String("ManagedBy"), Value: aws.String(mirageecs.TagValueMirage)}, 80 | {Key: aws.String("Param1"), Value: aws.String("Value1")}, 81 | {Key: aws.String("Param2"), Value: aws.String("Value2")}, 82 | }, 83 | expectedEnv: map[string]string{ 84 | "SUBDOMAIN": "testsubdomain", 85 | "SUBDOMAINRAW": "testsubdomain", 86 | "ENV1": "Value1", 87 | "ENV2": "Value2", 88 | }, 89 | compatV1: false, 90 | }, 91 | } 92 | 93 | opt := cmpopts.IgnoreUnexported(types.KeyValuePair{}, types.Tag{}) 94 | for _, tt := range tests { 95 | t.Run(tt.name, func(t *testing.T) { 96 | cfg, _ := mirageecs.NewConfig(context.Background(), &mirageecs.ConfigParams{ 97 | LocalMode: true, 98 | CompatV1: tt.compatV1, 99 | }) 100 | kvpResult := tt.taskParam.ToECSKeyValuePairs(tt.subdomain, tt.configParams, cfg.EncodeSubdomain) 101 | if diff := cmp.Diff(kvpResult, tt.expectedKVP, opt); diff != "" { 102 | t.Errorf("Mismatch in KeyValuePairs (-got +want):\n%s", diff) 103 | } 104 | tagsResult := tt.taskParam.ToECSTags(tt.subdomain, tt.configParams) 105 | if diff := cmp.Diff(tagsResult, tt.expectedTags, opt); diff != "" { 106 | t.Errorf("Mismatch in Tags (-got +want):\n%s", diff) 107 | } 108 | envResult := tt.taskParam.ToEnv(tt.subdomain, tt.configParams, cfg.EncodeSubdomain) 109 | if diff := cmp.Diff(envResult, tt.expectedEnv, opt); diff != "" { 110 | t.Errorf("Mismatch in Env (-got +want):\n%s", diff) 111 | } 112 | }) 113 | } 114 | } 115 | 116 | var purgeTests = []struct { 117 | name string 118 | param *mirageecs.APIPurgeRequest 119 | expected bool 120 | }{ 121 | { 122 | name: "young task", 123 | param: &mirageecs.APIPurgeRequest{ 124 | Duration: "600", // 10 minutes 125 | }, 126 | expected: false, 127 | }, 128 | { 129 | name: "old task", 130 | param: &mirageecs.APIPurgeRequest{ 131 | Duration: "300", // 5 minutes 132 | }, 133 | expected: true, 134 | }, 135 | { 136 | name: "excluded task", 137 | param: &mirageecs.APIPurgeRequest{ 138 | Duration: "300", 139 | Excludes: []string{"test"}, 140 | }, 141 | expected: false, 142 | }, 143 | { 144 | name: "excluded task not match", 145 | param: &mirageecs.APIPurgeRequest{ 146 | Duration: "300", 147 | Excludes: []string{"test2"}, 148 | }, 149 | expected: true, 150 | }, 151 | { 152 | name: "excluded tag", 153 | param: &mirageecs.APIPurgeRequest{ 154 | Duration: "300", 155 | ExcludeTags: []string{"DontPurge:true"}, 156 | }, 157 | expected: false, 158 | }, 159 | { 160 | name: "excluded regexp", 161 | param: &mirageecs.APIPurgeRequest{ 162 | Duration: "300", 163 | ExcludeRegexp: "te.t", 164 | }, 165 | expected: false, 166 | }, 167 | { 168 | name: "excluded regexp not match", 169 | param: &mirageecs.APIPurgeRequest{ 170 | Duration: "300", 171 | ExcludeRegexp: "xxx", 172 | }, 173 | expected: true, 174 | }, 175 | { 176 | name: "excluded tag not match", 177 | param: &mirageecs.APIPurgeRequest{ 178 | Duration: "300", 179 | ExcludeTags: []string{"xxx:true"}, 180 | }, 181 | expected: true, 182 | }, 183 | } 184 | 185 | func TestShouldBePurged(t *testing.T) { 186 | info := mirageecs.Information{ 187 | ID: "0123456789abcdef", 188 | ShortID: "testshortid", 189 | SubDomain: "test", 190 | GitBranch: "develop", 191 | TaskDef: "dummy", 192 | IPAddress: "127.0.0.1", 193 | Created: time.Now().Add(-7 * time.Minute), 194 | LastStatus: "RUNNING", 195 | PortMap: map[string]int{"http": 80}, 196 | Env: map[string]string{"ENV": "test"}, 197 | Tags: []types.Tag{ 198 | {Key: aws.String("Subdomain"), Value: aws.String("test")}, 199 | {Key: aws.String("DontPurge"), Value: aws.String("true")}, 200 | }, 201 | } 202 | for _, s := range purgeTests { 203 | t.Run(s.name, func(t *testing.T) { 204 | p, err := s.param.Validate() 205 | if err != nil { 206 | t.Errorf("Error in Validate: %v", err) 207 | } 208 | t.Logf("PurgeParams: %#v", p) 209 | if info.ShouldBePurged(p) != s.expected { 210 | t.Errorf("Mismatch in ShouldBePurged: %v", s) 211 | } 212 | }) 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /export_test.go: -------------------------------------------------------------------------------- 1 | package mirageecs 2 | 3 | var ( 4 | ValidateSubdomain = validateSubdomain 5 | NewHTTPTransport = newHTTPTransport 6 | ) 7 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/acidlemon/mirage-ecs/v2 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.23.3 6 | 7 | require ( 8 | github.com/ReneKroon/ttlcache/v2 v2.11.0 9 | github.com/aws/aws-sdk-go-v2 v1.19.0 10 | github.com/aws/aws-sdk-go-v2/config v1.18.28 11 | github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.26.3 12 | github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.22.1 13 | github.com/aws/aws-sdk-go-v2/service/ecs v1.28.1 14 | github.com/aws/aws-sdk-go-v2/service/route53 v1.28.4 15 | github.com/aws/aws-sdk-go-v2/service/s3 v1.37.0 16 | github.com/brunoscheufler/aws-ecs-metadata-go v0.0.0-20221221133751-67e37ae746cd 17 | github.com/fujiwara/go-amzn-oidc v0.0.7 18 | github.com/fujiwara/tracer v1.0.2 19 | github.com/golang-jwt/jwt/v4 v4.5.2 20 | github.com/google/go-cmp v0.5.9 21 | github.com/kayac/go-config v0.7.0 22 | github.com/labstack/echo/v4 v4.13.3 23 | github.com/methane/rproxy v0.0.0-20130309122237-aafd1c66433b 24 | github.com/samber/lo v1.38.1 25 | github.com/winebarrel/cronplan v1.10.1 26 | golang.org/x/sync v0.12.0 27 | gopkg.in/yaml.v2 v2.4.0 28 | ) 29 | 30 | require ( 31 | github.com/BurntSushi/toml v1.3.2 // indirect 32 | github.com/alecthomas/participle/v2 v2.1.0 // indirect 33 | github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10 // indirect 34 | github.com/aws/aws-sdk-go-v2/credentials v1.13.27 // indirect 35 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.5 // indirect 36 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.35 // indirect 37 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.29 // indirect 38 | github.com/aws/aws-sdk-go-v2/internal/ini v1.3.36 // indirect 39 | github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.27 // indirect 40 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.11 // indirect 41 | github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.30 // indirect 42 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.29 // indirect 43 | github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.14.4 // indirect 44 | github.com/aws/aws-sdk-go-v2/service/sns v1.17.10 // indirect 45 | github.com/aws/aws-sdk-go-v2/service/sso v1.12.13 // indirect 46 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.13 // indirect 47 | github.com/aws/aws-sdk-go-v2/service/sts v1.19.3 // indirect 48 | github.com/aws/smithy-go v1.13.5 // indirect 49 | github.com/jmespath/go-jmespath v0.4.0 // indirect 50 | github.com/labstack/gommon v0.4.2 // indirect 51 | github.com/mattn/go-colorable v0.1.13 // indirect 52 | github.com/mattn/go-isatty v0.0.20 // indirect 53 | github.com/shogo82148/go-retry v1.0.0 // indirect 54 | github.com/valyala/bytebufferpool v1.0.0 // indirect 55 | github.com/valyala/fasttemplate v1.2.2 // indirect 56 | golang.org/x/crypto v0.36.0 // indirect 57 | golang.org/x/exp v0.0.0-20230725012225-302865e7556b // indirect 58 | golang.org/x/net v0.38.0 // indirect 59 | golang.org/x/sys v0.31.0 // indirect 60 | golang.org/x/text v0.23.0 // indirect 61 | golang.org/x/time v0.8.0 // indirect 62 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect 63 | ) 64 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= 2 | github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= 3 | github.com/ReneKroon/ttlcache/v2 v2.11.0 h1:OvlcYFYi941SBN3v9dsDcC2N8vRxyHcCmJb3Vl4QMoM= 4 | github.com/ReneKroon/ttlcache/v2 v2.11.0/go.mod h1:mBxvsNY+BT8qLLd6CuAJubbKo6r0jh3nb5et22bbfGY= 5 | github.com/alecthomas/assert/v2 v2.3.0 h1:mAsH2wmvjsuvyBvAmCtm7zFsBlb8mIHx5ySLVdDZXL0= 6 | github.com/alecthomas/assert/v2 v2.3.0/go.mod h1:pXcQ2Asjp247dahGEmsZ6ru0UVwnkhktn7S0bBDLxvQ= 7 | github.com/alecthomas/participle/v2 v2.1.0 h1:z7dElHRrOEEq45F2TG5cbQihMtNTv8vwldytDj7Wrz4= 8 | github.com/alecthomas/participle/v2 v2.1.0/go.mod h1:Y1+hAs8DHPmc3YUFzqllV+eSQ9ljPTk0ZkPMtEdAx2c= 9 | github.com/alecthomas/repr v0.2.0 h1:HAzS41CIzNW5syS8Mf9UwXhNH1J9aix/BvDRf1Ml2Yk= 10 | github.com/alecthomas/repr v0.2.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= 11 | github.com/aws/aws-sdk-go-v2 v1.16.8/go.mod h1:6CpKuLXg2w7If3ABZCl/qZ6rEgwtjZTn4eAf4RcEyuw= 12 | github.com/aws/aws-sdk-go-v2 v1.19.0 h1:klAT+y3pGFBU/qVf1uzwttpBbiuozJYWzNLHioyDJ+k= 13 | github.com/aws/aws-sdk-go-v2 v1.19.0/go.mod h1:uzbQtefpm44goOPmdKyAlXSNcwlRgF3ePWVW6EtJvvw= 14 | github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10 h1:dK82zF6kkPeCo8J1e+tGx4JdvDIQzj7ygIoLg8WMuGs= 15 | github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10/go.mod h1:VeTZetY5KRJLuD/7fkQXMU6Mw7H5m/KP2J5Iy9osMno= 16 | github.com/aws/aws-sdk-go-v2/config v1.18.28 h1:TINEaKyh1Td64tqFvn09iYpKiWjmHYrG1fa91q2gnqw= 17 | github.com/aws/aws-sdk-go-v2/config v1.18.28/go.mod h1:nIL+4/8JdAuNHEjn/gPEXqtnS02Q3NXB/9Z7o5xE4+A= 18 | github.com/aws/aws-sdk-go-v2/credentials v1.13.27 h1:dz0yr/yR1jweAnsCx+BmjerUILVPQ6FS5AwF/OyG1kA= 19 | github.com/aws/aws-sdk-go-v2/credentials v1.13.27/go.mod h1:syOqAek45ZXZp29HlnRS/BNgMIW6uiRmeuQsz4Qh2UE= 20 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.5 h1:kP3Me6Fy3vdi+9uHd7YLr6ewPxRL+PU6y15urfTaamU= 21 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.5/go.mod h1:Gj7tm95r+QsDoN2Fhuz/3npQvcZbkEf5mL70n3Xfluc= 22 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.15/go.mod h1:pWrr2OoHlT7M/Pd2y4HV3gJyPb3qj5qMmnPkKSNPYK4= 23 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.35 h1:hMUCiE3Zi5AHrRNGf5j985u0WyqI6r2NULhUfo0N/No= 24 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.35/go.mod h1:ipR5PvpSPqIqL5Mi82BxLnfMkHVbmco8kUwO2xrCi0M= 25 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.9/go.mod h1:08tUpeSGN33QKSO7fwxXczNfiwCpbj+GxK6XKwqWVv0= 26 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.29 h1:yOpYx+FTBdpk/g+sBU6Cb1H0U/TLEcYYp66mYqsPpcc= 27 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.29/go.mod h1:M/eUABlDbw2uVrdAn+UsI6M727qp2fxkp8K0ejcBDUY= 28 | github.com/aws/aws-sdk-go-v2/internal/ini v1.3.36 h1:8r5m1BoAWkn0TDC34lUculryf7nUF25EgIMdjvGCkgo= 29 | github.com/aws/aws-sdk-go-v2/internal/ini v1.3.36/go.mod h1:Rmw2M1hMVTwiUhjwMoIBFWFJMhvJbct06sSidxInkhY= 30 | github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.27 h1:cZG7psLfqpkB6H+fIrgUDWmlzM474St1LP0jcz272yI= 31 | github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.27/go.mod h1:ZdjYvJpDlefgh8/hWelJhqgqJeodxu4SmbVsSdBlL7E= 32 | github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.26.3 h1:sAqtjjMc1DdA0JnYKKuqJVt/eHLTuN7bDf2T4UQ9sDs= 33 | github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.26.3/go.mod h1:r6kXYdL8M2/BnZatWvQ8yC/3UQvPrXTQnJtZ0xEbKRM= 34 | github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.22.1 h1:qm8LnOQM9yHwfGI7kY2W3gpd3hKttGuKkWplI7fHGH4= 35 | github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.22.1/go.mod h1:4tbPbziIVYtGAoIqr939uQmg6G/RAbZtU9j4384r1LI= 36 | github.com/aws/aws-sdk-go-v2/service/ecs v1.28.1 h1:PxWgrtfQvct60NjxSrFsSWG/Yg1HATRKP4IeUPiLlrE= 37 | github.com/aws/aws-sdk-go-v2/service/ecs v1.28.1/go.mod h1:eZBCsRjzc+ZX8x3h0beHOu+uxRWRwnEHzzvDgKy9v0E= 38 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.11 h1:y2+VQzC6Zh2ojtV2LoC0MNwHWc6qXv/j2vrQtlftkdA= 39 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.11/go.mod h1:iV4q2hsqtNECrfmlXyord9u4zyuFEJX9eLgLpSPzWA8= 40 | github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.30 h1:Bje8Xkh2OWpjBdNfXLrnn8eZg569dUQmhgtydxAYyP0= 41 | github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.30/go.mod h1:qQtIBl5OVMfmeQkz8HaVyh5DzFmmFXyvK27UgIgOr4c= 42 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.29 h1:IiDolu/eLmuB18DRZibj77n1hHQT7z12jnGO7Ze3pLc= 43 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.29/go.mod h1:fDbkK4o7fpPXWn8YAPmTieAMuB9mk/VgvW64uaUqxd4= 44 | github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.14.4 h1:hx4WksB0NRQ9utR+2c3gEGzl6uKj3eM6PMQ6tN3lgXs= 45 | github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.14.4/go.mod h1:JniVpqvw90sVjNqanGLufrVapWySL28fhBlYgl96Q/w= 46 | github.com/aws/aws-sdk-go-v2/service/route53 v1.28.4 h1:p4mTxJfCAyiTT4Wp6p/mOPa6j5MqCSRGot8qZwFs+Z0= 47 | github.com/aws/aws-sdk-go-v2/service/route53 v1.28.4/go.mod h1:VBLWpaHvhQNeu7N9rMEf00SWeOONb/HvaDUxe/7b44k= 48 | github.com/aws/aws-sdk-go-v2/service/s3 v1.37.0 h1:PalLOEGZ/4XfQxpGZFTLaoJSmPoybnqJYotaIZEf/Rg= 49 | github.com/aws/aws-sdk-go-v2/service/s3 v1.37.0/go.mod h1:PwyKKVL0cNkC37QwLcrhyeCrAk+5bY8O2ou7USyAS2A= 50 | github.com/aws/aws-sdk-go-v2/service/sns v1.17.10 h1:ZZuqucIwjbUEJqxxR++VDZX9BcMbX5ZcQaKoWul/ELk= 51 | github.com/aws/aws-sdk-go-v2/service/sns v1.17.10/go.mod h1:uITsRNVMeCB3MkWpXxXw0eDz8pW4TYLzj+eyQtbhSxM= 52 | github.com/aws/aws-sdk-go-v2/service/sso v1.12.13 h1:sWDv7cMITPcZ21QdreULwxOOAmE05JjEsT6fCDtDA9k= 53 | github.com/aws/aws-sdk-go-v2/service/sso v1.12.13/go.mod h1:DfX0sWuT46KpcqbMhJ9QWtxAIP1VozkDWf8VAkByjYY= 54 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.13 h1:BFubHS/xN5bjl818QaroN6mQdjneYQ+AOx44KNXlyH4= 55 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.13/go.mod h1:BzqsVVFduubEmzrVtUFQQIQdFqvUItF8XUq2EnS8Wog= 56 | github.com/aws/aws-sdk-go-v2/service/sts v1.19.3 h1:e5mnydVdCVWxP+5rPAGi2PYxC7u2OZgH1ypC114H04U= 57 | github.com/aws/aws-sdk-go-v2/service/sts v1.19.3/go.mod h1:yVGZA1CPkmUhBdA039jXNJJG7/6t+G+EBWmFq23xqnY= 58 | github.com/aws/smithy-go v1.12.0/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA= 59 | github.com/aws/smithy-go v1.13.5 h1:hgz0X/DX0dGqTYpGALqXJoRKRj5oQ7150i5FdTePzO8= 60 | github.com/aws/smithy-go v1.13.5/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA= 61 | github.com/brunoscheufler/aws-ecs-metadata-go v0.0.0-20221221133751-67e37ae746cd h1:C0dfBzAdNMqxokqWUysk2KTJSMmqvh9cNW1opdy5+0Q= 62 | github.com/brunoscheufler/aws-ecs-metadata-go v0.0.0-20221221133751-67e37ae746cd/go.mod h1:CeKhh8xSs3WZAc50xABMxu+FlfAAd5PNumo7NfOv7EE= 63 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 64 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 65 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 66 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 67 | github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= 68 | github.com/fujiwara/go-amzn-oidc v0.0.7 h1:PlvfdGu0QKIy3MHmAsSeHOGpMcPC7k8VTmXtg3y8oBQ= 69 | github.com/fujiwara/go-amzn-oidc v0.0.7/go.mod h1:KwCMzB/xJ+0ehAQ0+ResFXgl5h0xHr1noDrQadqhKVE= 70 | github.com/fujiwara/tracer v1.0.2 h1:ztstnson+QwOpO69Jir4nkUKlYgse3vJ28FO2eOUPk0= 71 | github.com/fujiwara/tracer v1.0.2/go.mod h1:r2QzBEBNsW9OhmoVdmTANG+GEmxWNZk7317/mnW2yIw= 72 | github.com/golang-jwt/jwt/v4 v4.4.3/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= 73 | github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= 74 | github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= 75 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 76 | github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= 77 | github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= 78 | github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= 79 | github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= 80 | github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= 81 | github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 82 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 83 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 84 | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 85 | github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 86 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 87 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 88 | github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= 89 | github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= 90 | github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= 91 | github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= 92 | github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= 93 | github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= 94 | github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= 95 | github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= 96 | github.com/kayac/go-config v0.7.0 h1:BeONaFFq/ILFiEzkCMpKarsjcc3YBgJ7QKg39hXU+nk= 97 | github.com/kayac/go-config v0.7.0/go.mod h1:Nfkw4LZOh/7HGepftBvD2lKEpPyl1Vp89yA7gDJS5r0= 98 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 99 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 100 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 101 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 102 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 103 | github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= 104 | github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= 105 | github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= 106 | github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= 107 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 108 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 109 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 110 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 111 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 112 | github.com/methane/rproxy v0.0.0-20130309122237-aafd1c66433b h1:b0UQLKwxhmjHjmU5G7MjZb/znNzrVbNXo7cqBFbheMM= 113 | github.com/methane/rproxy v0.0.0-20130309122237-aafd1c66433b/go.mod h1:dFFVhfY408BCzenWxVoBko8GGhFvaWJWMlbLn/fICO8= 114 | github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= 115 | github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 116 | github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= 117 | github.com/onsi/ginkgo v1.14.2/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= 118 | github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= 119 | github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= 120 | github.com/onsi/gomega v1.10.3/go.mod h1:V9xEwhxec5O8UDM77eCW8vLymOMltsqPVYWrpDsH8xc= 121 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 122 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 123 | github.com/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM= 124 | github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA= 125 | github.com/serenize/snaker v0.0.0-20171204205717-a683aaf2d516/go.mod h1:Yow6lPLSAXx2ifx470yD/nUe22Dv5vBvxK/UK9UUTVs= 126 | github.com/shogo82148/go-retry v1.0.0 h1:c487Qe+QYUffpUpPxrUN5fGJq6WVHSzGS4N3MNuR2OU= 127 | github.com/shogo82148/go-retry v1.0.0/go.mod h1:5jiw5yPWW6K+pMyimtNoaQSDD08RMEsJbhDwFrui5rc= 128 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 129 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 130 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 131 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 132 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 133 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 134 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 135 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 136 | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 137 | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 138 | github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= 139 | github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= 140 | github.com/winebarrel/cronplan v1.10.1 h1:sTnmKWpGjXr3tDpgSSTCohltaimpBNXW/LgFf0SEMwo= 141 | github.com/winebarrel/cronplan v1.10.1/go.mod h1:FXpmoZVzj9eZoyHe1lpUezcFL3Tk6p5OBSovWeHq4qY= 142 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 143 | go.uber.org/goleak v1.1.10 h1:z+mqJhf6ss6BSfSM671tgKyZBFPTTJM+HLxnhPC3wu0= 144 | go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= 145 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 146 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 147 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 148 | golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= 149 | golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= 150 | golang.org/x/exp v0.0.0-20230725012225-302865e7556b h1:tK7yjGqVRzYdXsBcfD2MLhFAhHfDgGLm2rY1ub7FA9k= 151 | golang.org/x/exp v0.0.0-20230725012225-302865e7556b/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= 152 | golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 153 | golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5 h1:2M3HP5CCK1Si9FQhwnzYhXdG6DXeebvUHFpre8QvbyI= 154 | golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= 155 | golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= 156 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 157 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 158 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 159 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 160 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 161 | golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 162 | golang.org/x/net v0.0.0-20201006153459-a7d1128ccaa0/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 163 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 164 | golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= 165 | golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= 166 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 167 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 168 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 169 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 170 | golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= 171 | golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 172 | golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 173 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 174 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 175 | golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 176 | golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 177 | golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 178 | golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 179 | golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 180 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 181 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 182 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 183 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 184 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 185 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 186 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 187 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 188 | golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 189 | golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 190 | golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= 191 | golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 192 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 193 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 194 | golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 195 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 196 | golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 197 | golang.org/x/tools v0.0.0-20210112230658-8b4aab62c064/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 198 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= 199 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= 200 | golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 201 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 202 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 203 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 204 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= 205 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 206 | google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= 207 | google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= 208 | google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= 209 | google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= 210 | google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= 211 | google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 212 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 213 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= 214 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 215 | gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= 216 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 217 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 218 | gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 219 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 220 | gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 221 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 222 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 223 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 224 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 225 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 226 | -------------------------------------------------------------------------------- /html/launcher.html: -------------------------------------------------------------------------------- 1 | 57 | 71 | -------------------------------------------------------------------------------- /html/layout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Mirage-ECS Dashboard 8 | 10 | 12 | 13 | 14 | 15 | 16 | 28 |
29 |

Current Task List

30 | 32 |
33 | 34 |
35 | 40 | 43 |
44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /html/list.html: -------------------------------------------------------------------------------- 1 | {{ if .error }} 2 |

Error occurred while retreiving information. Detail: {{ .error }}

3 | {{ else }} 4 | 5 |
6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | {{ range $row := .info }} 22 | 23 | 24 | 25 | 26 | 33 | 36 | 37 | 48 | 51 | 52 | 53 | {{ end }} 54 | 55 |
subdomainbranchTask definitionTask IDStartedStatusActionTrace
{{ $row.SubDomain }}{{ $row.GitBranch }}{{ $row.TaskDef }} 27 |
28 | {{ slice $row.ShortID 0 8 }}... 29 | 30 | 31 |
32 |
{{if $row.Created.IsZero}}- 34 | {{ else }}{{$row.Created.Format "2006-01-02 15:04:05 MST"}} 35 | {{end}}{{ $row.LastStatus }} 38 | {{ if eq $row.LastStatus "RUNNING" }} 39 | 45 | 46 | {{ end }} 47 | 49 | 50 |
56 | {{ end }} 57 | -------------------------------------------------------------------------------- /local.go: -------------------------------------------------------------------------------- 1 | package mirageecs 2 | 3 | import ( 4 | "context" 5 | "crypto/rand" 6 | "encoding/hex" 7 | "fmt" 8 | "log/slog" 9 | "net/http" 10 | "net/http/httptest" 11 | "net/url" 12 | "sort" 13 | "strconv" 14 | "time" 15 | 16 | "github.com/samber/lo" 17 | ) 18 | 19 | // LocalTaskRunner is a mock implementation of TaskRunner. 20 | type LocalTaskRunner struct { 21 | Informations []*Information 22 | 23 | stopServerFuncs map[string]func() 24 | cfg *Config 25 | proxyControlCh chan *proxyControl 26 | } 27 | 28 | func NewLocalTaskRunner(cfg *Config) TaskRunner { 29 | return &LocalTaskRunner{ 30 | Informations: []*Information{}, 31 | stopServerFuncs: map[string]func(){}, 32 | cfg: cfg, 33 | } 34 | } 35 | 36 | func (e *LocalTaskRunner) SetProxyControlChannel(ch chan *proxyControl) { 37 | e.proxyControlCh = ch 38 | } 39 | 40 | func (e *LocalTaskRunner) List(_ context.Context, status string) ([]*Information, error) { 41 | infos := lo.Filter(e.Informations, func(info *Information, _ int) bool { 42 | return info.LastStatus == status 43 | }) 44 | sort.Slice(infos, func(i, j int) bool { 45 | return infos[i].Created.After(infos[j].Created) 46 | }) 47 | return infos, nil 48 | } 49 | 50 | func (e *LocalTaskRunner) Trace(_ context.Context, id string) (string, error) { 51 | return fmt.Sprintf("mock trace of %s", id), nil 52 | } 53 | 54 | func (e *LocalTaskRunner) Launch(ctx context.Context, subdomain string, option TaskParameter, taskdefs ...string) error { 55 | if info, ok := e.find(subdomain); ok { 56 | slog.Info(f("subdomain %s is already running task id %s. Terminating...", subdomain, info.ShortID)) 57 | err := e.TerminateBySubdomain(ctx, subdomain) 58 | if err != nil { 59 | return err 60 | } 61 | } 62 | id := generateRandomHexID(32) 63 | env := option.ToEnv(subdomain, e.cfg.Parameter, e.cfg.EncodeSubdomain) 64 | slog.Info(f("Launching a new mock task: subdomain=%s, taskdef=%s, id=%s", subdomain, taskdefs[0], id)) 65 | contents := fmt.Sprintf("Hello, Mirage! subdomain: %s\n%#v", subdomain, env) 66 | port, stopServerFunc := runMockServer(contents) 67 | e.Informations = append(e.Informations, &Information{ 68 | ID: "arn:aws:ecs:ap-northeast-1:123456789012:task/mirage/" + id, 69 | ShortID: id, 70 | SubDomain: subdomain, 71 | GitBranch: option["branch"], 72 | TaskDef: taskdefs[0], 73 | IPAddress: "127.0.0.1", 74 | Created: time.Now().UTC(), 75 | LastStatus: statusRunning, 76 | PortMap: map[string]int{ 77 | "httpd": port, 78 | }, 79 | Env: env, 80 | Tags: option.ToECSTags(subdomain, e.cfg.Parameter), 81 | }) 82 | e.stopServerFuncs[id] = stopServerFunc 83 | e.proxyControlCh <- &proxyControl{ 84 | Action: proxyAdd, 85 | Subdomain: subdomain, 86 | IPAddress: "127.0.0.1", 87 | Port: port, 88 | } 89 | return nil 90 | } 91 | 92 | func (e *LocalTaskRunner) Logs(_ context.Context, subdomain string, since time.Time, tail int) ([]string, error) { 93 | // Logs returns logs of the specified subdomain. 94 | return []string{"Sorry. mock server logs are empty."}, nil 95 | } 96 | 97 | func (e *LocalTaskRunner) Terminate(ctx context.Context, id string) error { 98 | for _, info := range e.Informations { 99 | if info.ID == id { 100 | return e.TerminateBySubdomain(ctx, info.SubDomain) 101 | } 102 | } 103 | return nil 104 | } 105 | 106 | func (e *LocalTaskRunner) find(subdomain string) (*Information, bool) { 107 | for _, info := range e.Informations { 108 | if info.SubDomain == subdomain && info.LastStatus == statusRunning { 109 | return info, true 110 | } 111 | } 112 | return nil, false 113 | } 114 | 115 | func (e *LocalTaskRunner) TerminateBySubdomain(ctx context.Context, subdomain string) error { 116 | slog.Info(f("Terminating a mock task: subdomain=%s", subdomain)) 117 | if info, ok := e.find(subdomain); ok { 118 | if stop := e.stopServerFuncs[info.ShortID]; stop != nil { 119 | stop() 120 | } 121 | e.proxyControlCh <- &proxyControl{ 122 | Action: proxyRemove, 123 | Subdomain: subdomain, 124 | } 125 | info.LastStatus = statusStopped 126 | e.Informations = lo.Filter(e.Informations, func(i *Information, _ int) bool { 127 | return i.ShortID != info.ShortID 128 | }) 129 | e.Informations = append(e.Informations, info) 130 | } 131 | return nil 132 | } 133 | 134 | func generateRandomHexID(length int) string { 135 | idBytes := make([]byte, length/2) 136 | if _, err := rand.Read(idBytes); err != nil { 137 | panic(err) 138 | } 139 | id := hex.EncodeToString(idBytes) 140 | return id 141 | } 142 | 143 | // run mock http server on ephemeral port at localhost, returns the port number and a function to stop the server 144 | func runMockServer(content string) (int, func()) { 145 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 146 | fmt.Fprintln(w, content) 147 | })) 148 | slog.Info(f("mock server is running at %s", ts.URL)) 149 | u, _ := url.Parse(ts.URL) 150 | port, _ := strconv.Atoi(u.Port()) 151 | return port, ts.Close 152 | } 153 | 154 | func (e *LocalTaskRunner) GetAccessCount(_ context.Context, subdomain string, duration time.Duration) (int64, error) { 155 | slog.Debug("GetAccessCount is not implemented in LocalTaskRunner") 156 | return 0, nil 157 | } 158 | 159 | func (e *LocalTaskRunner) PutAccessCounts(_ context.Context, _ map[string]accessCount) error { 160 | slog.Debug("PutAccessCounts is not implemented in LocalTaskRunner") 161 | return nil 162 | } 163 | -------------------------------------------------------------------------------- /log.go: -------------------------------------------------------------------------------- 1 | package mirageecs 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "io" 8 | "log/slog" 9 | "path" 10 | "runtime" 11 | "strings" 12 | "sync" 13 | ) 14 | 15 | const LogTimeFormat = "2006-01-02T15:04:05.999Z07:00" 16 | 17 | var LogLevel = new(slog.LevelVar) 18 | 19 | func init() { 20 | LogLevel.Set(slog.LevelInfo) 21 | } 22 | 23 | func f(format string, args ...any) string { 24 | return fmt.Sprintf(format, args...) 25 | } 26 | 27 | func SetLogLevel(l string) { 28 | switch strings.ToLower(l) { 29 | case "debug": 30 | LogLevel.Set(slog.LevelDebug) 31 | case "info": 32 | LogLevel.Set(slog.LevelInfo) 33 | case "warn": 34 | LogLevel.Set(slog.LevelWarn) 35 | case "error": 36 | LogLevel.Set(slog.LevelError) 37 | default: 38 | slog.Warn(f("invalid log level %s. ignored", l)) 39 | } 40 | } 41 | 42 | type logHandler struct { 43 | opts *slog.HandlerOptions 44 | preformatted []byte 45 | mu *sync.Mutex 46 | w io.Writer 47 | } 48 | 49 | func NewLogHandler(w io.Writer, opts *slog.HandlerOptions) slog.Handler { 50 | return &logHandler{ 51 | opts: opts, 52 | mu: new(sync.Mutex), 53 | w: w, 54 | } 55 | } 56 | 57 | func (h *logHandler) Enabled(ctx context.Context, level slog.Level) bool { 58 | return level >= h.opts.Level.Level() 59 | } 60 | 61 | func (h *logHandler) Handle(ctx context.Context, record slog.Record) error { 62 | buf := bytes.NewBuffer(nil) 63 | fmt.Fprint(buf, record.Time.Format(LogTimeFormat)) 64 | fmt.Fprintf(buf, " [%s]", strings.ToLower(record.Level.String())) 65 | if h.opts.AddSource { 66 | frame, _ := runtime.CallersFrames([]uintptr{record.PC}).Next() 67 | fmt.Fprintf(buf, " [%s:%d]", path.Base(frame.File), frame.Line) 68 | } 69 | if len(h.preformatted) > 0 { 70 | buf.Write(h.preformatted) 71 | } 72 | record.Attrs(func(a slog.Attr) bool { 73 | fmt.Fprintf(buf, " [%s:%v]", a.Key, a.Value) 74 | return true 75 | }) 76 | fmt.Fprintf(buf, " %s\n", record.Message) 77 | h.mu.Lock() 78 | defer h.mu.Unlock() 79 | _, err := h.w.Write(buf.Bytes()) 80 | return err 81 | } 82 | 83 | func (h *logHandler) WithAttrs(attrs []slog.Attr) slog.Handler { 84 | preformatted := []byte{} 85 | for _, a := range attrs { 86 | preformatted = append(preformatted, fmt.Sprintf(" [%s:%v]", a.Key, a.Value)...) 87 | } 88 | return &logHandler{ 89 | opts: h.opts, 90 | preformatted: preformatted, 91 | mu: h.mu, 92 | w: h.w, 93 | } 94 | } 95 | 96 | func (h *logHandler) WithGroup(group string) slog.Handler { 97 | return h 98 | } 99 | -------------------------------------------------------------------------------- /mirage.go: -------------------------------------------------------------------------------- 1 | package mirageecs 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "log/slog" 8 | "net" 9 | "net/http" 10 | "sort" 11 | "strings" 12 | "sync" 13 | "time" 14 | ) 15 | 16 | var Version = "current" 17 | 18 | type Mirage struct { 19 | Config *Config 20 | WebApi *WebApi 21 | ReverseProxy *ReverseProxy 22 | Route53 *Route53 23 | 24 | runner TaskRunner 25 | proxyControlCh chan *proxyControl 26 | } 27 | 28 | func New(ctx context.Context, cfg *Config) *Mirage { 29 | // launch server 30 | runner := cfg.NewTaskRunner() 31 | ch := make(chan *proxyControl, 10) 32 | runner.SetProxyControlChannel(ch) 33 | m := &Mirage{ 34 | Config: cfg, 35 | ReverseProxy: NewReverseProxy(cfg), 36 | WebApi: NewWebApi(cfg, runner), 37 | Route53: NewRoute53(ctx, cfg), 38 | runner: runner, 39 | proxyControlCh: ch, 40 | } 41 | return m 42 | } 43 | 44 | func (m *Mirage) Run(ctx context.Context) error { 45 | var wg sync.WaitGroup 46 | ctx, cancel := context.WithCancel(ctx) 47 | defer cancel() 48 | errors := make(chan error, 10) 49 | for _, v := range m.Config.Listen.HTTP { 50 | wg.Add(1) 51 | go func(port int) { 52 | defer wg.Done() 53 | laddr := fmt.Sprintf("%s:%d", m.Config.Listen.ForeignAddress, port) 54 | listener, err := net.Listen("tcp", laddr) 55 | if err != nil { 56 | slog.Error(f("cannot listen %s: %s", laddr, err)) 57 | errors <- err 58 | cancel() 59 | return 60 | } 61 | 62 | mux := http.NewServeMux() 63 | mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) { 64 | m.ServeHTTPWithPort(w, req, port) 65 | }) 66 | slog.Info(f("listen addr: %s", laddr)) 67 | srv := &http.Server{ 68 | Handler: mux, 69 | } 70 | go srv.Serve(listener) 71 | <-ctx.Done() 72 | slog.Info(f("shutdown server: %s", laddr)) 73 | srv.Shutdown(ctx) 74 | }(v.ListenPort) 75 | } 76 | 77 | wg.Add(3) 78 | go m.syncECSToMirage(ctx, &wg) 79 | go m.RunAccessCountCollector(ctx, &wg) 80 | go m.RunScheduledPurger(ctx, &wg) 81 | wg.Wait() 82 | slog.Info("shutdown mirage-ecs") 83 | select { 84 | case err := <-errors: 85 | return err 86 | default: 87 | } 88 | m.Config.Cleanup() 89 | return nil 90 | } 91 | 92 | func (m *Mirage) ServeHTTPWithPort(w http.ResponseWriter, req *http.Request, port int) { 93 | host := strings.ToLower(strings.Split(req.Host, ":")[0]) 94 | 95 | switch { 96 | case m.isWebApiHost(host): 97 | m.WebApi.ServeHTTP(w, req) 98 | 99 | case m.isTaskHost(host): 100 | m.ReverseProxy.ServeHTTPWithPort(w, req, port) 101 | 102 | case strings.HasSuffix(host, m.Config.Host.ReverseProxySuffix): 103 | msg := fmt.Sprintf("%s is not found", host) 104 | slog.Warn(msg) 105 | http.Error(w, msg, http.StatusNotFound) 106 | 107 | default: 108 | // not a vhost, returns 200 (for healthcheck) 109 | http.Error(w, "mirage-ecs", http.StatusOK) 110 | } 111 | 112 | } 113 | 114 | func (m *Mirage) isTaskHost(host string) bool { 115 | if strings.HasSuffix(host, m.Config.Host.ReverseProxySuffix) { 116 | subdomain := strings.ToLower(strings.Split(host, ".")[0]) 117 | return m.ReverseProxy.Exists(subdomain) 118 | } 119 | 120 | return false 121 | } 122 | 123 | func (m *Mirage) isWebApiHost(host string) bool { 124 | return isSameHost(m.Config.Host.WebApi, host) 125 | } 126 | 127 | func isSameHost(s1 string, s2 string) bool { 128 | lower1 := strings.Trim(strings.ToLower(s1), " ") 129 | lower2 := strings.Trim(strings.ToLower(s2), " ") 130 | 131 | return lower1 == lower2 132 | } 133 | 134 | func (m *Mirage) RunAccessCountCollector(ctx context.Context, wg *sync.WaitGroup) { 135 | defer wg.Done() 136 | tk := time.NewTicker(m.ReverseProxy.accessCounterUnit) 137 | for { 138 | select { 139 | case <-tk.C: 140 | case <-ctx.Done(): 141 | slog.Warn("RunAccessCountCollector() is done") 142 | return 143 | } 144 | all := m.ReverseProxy.CollectAccessCounts() 145 | s, _ := json.Marshal(all) 146 | slog.Info(f("access counters: %s", string(s))) 147 | m.runner.PutAccessCounts(ctx, all) 148 | } 149 | } 150 | 151 | func (m *Mirage) RunScheduledPurger(ctx context.Context, wg *sync.WaitGroup) { 152 | defer wg.Done() 153 | p := m.Config.Purge 154 | if p == nil { 155 | slog.Debug("Purge is not configured") 156 | return 157 | } 158 | slog.Info(f("starting up RunScheduledPurger() schedule: %s", p.Cron.String())) 159 | for { 160 | now := time.Now().Add(time.Minute) 161 | next := p.Cron.Next(now) 162 | slog.Info(f("next purge invocation at: %s", next)) 163 | duration := time.Until(next) 164 | select { 165 | case <-ctx.Done(): 166 | slog.Info("RunScheduledPurger() is done") 167 | return 168 | case <-time.After(duration): 169 | slog.Info("scheduled purge invoked") 170 | if err := m.WebApi.purge(ctx, p.PurgeParams); err != nil { 171 | slog.Warn(err.Error()) 172 | } 173 | } 174 | } 175 | } 176 | 177 | const ( 178 | CloudWatchMetricNameSpace = "mirage-ecs" 179 | CloudWatchMetricName = "RequestCount" 180 | CloudWatchDimensionName = "subdomain" 181 | ) 182 | 183 | func (app *Mirage) syncECSToMirage(ctx context.Context, wg *sync.WaitGroup) { 184 | wg.Done() 185 | slog.Debug("starting up syncECSToMirage()") 186 | rp := app.ReverseProxy 187 | r53 := app.Route53 188 | ticker := time.NewTicker(time.Second * 10) 189 | defer ticker.Stop() 190 | 191 | SYNC: 192 | for { 193 | select { 194 | case msg := <-app.proxyControlCh: 195 | slog.Debug(f("proxyControl %#v", msg)) 196 | rp.Modify(msg) 197 | continue SYNC 198 | case <-ticker.C: 199 | case <-ctx.Done(): 200 | slog.Debug("syncECSToMirage() is done") 201 | return 202 | } 203 | 204 | running, err := app.runner.List(ctx, statusRunning) 205 | if err != nil { 206 | slog.Warn(err.Error()) 207 | continue 208 | } 209 | sort.SliceStable(running, func(i, j int) bool { 210 | return running[i].Created.Before(running[j].Created) 211 | }) 212 | available := make(map[string]bool) 213 | for _, info := range running { 214 | slog.Debug(f("running task %s", info.ID)) 215 | if info.IPAddress != "" { 216 | available[info.SubDomain] = true 217 | for name, port := range info.PortMap { 218 | rp.AddSubdomain(info.SubDomain, info.IPAddress, port) 219 | r53.Add(name+"."+info.SubDomain, info.IPAddress) 220 | } 221 | } 222 | } 223 | 224 | stopped, err := app.runner.List(ctx, statusStopped) 225 | if err != nil { 226 | slog.Warn(err.Error()) 227 | continue 228 | } 229 | for _, info := range stopped { 230 | slog.Debug(f("stopped task %s", info.ID)) 231 | for name := range info.PortMap { 232 | r53.Delete(name+"."+info.SubDomain, info.IPAddress) 233 | } 234 | } 235 | 236 | for _, subdomain := range rp.Subdomains() { 237 | if !available[subdomain] { 238 | rp.RemoveSubdomain(subdomain) 239 | } 240 | } 241 | if err := r53.Apply(ctx); err != nil { 242 | slog.Warn(err.Error()) 243 | } 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /purge.go: -------------------------------------------------------------------------------- 1 | package mirageecs 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/winebarrel/cronplan" 7 | ) 8 | 9 | type Purge struct { 10 | Schedule string `json:"schedule" yaml:"schedule"` 11 | Request *APIPurgeRequest `json:"request" yaml:"request"` 12 | 13 | PurgeParams *PurgeParams `json:"-" yaml:"-"` 14 | Cron *cronplan.Expression `json:"-" yaml:"-"` 15 | } 16 | 17 | func (p *Purge) Validate() error { 18 | cron, err := cronplan.Parse(p.Schedule) 19 | if err != nil { 20 | return fmt.Errorf("invalid schedule expression %s: %w", p.Schedule, err) 21 | } 22 | p.Cron = cron 23 | 24 | if p.Request == nil { 25 | return fmt.Errorf("purge request is required") 26 | } 27 | purgeParams, err := p.Request.Validate() 28 | if err != nil { 29 | return fmt.Errorf("invalid purge request: %w", err) 30 | } 31 | p.PurgeParams = purgeParams 32 | 33 | return nil 34 | } 35 | -------------------------------------------------------------------------------- /purge_test.go: -------------------------------------------------------------------------------- 1 | package mirageecs_test 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | mirageecs "github.com/acidlemon/mirage-ecs/v2" 8 | "github.com/kayac/go-config" 9 | ) 10 | 11 | func TestPurgeConfig(t *testing.T) { 12 | cfg := mirageecs.Config{} 13 | err := config.LoadWithEnvBytes(&cfg, []byte(` 14 | purge: 15 | schedule: "*/3 * * * ? *" # every 3 minutes 16 | request: 17 | duration: "300" # 5 minutes 18 | excludes: 19 | - "test" 20 | - "test2" 21 | exclude_tags: 22 | - "DontPurge:true" 23 | exclude_regexp: "te.t" 24 | `)) 25 | if err != nil { 26 | t.Fatal(err) 27 | } 28 | if err := cfg.Purge.Validate(); err != nil { 29 | t.Fatal(err) 30 | } 31 | now := time.Date(2024, 11, 7, 11, 22, 33, 0, time.UTC) 32 | next := cfg.Purge.Cron.Next(now) 33 | if next != time.Date(2024, 11, 7, 11, 24, 0, 0, time.UTC) { 34 | t.Errorf("unexpected next time: %s", next) 35 | } 36 | if cfg.Purge.PurgeParams.Duration != time.Second * 300 { 37 | t.Errorf("unexpected duration: %d", cfg.Purge.PurgeParams.Duration) 38 | } 39 | if len(cfg.Purge.PurgeParams.Excludes) != 2 { 40 | t.Errorf("unexpected excludes: %v", cfg.Purge.PurgeParams.Excludes) 41 | } 42 | if len(cfg.Purge.PurgeParams.ExcludeTags) != 1 { 43 | t.Errorf("unexpected exclude_tags: %v", cfg.Purge.PurgeParams.ExcludeTags) 44 | } 45 | if !cfg.Purge.PurgeParams.ExcludeRegexp.MatchString("test") { 46 | t.Errorf("unexpected exclude_regexp: %v", cfg.Purge.PurgeParams.ExcludeRegexp) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /reverseproxy.go: -------------------------------------------------------------------------------- 1 | package mirageecs 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "log/slog" 7 | "net" 8 | "net/http" 9 | "net/url" 10 | "path" 11 | "strconv" 12 | "strings" 13 | "sync" 14 | "time" 15 | 16 | // "github.com/acidlemon/go-dumper" 17 | "github.com/methane/rproxy" 18 | ) 19 | 20 | type proxyAction string 21 | 22 | const ( 23 | proxyAdd = proxyAction("Add") 24 | proxyRemove = proxyAction("Remove") 25 | ) 26 | 27 | var proxyHandlerLifetime = 30 * time.Second 28 | 29 | type proxyControl struct { 30 | Action proxyAction 31 | Subdomain string 32 | IPAddress string 33 | Port int 34 | } 35 | 36 | type ReverseProxy struct { 37 | mu sync.RWMutex 38 | cfg *Config 39 | domains []string 40 | domainMap map[string]proxyHandlers 41 | accessCounters map[string]*AccessCounter 42 | accessCounterUnit time.Duration 43 | } 44 | 45 | func NewReverseProxy(cfg *Config) *ReverseProxy { 46 | unit := time.Minute 47 | if cfg.localMode { 48 | unit = time.Second * 10 49 | proxyHandlerLifetime = time.Hour * 24 * 365 * 10 // not expire 50 | slog.Debug(f("local mode: access counter unit=%s", unit)) 51 | } 52 | return &ReverseProxy{ 53 | cfg: cfg, 54 | domainMap: make(map[string]proxyHandlers), 55 | accessCounters: make(map[string]*AccessCounter), 56 | accessCounterUnit: unit, 57 | } 58 | } 59 | 60 | func (r *ReverseProxy) ServeHTTPWithPort(w http.ResponseWriter, req *http.Request, port int) { 61 | subdomain := strings.ToLower(strings.Split(req.Host, ".")[0]) 62 | 63 | if handler := r.FindHandler(subdomain, port); handler != nil { 64 | slog.Debug(f("proxy handler found for subdomain %s", subdomain)) 65 | handler.ServeHTTP(w, req) 66 | } else { 67 | slog.Debug(f("proxy handler not found for subdomain %s", subdomain)) 68 | http.NotFound(w, req) 69 | } 70 | } 71 | 72 | func (r *ReverseProxy) Exists(subdomain string) bool { 73 | r.mu.RLock() 74 | defer r.mu.RUnlock() 75 | _, exists := r.domainMap[subdomain] 76 | if exists { 77 | return true 78 | } 79 | for _, name := range r.domains { 80 | if m, _ := path.Match(name, subdomain); m { 81 | return true 82 | } 83 | } 84 | return false 85 | } 86 | 87 | func (r *ReverseProxy) Subdomains() []string { 88 | r.mu.RLock() 89 | defer r.mu.RUnlock() 90 | ds := make([]string, len(r.domains)) 91 | copy(ds, r.domains) 92 | return ds 93 | } 94 | 95 | func (r *ReverseProxy) FindHandler(subdomain string, port int) http.Handler { 96 | r.mu.RLock() 97 | defer r.mu.RUnlock() 98 | slog.Debug(f("FindHandler for %s:%d", subdomain, port)) 99 | 100 | proxyHandlers, ok := r.domainMap[subdomain] 101 | if !ok { 102 | for _, name := range r.domains { 103 | if m, _ := path.Match(name, subdomain); m { 104 | proxyHandlers = r.domainMap[name] 105 | break 106 | } 107 | } 108 | if proxyHandlers == nil { 109 | return nil 110 | } 111 | } 112 | 113 | handler, ok := proxyHandlers.Handler(port) 114 | if !ok { 115 | return nil 116 | } 117 | return handler 118 | } 119 | 120 | type proxyHandler struct { 121 | handler http.Handler 122 | timer *time.Timer 123 | } 124 | 125 | func newProxyHandler(h http.Handler) *proxyHandler { 126 | return &proxyHandler{ 127 | handler: h, 128 | timer: time.NewTimer(proxyHandlerLifetime), 129 | } 130 | } 131 | 132 | func (h *proxyHandler) alive() bool { 133 | select { 134 | case <-h.timer.C: 135 | return false 136 | default: 137 | return true 138 | } 139 | } 140 | 141 | func (h *proxyHandler) extend() { 142 | h.timer.Reset(proxyHandlerLifetime) // extend lifetime 143 | } 144 | 145 | type proxyHandlers map[int]map[string]*proxyHandler 146 | 147 | func (ph proxyHandlers) Handler(port int) (http.Handler, bool) { 148 | handlers := ph[port] 149 | if len(handlers) == 0 { 150 | return nil, false 151 | } 152 | for ipaddress, handler := range ph[port] { 153 | if handler.alive() { 154 | // return first (randomized by Go's map) 155 | return handler.handler, true 156 | } else { 157 | slog.Info(f("proxy handler to %s is dead", ipaddress)) 158 | delete(ph[port], ipaddress) 159 | } 160 | } 161 | return nil, false 162 | } 163 | 164 | func (ph proxyHandlers) exists(port int, addr string) bool { 165 | if ph[port] == nil { 166 | return false 167 | } 168 | if h := ph[port][addr]; h == nil { 169 | return false 170 | } else if h.alive() { 171 | slog.Debug(f("proxy handler to %s extends lifetime", addr)) 172 | h.extend() 173 | return true 174 | } else { 175 | slog.Info(f("proxy handler to %s is dead", addr)) 176 | delete(ph[port], addr) 177 | return false 178 | } 179 | } 180 | 181 | func (ph proxyHandlers) add(port int, ipaddress string, h http.Handler) { 182 | if ph[port] == nil { 183 | ph[port] = make(map[string]*proxyHandler) 184 | } 185 | slog.Info(f("new proxy handler to %s", ipaddress)) 186 | ph[port][ipaddress] = newProxyHandler(h) 187 | } 188 | 189 | func (r *ReverseProxy) AddSubdomain(subdomain string, ipaddress string, targetPort int) { 190 | r.mu.Lock() 191 | defer r.mu.Unlock() 192 | addr := net.JoinHostPort(ipaddress, strconv.Itoa(targetPort)) 193 | slog.Debug(f("AddSubdomain %s -> %s", subdomain, addr)) 194 | var ph proxyHandlers 195 | if _ph, exists := r.domainMap[subdomain]; exists { 196 | ph = _ph 197 | } else { 198 | ph = make(proxyHandlers) 199 | } 200 | 201 | var counter *AccessCounter 202 | if c, exists := r.accessCounters[subdomain]; exists { 203 | counter = c 204 | } else { 205 | counter = NewAccessCounter(r.accessCounterUnit) 206 | r.accessCounters[subdomain] = counter 207 | } 208 | 209 | // create reverse proxy 210 | proxy := false 211 | for _, v := range r.cfg.Listen.HTTP { 212 | if (v.TargetPort != targetPort) && !r.cfg.localMode { 213 | continue 214 | // local mode allows any port 215 | } 216 | if ph.exists(v.ListenPort, addr) { 217 | proxy = true 218 | continue 219 | } 220 | destUrlString := "http://" + addr 221 | destUrl, err := url.Parse(destUrlString) 222 | if err != nil { 223 | slog.Error(f("invalid destination url: %s %s", destUrlString, err)) 224 | continue 225 | } 226 | handler := rproxy.NewSingleHostReverseProxy(destUrl) 227 | tp := &Transport{ 228 | Transport: newHTTPTransport(r.cfg.Network.ProxyTimeout), 229 | Counter: counter, 230 | Subdomain: subdomain, 231 | } 232 | if v.RequireAuthCookie { 233 | tp.AuthCookieValidateFunc = r.cfg.Auth.ValidateAuthCookie 234 | } 235 | handler.Transport = tp 236 | ph.add(v.ListenPort, addr, handler) 237 | proxy = true 238 | slog.Info(f("add subdomain: %s:%d -> %s", subdomain, v.ListenPort, addr)) 239 | } 240 | if !proxy { 241 | slog.Warn(f("proxy of subdomain %s(target port %d) is not created. define target port in listen.http[]", subdomain, targetPort)) 242 | return 243 | } 244 | 245 | r.domainMap[subdomain] = ph 246 | for _, name := range r.domains { 247 | if name == subdomain { 248 | return 249 | } 250 | } 251 | r.domains = append(r.domains, subdomain) 252 | } 253 | 254 | func (r *ReverseProxy) RemoveSubdomain(subdomain string) { 255 | r.mu.Lock() 256 | defer r.mu.Unlock() 257 | slog.Info(f("removing subdomain: %s", subdomain)) 258 | delete(r.domainMap, subdomain) 259 | delete(r.accessCounters, subdomain) 260 | for i, name := range r.domains { 261 | if name == subdomain { 262 | r.domains = append(r.domains[:i], r.domains[i+1:]...) 263 | return 264 | } 265 | } 266 | } 267 | 268 | func (r *ReverseProxy) Modify(action *proxyControl) { 269 | switch action.Action { 270 | case proxyAdd: 271 | r.AddSubdomain(action.Subdomain, action.IPAddress, action.Port) 272 | case proxyRemove: 273 | r.RemoveSubdomain(action.Subdomain) 274 | default: 275 | slog.Error(f("unknown proxy action: %s", action.Action)) 276 | } 277 | } 278 | 279 | func (r *ReverseProxy) CollectAccessCounts() map[string]accessCount { 280 | r.mu.RLock() 281 | defer r.mu.RUnlock() 282 | counts := make(map[string]accessCount) 283 | for subdomain, counter := range r.accessCounters { 284 | counts[subdomain] = counter.Collect() 285 | } 286 | return counts 287 | } 288 | 289 | func newHTTPTransport(t time.Duration) http.RoundTripper { 290 | tp := http.DefaultTransport.(*http.Transport).Clone() 291 | tp.DialContext = (&net.Dialer{ 292 | Timeout: t, 293 | KeepAlive: 30 * time.Second, 294 | }).DialContext 295 | tp.TLSHandshakeTimeout = t 296 | tp.ResponseHeaderTimeout = t 297 | return tp 298 | } 299 | 300 | type Transport struct { 301 | Counter *AccessCounter 302 | Transport http.RoundTripper 303 | Subdomain string 304 | AuthCookieValidateFunc func(*http.Cookie) error 305 | } 306 | 307 | func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) { 308 | t.Counter.Add() 309 | 310 | slog.Debug(f("subdomain %s %s roundtrip", t.Subdomain, req.URL)) 311 | // OPTIONS request is not authenticated because it is preflighted. 312 | // https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS#Preflighted_requests 313 | if t.AuthCookieValidateFunc != nil && req.Method != http.MethodOptions { 314 | slog.Debug(f("subdomain %s %s roundtrip: require auth cookie", t.Subdomain, req.URL)) 315 | cookie, err := req.Cookie(AuthCookieName) 316 | if err != nil || cookie == nil { 317 | slog.Warn(f("subdomain %s %s roundtrip failed: %s", t.Subdomain, req.URL, err)) 318 | return newForbiddenResponse(), nil 319 | } 320 | if err := t.AuthCookieValidateFunc(cookie); err != nil { 321 | slog.Warn(f("subdomain %s %s roundtrip failed: %s", t.Subdomain, req.URL, err)) 322 | return newForbiddenResponse(), nil 323 | } 324 | } 325 | resp, err := t.Transport.RoundTrip(req) 326 | if err != nil { 327 | slog.Warn(f("subdomain %s %s roundtrip failed: %s", t.Subdomain, req.URL, err)) 328 | if strings.Contains(err.Error(), "timeout") { 329 | return newTimeoutResponse(t.Subdomain, req.URL.String(), err), nil 330 | } 331 | return nil, err 332 | } 333 | return resp, nil 334 | } 335 | 336 | func newTimeoutResponse(subdomain string, u string, err error) *http.Response { 337 | resp := new(http.Response) 338 | resp.StatusCode = http.StatusGatewayTimeout 339 | msg := fmt.Sprintf("%s upstream timeout: %s %s", subdomain, u, err.Error()) 340 | resp.Body = io.NopCloser(strings.NewReader(msg)) 341 | return resp 342 | } 343 | 344 | func newForbiddenResponse() *http.Response { 345 | resp := new(http.Response) 346 | resp.StatusCode = http.StatusForbidden 347 | resp.Body = io.NopCloser(strings.NewReader("Forbidden")) 348 | return resp 349 | } 350 | -------------------------------------------------------------------------------- /reverseproxy_test.go: -------------------------------------------------------------------------------- 1 | package mirageecs_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/google/go-cmp/cmp" 8 | 9 | mirageecs "github.com/acidlemon/mirage-ecs/v2" 10 | ) 11 | 12 | func TestReverseProxy(t *testing.T) { 13 | ctx := context.Background() 14 | cfg, err := mirageecs.NewConfig(ctx, &mirageecs.ConfigParams{ 15 | Domain: "example.net", 16 | }) 17 | if err != nil { 18 | t.Error(err) 19 | } 20 | cfg.Listen.HTTP = []mirageecs.PortMap{ 21 | {ListenPort: 80, TargetPort: 80}, 22 | {ListenPort: 8080, TargetPort: 8080}, 23 | } 24 | rp := mirageecs.NewReverseProxy(cfg) 25 | 26 | if ds := rp.Subdomains(); len(ds) != 0 { 27 | t.Errorf("invalid subdomains %#v", ds) 28 | } 29 | rp.AddSubdomain("bbb", "192.168.1.2", 80) 30 | rp.AddSubdomain("aaa", "192.168.1.1", 80) 31 | rp.AddSubdomain("ccc", "192.168.1.3", 80) 32 | if diff := cmp.Diff(rp.Subdomains(), []string{"bbb", "aaa", "ccc"}); diff != "" { 33 | t.Errorf("invalid subdomains %s", diff) 34 | } 35 | for _, d := range rp.Subdomains() { 36 | if !rp.Exists(d) { 37 | t.Errorf("subdomain %s not found", d) 38 | } 39 | } 40 | 41 | // add same subdomain 42 | rp.AddSubdomain("aaa", "192.168.1.1", 80) 43 | if diff := cmp.Diff(rp.Subdomains(), []string{"bbb", "aaa", "ccc"}); diff != "" { 44 | t.Errorf("after added same: invalid subdomains %s", diff) 45 | } 46 | 47 | // add same subdomain with different port 48 | rp.AddSubdomain("aaa", "192.168.1.1", 8080) 49 | if diff := cmp.Diff(rp.Subdomains(), []string{"bbb", "aaa", "ccc"}); diff != "" { 50 | t.Errorf("after added same with different port: invalid subdomains %s", diff) 51 | } 52 | 53 | for _, port := range []int{80, 8080} { 54 | h := rp.FindHandler("aaa", port) 55 | if h == nil { 56 | t.Errorf("handler not found for aaa:%d", port) 57 | } 58 | } 59 | 60 | // remove subdomain 61 | rp.RemoveSubdomain("aaa") 62 | if diff := cmp.Diff(rp.Subdomains(), []string{"bbb", "ccc"}); diff != "" { 63 | t.Errorf("after removed: invalid subdomains %s", diff) 64 | } 65 | 66 | // wildcard 67 | rp.AddSubdomain("foo-*", "10.0.0.1", 80) 68 | rp.AddSubdomain("foo-bar-*", "10.0.0.2", 80) 69 | rp.AddSubdomain("*-baz", "10.0.0.3", 80) 70 | for _, name := range []string{"foo-111", "foo-bar-222", "111-baz"} { 71 | if !rp.Exists(name) { 72 | t.Errorf("subdomain %s not found", name) 73 | } 74 | } 75 | 76 | { 77 | h1 := rp.FindHandler("foo-999", 80) 78 | if h1 == nil { 79 | t.Errorf("handler not found for foo-999") 80 | } 81 | h2 := rp.FindHandler("foo-baz", 80) // foo-baz is matched with "foo-*" 82 | if h2 != h1 { 83 | t.Errorf("handler not matched for foo-*") 84 | } 85 | } 86 | { 87 | h1 := rp.FindHandler("foo-bar-999", 80) 88 | if h1 == nil { 89 | t.Errorf("handler not found for foo-bar-999") 90 | } 91 | h2 := rp.FindHandler("foo-bar-baz", 80) // foo-bar-baz is matched with "foo-*" 92 | if h2 != h1 { 93 | t.Errorf("handler not matched for foo-*") 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /route53.go: -------------------------------------------------------------------------------- 1 | package mirageecs 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | "time" 8 | 9 | ttlcache "github.com/ReneKroon/ttlcache/v2" 10 | "github.com/aws/aws-sdk-go-v2/aws" 11 | "github.com/aws/aws-sdk-go-v2/service/route53" 12 | "github.com/aws/aws-sdk-go-v2/service/route53/types" 13 | ) 14 | 15 | type Route53 struct { 16 | svc *route53.Client 17 | changes []*route53Change 18 | hostedZoneID *string 19 | zoneName string 20 | cache *ttlcache.Cache 21 | } 22 | 23 | type route53Change struct { 24 | name string 25 | value string 26 | delete bool 27 | } 28 | 29 | func (c *route53Change) String() string { 30 | return fmt.Sprintf("%s %s %s", c.action(), c.name, c.value) 31 | } 32 | 33 | func (c *route53Change) action() string { 34 | if c.delete { 35 | return "DELTETE" 36 | } else { 37 | return "UPSERT" 38 | } 39 | } 40 | 41 | func NewRoute53(ctx context.Context, cfg *Config) *Route53 { 42 | svc := route53.NewFromConfig(*cfg.awscfg) 43 | r := &Route53{ 44 | svc: svc, 45 | } 46 | if id := cfg.Link.HostedZoneID; id != "" { 47 | out, err := svc.GetHostedZone(ctx, &route53.GetHostedZoneInput{ 48 | Id: aws.String(id), 49 | }) 50 | if err != nil { 51 | slog.Error(f("failed to get hosted zone %s", err)) 52 | return nil 53 | } 54 | r.zoneName = *out.HostedZone.Name 55 | r.hostedZoneID = out.HostedZone.Id 56 | } 57 | r.cache = ttlcache.NewCache() 58 | r.cache.SetTTL(5 * time.Minute) 59 | r.cache.SkipTTLExtensionOnHit(true) 60 | 61 | return r 62 | } 63 | 64 | func (r *Route53) Add(name, addr string) { 65 | if r.hostedZoneID == nil { 66 | return 67 | } 68 | change := &route53Change{ 69 | name: fmt.Sprintf("%s.%s", name, r.zoneName), 70 | value: addr, 71 | } 72 | key := change.String() 73 | if _, err := r.cache.Get(key); err == nil { 74 | slog.Debug(f("%s is cached. skip", key)) 75 | return 76 | } 77 | r.cache.Set(key, nil) 78 | 79 | slog.Debug(f("route53 change: %s", change.String())) 80 | r.changes = append(r.changes, change) 81 | } 82 | 83 | func (r *Route53) Delete(name string, addr string) { 84 | if r.hostedZoneID == nil { 85 | return 86 | } 87 | change := &route53Change{ 88 | name: fmt.Sprintf("%s.%s", name, r.zoneName), 89 | value: addr, 90 | delete: true, 91 | } 92 | key := change.String() 93 | if _, err := r.cache.Get(key); err == nil { 94 | slog.Debug(f("%s is cached. skip", key)) 95 | return 96 | } 97 | r.cache.Set(key, nil) 98 | 99 | slog.Debug(f("route53 change: %s", change.String())) 100 | r.changes = append(r.changes, change) 101 | } 102 | 103 | func (r *Route53) Apply(ctx context.Context) error { 104 | if r.hostedZoneID == nil || len(r.changes) == 0 { 105 | return nil 106 | } 107 | defer func() { 108 | // clear changes queue 109 | r.changes = r.changes[0:0] 110 | }() 111 | 112 | addes := make(map[string][]*route53Change) 113 | deletes := make(map[string][]*route53Change) 114 | for _, c := range r.changes { 115 | if c.delete { 116 | deletes[c.name] = append(deletes[c.name], c) 117 | } else { 118 | addes[c.name] = append(addes[c.name], c) 119 | } 120 | } 121 | 122 | // sum by name 123 | var changes []types.Change 124 | DELETES: 125 | for name, cs := range deletes { 126 | var records []types.ResourceRecord 127 | for _, c := range cs { 128 | if len(addes[c.name]) > 0 { 129 | continue DELETES // skip delete when adds exists 130 | } 131 | records = append(records, types.ResourceRecord{Value: &c.value}) 132 | } 133 | change := types.Change{ 134 | Action: "DELETE", 135 | ResourceRecordSet: &types.ResourceRecordSet{ 136 | Name: aws.String(name), 137 | ResourceRecords: records, 138 | TTL: aws.Int64(60), 139 | Type: types.RRTypeA, 140 | }, 141 | } 142 | changes = append(changes, change) 143 | slog.Info(f("route53 change: %v", change)) 144 | } 145 | for name, cs := range addes { 146 | var records []types.ResourceRecord 147 | for _, c := range cs { 148 | records = append(records, types.ResourceRecord{Value: &c.value}) 149 | } 150 | change := types.Change{ 151 | Action: "UPSERT", 152 | ResourceRecordSet: &types.ResourceRecordSet{ 153 | Name: aws.String(name), 154 | ResourceRecords: records, 155 | TTL: aws.Int64(60), 156 | Type: types.RRTypeA, 157 | }, 158 | } 159 | changes = append(changes, change) 160 | slog.Info(f("route53 change: %v", change)) 161 | } 162 | 163 | _, err := r.svc.ChangeResourceRecordSets(ctx, &route53.ChangeResourceRecordSetsInput{ 164 | ChangeBatch: &types.ChangeBatch{ 165 | Changes: changes, 166 | }, 167 | HostedZoneId: r.hostedZoneID, 168 | }) 169 | if err != nil { 170 | return err 171 | } 172 | slog.Info(f("route53 ChangeResourceRecordSets complete with %d changes", len(changes))) 173 | return nil 174 | } 175 | -------------------------------------------------------------------------------- /terraform/.gitignore: -------------------------------------------------------------------------------- 1 | .envrc 2 | .terraform/ 3 | terraform.tfstate* 4 | terraform.tfvars 5 | -------------------------------------------------------------------------------- /terraform/.terraform-version: -------------------------------------------------------------------------------- 1 | 1.4.6 2 | -------------------------------------------------------------------------------- /terraform/.terraform.lock.hcl: -------------------------------------------------------------------------------- 1 | # This file is maintained automatically by "terraform init". 2 | # Manual edits may be lost in future updates. 3 | 4 | provider "registry.terraform.io/hashicorp/aws" { 5 | version = "4.65.0" 6 | constraints = "4.65.0" 7 | hashes = [ 8 | "h1:fbSgoS5GLuwKAZlovFvGoYl4B0Bi5T7+MmFiVZL0uOo=", 9 | "zh:0461b8dfc14e94971bfd12783cbd5a5574b9fcfc3694b6afaa8836f90b61c1f9", 10 | "zh:24a27e7b1f6eb33e9da6f2ffaaa6bc48e933a24224c6572d6e588994e5c7130b", 11 | "zh:2ca189d04573414bef4876c17ccb2b76f6e721e0450f6ab3700d94d7c04bec64", 12 | "zh:3fb0654a527677231dab2140e9a55df3b90dba478b3db50001e21a045437a47a", 13 | "zh:4918173d9c7d2735908622c17efd01746a046f0a571690afa7dd0866f22045f7", 14 | "zh:491d259b15166f751076d2bdc443928ca63f6c0a83b02ea75fff8b4224662207", 15 | "zh:4ff8e178f0656f04f88558c295a1d246b1bdcf5ad81d8b3b9ccceaeca2eb7fa8", 16 | "zh:5e4eaf2855a740124f4bbe34ac4bd22c7f320aa3e91d9cef64396ad0a1571544", 17 | "zh:65762c60c4bac2e0d55ed8c2877e455e84465cb12f0c885363a1b561cd4f5f07", 18 | "zh:7c5e4f85eb5f70e6da2d64701dd5551f2bc334dbb9add76bfc6a2bea6acf4483", 19 | "zh:90d32b238113528319d7a5fade97bd8ac9a8b654482fc9056478a43d2e297886", 20 | "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", 21 | "zh:e6ed3299516a8fb2292af7e7e123d09817dfd8e039aaf35ad5a276f739668e88", 22 | "zh:eb84fa96c63d836b3b4689835cb7c4487808dfd1ba7ddacf4d8c4c6ff65cdbef", 23 | "zh:ff97d1498193c99c9c35afd9bfcdce011abf460ec041721727d6e542f7a3bedd", 24 | ] 25 | } 26 | 27 | provider "registry.terraform.io/hashicorp/http" { 28 | version = "3.4.0" 29 | hashes = [ 30 | "h1:h3URn6qAnP36OlSqI1tTuKgPL3GriZaJia9ZDrUvRdg=", 31 | "zh:56712497a87bc4e91bbaf1a5a2be4b3f9cfa2384baeb20fc9fad0aff8f063914", 32 | "zh:6661355e1090ebacab16a40ede35b029caffc279d67da73a000b6eecf0b58eba", 33 | "zh:67b92d343e808b92d7e6c3bbcb9b9d5475fecfed0836963f7feb9d9908bd4c4f", 34 | "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", 35 | "zh:86ebb9be9b685c96dbb5c024b55d87526d57a4b127796d6046344f8294d3f28e", 36 | "zh:902be7cfca4308cba3e1e7ba6fc292629dfd150eb9a9f054a854fa1532b0ceba", 37 | "zh:9ba26e0215cd53b21fe26a0a98c007de1348b7d13a75ae3cfaf7729e0f2c50bb", 38 | "zh:a195c941e1f1526147134c257ff549bea4c89c953685acd3d48d9de7a38f39dc", 39 | "zh:a7967b3d2a8c3e7e1dc9ae381ca753268f9fce756466fe2fc9e414ca2d85a92e", 40 | "zh:bde56542e9a093434d96bea21c341285737c6d38fea2f05e12ba7b333f3e9c05", 41 | "zh:c0306f76903024c497fd01f9fd9bace5854c263e87a97bc2e89dcc96d35ca3cc", 42 | "zh:f9335a6c336171e85f8e3e99c3d31758811a19aeb21fa8c9013d427e155ae2a9", 43 | ] 44 | } 45 | -------------------------------------------------------------------------------- /terraform/README.md: -------------------------------------------------------------------------------- 1 | ## An example of mirage-ecs deployment using terraform 2 | 3 | This example shows how to deploy mirage-ecs using terraform. 4 | 5 | ### Prerequisites 6 | 7 | - [Terraform](https://www.terraform.io/) >= v1.0.0 8 | - [ecspresso](https://github.com/kayac/ecspresso) >= v2.0.0 9 | 10 | #### Environment variables 11 | 12 | - `AWS_REGION` for AWS region. (e.g. `ap-northeast-1`) 13 | - `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`, or `AWS_PROFILE` for AWS credentials. 14 | - `AWS_SDK_LOAD_CONFIG=true` may be required if you use `AWS_PROFILE` and `~/.aws/config`. 15 | 16 | ### Usage 17 | 18 | ```console 19 | $ terraform init 20 | $ terraform apply -var domain=dev.your.example.com 21 | $ ecspresso deploy 22 | ``` 23 | 24 | While applying terraform, `dev.your.example.com` will be registered to Route53. 25 | You should delegate `dev.your.example.com` to the name servers from `your.example.com`. 26 | 27 | After deploying, you can access to `https://mirage.dev.your.example.com` and see the mirage-ecs. 28 | 29 | #### Customization 30 | 31 | You can customize the deployment by editing `terraform.tfvars` and `ecspresso.yml`. 32 | 33 | `oauth_client_id` and `oauth_client_secret` are used for authentication by ALB with Google OAuth. 34 | If you want to enable authentication, you should set them. 35 | Set the Google OAuth callback URL to `https://mirage.{var.domain}/oauth2/idpresponse`. 36 | 37 | `ecspresso.yml` is used for ECS deployment. 38 | See [ecspresso](https://github.com/kayac/ecspresso) for details. 39 | 40 | ### Cleanup 41 | 42 | ```console 43 | $ ecspresso delete --terminate 44 | $ terraform destroy -var domain=dev.your.example.com 45 | ``` 46 | -------------------------------------------------------------------------------- /terraform/acm.tf: -------------------------------------------------------------------------------- 1 | resource "aws_acm_certificate" "mirage-ecs" { 2 | domain_name = var.domain 3 | validation_method = "DNS" 4 | subject_alternative_names = [ 5 | "*.${var.domain}" 6 | ] 7 | tags = { 8 | Name = var.project 9 | } 10 | } 11 | 12 | resource "aws_acm_certificate_validation" "mirage-ecs" { 13 | certificate_arn = aws_acm_certificate.mirage-ecs.arn 14 | validation_record_fqdns = [for v in aws_route53_record.validation : v.fqdn] 15 | } 16 | -------------------------------------------------------------------------------- /terraform/alb.tf: -------------------------------------------------------------------------------- 1 | resource "aws_lb" "mirage-ecs" { 2 | name = var.project 3 | internal = false 4 | load_balancer_type = "application" 5 | security_groups = [ 6 | aws_security_group.alb.id, 7 | aws_security_group.default.id, 8 | ] 9 | subnets = [ 10 | aws_subnet.public-a.id, 11 | aws_subnet.public-c.id, 12 | aws_subnet.public-d.id, 13 | ] 14 | tags = { 15 | Name = var.project 16 | } 17 | } 18 | 19 | resource "aws_lb_target_group" "mirage-ecs-http" { 20 | name = "${var.project}-http" 21 | port = 80 22 | target_type = "ip" 23 | vpc_id = aws_vpc.main.id 24 | protocol = "HTTP" 25 | deregistration_delay = 10 26 | 27 | health_check { 28 | path = "/" 29 | port = "traffic-port" 30 | protocol = "HTTP" 31 | healthy_threshold = 2 32 | unhealthy_threshold = 10 33 | timeout = 5 34 | interval = 6 35 | } 36 | tags = { 37 | Name = "${var.project}-http" 38 | } 39 | } 40 | 41 | resource "aws_lb_listener" "mirage-ecs-http" { 42 | load_balancer_arn = aws_lb.mirage-ecs.arn 43 | port = 80 44 | protocol = "HTTP" 45 | 46 | default_action { 47 | type = "redirect" 48 | redirect { 49 | port = "443" 50 | protocol = "HTTPS" 51 | status_code = "HTTP_301" 52 | } 53 | } 54 | tags = { 55 | Name = "${var.project}-https" 56 | } 57 | } 58 | 59 | resource "aws_lb_listener" "mirage-ecs-https" { 60 | load_balancer_arn = aws_lb.mirage-ecs.arn 61 | port = 443 62 | protocol = "HTTPS" 63 | ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06" 64 | certificate_arn = aws_acm_certificate.mirage-ecs.arn 65 | 66 | default_action { 67 | type = "fixed-response" 68 | fixed_response { 69 | status_code = "404" 70 | content_type = "text/plain" 71 | message_body = "404 Not Found" 72 | } 73 | } 74 | tags = { 75 | Name = "${var.project}-https" 76 | } 77 | } 78 | 79 | resource "aws_lb_listener_rule" "mirage-ecs-mirage-web" { 80 | listener_arn = aws_lb_listener.mirage-ecs-https.arn 81 | priority = 1 82 | 83 | // If you want to use OIDC authentication, you need to set the following tf variables. 84 | // oauth_client_id, oauth_client_secret 85 | // You must set the OAuth callback URL to https://mirage.${var.domain}/oauth2/idpresponse 86 | // See also https://docs.aws.amazon.com/ja_jp/elasticloadbalancing/latest/application/listener-authenticate-users.html 87 | dynamic "action" { 88 | for_each = var.oauth_client_id != "" ? [1] : [] 89 | content { 90 | type = "authenticate-oidc" 91 | authenticate_oidc { 92 | authorization_endpoint = jsondecode(data.http.oidc_configuration.response_body)["authorization_endpoint"] 93 | issuer = jsondecode(data.http.oidc_configuration.response_body)["issuer"] 94 | token_endpoint = jsondecode(data.http.oidc_configuration.response_body)["token_endpoint"] 95 | user_info_endpoint = jsondecode(data.http.oidc_configuration.response_body)["userinfo_endpoint"] 96 | scope = "email" 97 | client_id = var.oauth_client_id 98 | client_secret = var.oauth_client_secret 99 | } 100 | } 101 | } 102 | action { 103 | type = "forward" 104 | target_group_arn = aws_lb_target_group.mirage-ecs-http.arn 105 | } 106 | 107 | condition { 108 | host_header { 109 | values = [ 110 | "mirage.${var.domain}", 111 | ] 112 | } 113 | } 114 | } 115 | 116 | resource "aws_lb_listener_rule" "main-mirage-ecs-launched" { 117 | listener_arn = aws_lb_listener.mirage-ecs-https.arn 118 | priority = 2 119 | action { 120 | type = "forward" 121 | target_group_arn = aws_lb_target_group.mirage-ecs-http.arn 122 | } 123 | 124 | condition { 125 | host_header { 126 | values = [ 127 | "*.${var.domain}", 128 | ] 129 | } 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /terraform/config.tf: -------------------------------------------------------------------------------- 1 | variable "project" { 2 | type = string 3 | default = "mirage-ecs" 4 | } 5 | 6 | provider "aws" { 7 | region = "ap-northeast-1" 8 | default_tags { 9 | tags = { 10 | "env" = "${var.project}" 11 | } 12 | } 13 | } 14 | 15 | terraform { 16 | required_version = "= 1.4.6" 17 | 18 | required_providers { 19 | aws = { 20 | source = "hashicorp/aws" 21 | version = "= 4.65.0" 22 | } 23 | } 24 | } 25 | 26 | data "aws_caller_identity" "current" { 27 | } 28 | 29 | variable "domain" { 30 | type = string 31 | } 32 | 33 | variable "oauth_client_id" { 34 | type = string 35 | default = "" 36 | } 37 | 38 | variable "oauth_client_secret" { 39 | type = string 40 | default = "" 41 | } 42 | 43 | provider "http" {} 44 | 45 | data "http" "oidc_configuration" { 46 | url = "https://accounts.google.com/.well-known/openid-configuration" 47 | } 48 | -------------------------------------------------------------------------------- /terraform/config.yaml: -------------------------------------------------------------------------------- 1 | parameters: 2 | - name: additional 3 | env: ADDITIONAL 4 | default: "foo" 5 | description: "Additional parameter" 6 | options: 7 | - label: "Foo" 8 | value: "foo" 9 | - label: "Bar" 10 | value: "bar" 11 | htmldir: "{{ must_env `HTMLDIR` }}" 12 | #auth: 13 | # amzn_oidc: 14 | # claim: email 15 | # matchers: 16 | # - suffix: "@gmail.com" 17 | -------------------------------------------------------------------------------- /terraform/ecs-service-def.jsonnet: -------------------------------------------------------------------------------- 1 | { 2 | deploymentConfiguration: { 3 | deploymentCircuitBreaker: { 4 | enable: false, 5 | rollback: false, 6 | }, 7 | maximumPercent: 200, 8 | minimumHealthyPercent: 100, 9 | }, 10 | deploymentController: { 11 | type: 'ECS', 12 | }, 13 | desiredCount: 1, 14 | enableECSManagedTags: false, 15 | enableExecuteCommand: true, 16 | healthCheckGracePeriodSeconds: 0, 17 | launchType: 'FARGATE', 18 | loadBalancers: [ 19 | { 20 | containerName: 'mirage-ecs', 21 | containerPort: 80, 22 | targetGroupArn: '{{ tfstate `aws_lb_target_group.mirage-ecs-http.arn` }}', 23 | }, 24 | ], 25 | networkConfiguration: { 26 | awsvpcConfiguration: { 27 | assignPublicIp: 'ENABLED', 28 | securityGroups: [ 29 | '{{ tfstate `aws_security_group.default.id` }}', 30 | ], 31 | subnets: [ 32 | '{{ tfstate `aws_subnet.public-a.id` }}', 33 | '{{ tfstate `aws_subnet.public-c.id` }}', 34 | '{{ tfstate `aws_subnet.public-d.id` }}', 35 | ], 36 | }, 37 | }, 38 | platformFamily: 'Linux', 39 | platformVersion: 'LATEST', 40 | propagateTags: 'SERVICE', 41 | runningCount: 0, 42 | schedulingStrategy: 'REPLICA', 43 | tags: [ 44 | { 45 | key: 'env', 46 | value: 'mirage-ecs', 47 | }, 48 | ], 49 | } 50 | -------------------------------------------------------------------------------- /terraform/ecs-task-def.jsonnet: -------------------------------------------------------------------------------- 1 | { 2 | cpu: '256', 3 | memory: '512', 4 | containerDefinitions: [ 5 | { 6 | name: 'mirage-ecs', 7 | image: 'ghcr.io/acidlemon/mirage-ecs:{{ env `VERSION` `v2.0.0` }}', 8 | portMappings: [ 9 | { 10 | containerPort: 80, 11 | hostPort: 80, 12 | protocol: 'tcp', 13 | }, 14 | ], 15 | essential: true, 16 | environment: [ 17 | { 18 | name: 'MIRAGE_DOMAIN', 19 | value: '{{ tfstate `aws_route53_zone.mirage-ecs.name` }}', 20 | }, 21 | { 22 | name: 'MIRAGE_LOG_LEVEL', 23 | value: '{{ env `LOG_LEVEL` `info` }}', 24 | }, 25 | { 26 | name: 'MIRAGE_CONF', 27 | value: 's3://{{ tfstate `aws_s3_bucket.mirage-ecs.bucket` }}/config.yaml' 28 | }, 29 | { 30 | name: 'HTMLDIR', 31 | value: 's3://{{ tfstate `aws_s3_bucket.mirage-ecs.bucket` }}/html' 32 | }, 33 | ], 34 | logConfiguration: { 35 | logDriver: 'awslogs', 36 | options: { 37 | 'awslogs-group': '{{ tfstate `aws_cloudwatch_log_group.mirage-ecs.name` }}', 38 | 'awslogs-region': '{{ must_env `AWS_REGION` }}', 39 | 'awslogs-stream-prefix': 'mirage-ecs', 40 | }, 41 | }, 42 | }, 43 | ], 44 | family: 'mirage-ecs', 45 | taskRoleArn: '{{ tfstate `aws_iam_role.task.arn` }}', 46 | executionRoleArn: '{{ tfstate `data.aws_iam_role.ecs-task-execiton.arn` }}', 47 | networkMode: 'awsvpc', 48 | requiresCompatibilities: [ 49 | "EC2", 50 | "FARGATE", 51 | ], 52 | } 53 | -------------------------------------------------------------------------------- /terraform/ecs.tf: -------------------------------------------------------------------------------- 1 | resource "aws_ecs_cluster" "mirage-ecs" { 2 | name = var.project 3 | tags = { 4 | Name = var.project 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /terraform/ecspresso.jsonnet: -------------------------------------------------------------------------------- 1 | { 2 | region: 'ap-northeast-1', 3 | cluster: 'mirage-ecs', 4 | service: 'mirage-ecs', 5 | service_definition: 'ecs-service-def.jsonnet', 6 | task_definition: 'ecs-task-def.jsonnet', 7 | timeout: '10m0s', 8 | plugins: [ 9 | { 10 | name: "tfstate", 11 | config: { 12 | url: "terraform.tfstate" 13 | }, 14 | }, 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /terraform/iam.tf: -------------------------------------------------------------------------------- 1 | resource "aws_iam_role" "task" { 2 | name = "${var.project}-ecs-task" 3 | assume_role_policy = jsonencode({ 4 | Version = "2012-10-17" 5 | Statement = [ 6 | { 7 | Action = "sts:AssumeRole" 8 | Principal = { 9 | Service = "ecs-tasks.amazonaws.com" 10 | } 11 | Effect = "Allow" 12 | Sid = "" 13 | } 14 | ] 15 | }) 16 | } 17 | 18 | resource "aws_iam_policy" "mirage-ecs" { 19 | name = var.project 20 | policy = jsonencode({ 21 | Version = "2012-10-17" 22 | Statement = [ 23 | { 24 | Action = [ 25 | "iam:PassRole", 26 | "ecs:RunTask", 27 | "ecs:DescribeTasks", 28 | "ecs:DescribeTaskDefinition", 29 | "ecs:DescribeServices", 30 | "ecs:StopTask", 31 | "ecs:ListTasks", 32 | "ecs:TagResource", 33 | "cloudwatch:PutMetricData", 34 | "cloudwatch:GetMetricData", 35 | "logs:GetLogEvents", 36 | "route53:GetHostedZone", 37 | "route53:ChangeResourceRecordSets", 38 | ] 39 | Effect = "Allow" 40 | Resource = "*" 41 | }, 42 | { 43 | Action = [ 44 | "s3:GetObject", 45 | ], 46 | Effect = "Allow", 47 | Resource = [ 48 | "${aws_s3_bucket.mirage-ecs.arn}/*", 49 | ] 50 | }, 51 | { 52 | Action = [ 53 | "s3:ListBucket", 54 | ], 55 | Effect = "Allow", 56 | Resource = [ 57 | "${aws_s3_bucket.mirage-ecs.arn}", 58 | ] 59 | }, 60 | ] 61 | }) 62 | } 63 | 64 | resource "aws_iam_role_policy_attachment" "mirage-ecs" { 65 | role = aws_iam_role.task.name 66 | policy_arn = aws_iam_policy.mirage-ecs.arn 67 | } 68 | 69 | data "aws_iam_role" "ecs-task-execiton" { 70 | name = "ecsTaskExecutionRole" 71 | } 72 | 73 | resource "aws_iam_policy" "mirage-ecs-exec" { 74 | name = "${var.project}-ecs-exec" 75 | policy = jsonencode({ 76 | Version = "2012-10-17" 77 | Statement = [ 78 | { 79 | Effect = "Allow" 80 | Action = [ 81 | "ssmmessages:CreateControlChannel", 82 | "ssmmessages:CreateDataChannel", 83 | "ssmmessages:OpenControlChannel", 84 | "ssmmessages:OpenDataChannel", 85 | ] 86 | Resource = "*" 87 | } 88 | ] 89 | }) 90 | } 91 | 92 | resource "aws_iam_role_policy_attachment" "mirage-ecs-exec" { 93 | role = aws_iam_role.task.name 94 | policy_arn = aws_iam_policy.mirage-ecs-exec.arn 95 | } 96 | -------------------------------------------------------------------------------- /terraform/logs.tf: -------------------------------------------------------------------------------- 1 | resource "aws_cloudwatch_log_group" "mirage-ecs" { 2 | name = "/aws/ecs/${var.project}" 3 | } 4 | -------------------------------------------------------------------------------- /terraform/route53.tf: -------------------------------------------------------------------------------- 1 | resource "aws_route53_zone" "mirage-ecs" { 2 | name = var.domain 3 | } 4 | 5 | resource "aws_route53_record" "mirage-ecs" { 6 | zone_id = aws_route53_zone.mirage-ecs.zone_id 7 | name = "mirage.${var.domain}" 8 | type = "A" 9 | alias { 10 | name = aws_lb.mirage-ecs.dns_name 11 | zone_id = aws_lb.mirage-ecs.zone_id 12 | evaluate_target_health = true 13 | } 14 | } 15 | 16 | resource "aws_route53_record" "mirage-tasks" { 17 | zone_id = aws_route53_zone.mirage-ecs.zone_id 18 | name = "*.${var.domain}" 19 | type = "A" 20 | alias { 21 | name = aws_lb.mirage-ecs.dns_name 22 | zone_id = aws_lb.mirage-ecs.zone_id 23 | evaluate_target_health = true 24 | } 25 | } 26 | 27 | resource "aws_route53_record" "validation" { 28 | zone_id = aws_route53_zone.mirage-ecs.zone_id 29 | for_each = { 30 | for dvo in aws_acm_certificate.mirage-ecs.domain_validation_options : dvo.domain_name => { 31 | name = dvo.resource_record_name 32 | record = dvo.resource_record_value 33 | type = dvo.resource_record_type 34 | } 35 | } 36 | name = each.value.name 37 | records = [each.value.record] 38 | type = each.value.type 39 | allow_overwrite = true 40 | ttl = 60 41 | } 42 | -------------------------------------------------------------------------------- /terraform/s3.tf: -------------------------------------------------------------------------------- 1 | resource "aws_s3_bucket" "mirage-ecs" { 2 | bucket = format("mirage-%s", replace(var.domain, ".", "-")) 3 | } 4 | 5 | resource "aws_s3_object" "config" { 6 | bucket = aws_s3_bucket.mirage-ecs.id 7 | key = "config.yaml" 8 | source = "config.yaml" 9 | } 10 | 11 | resource "aws_s3_object" "html" { 12 | for_each = toset(["launcher.html", "layout.html", "list.html"]) 13 | bucket = aws_s3_bucket.mirage-ecs.id 14 | key = "html/${each.value}" 15 | source = format("../html/%s", each.value) 16 | } 17 | -------------------------------------------------------------------------------- /terraform/sg.tf: -------------------------------------------------------------------------------- 1 | resource "aws_security_group" "default" { 2 | name = "${var.project}-default" 3 | vpc_id = aws_vpc.main.id 4 | ingress { 5 | self = true 6 | from_port = 0 7 | to_port = 0 8 | protocol = "-1" 9 | } 10 | egress { 11 | from_port = 0 12 | to_port = 0 13 | protocol = "-1" 14 | cidr_blocks = ["0.0.0.0/0"] 15 | } 16 | } 17 | 18 | resource "aws_security_group" "alb" { 19 | name = "${var.project}-alb" 20 | vpc_id = aws_vpc.main.id 21 | ingress { 22 | from_port = 80 23 | to_port = 80 24 | protocol = "tcp" 25 | cidr_blocks = ["0.0.0.0/0"] 26 | } 27 | ingress { 28 | from_port = 443 29 | to_port = 443 30 | protocol = "tcp" 31 | cidr_blocks = ["0.0.0.0/0"] 32 | } 33 | egress { 34 | from_port = 0 35 | to_port = 0 36 | protocol = "-1" 37 | cidr_blocks = ["0.0.0.0/0"] 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /terraform/vpc.tf: -------------------------------------------------------------------------------- 1 | resource "aws_vpc" "main" { 2 | cidr_block = "10.0.0.0/16" 3 | tags = { 4 | Name = var.project 5 | } 6 | } 7 | 8 | resource "aws_subnet" "public-a" { 9 | vpc_id = aws_vpc.main.id 10 | cidr_block = "10.0.1.0/24" 11 | availability_zone = "ap-northeast-1a" 12 | tags = { 13 | Name = "${var.project}-public-a" 14 | } 15 | } 16 | 17 | resource "aws_subnet" "public-c" { 18 | vpc_id = aws_vpc.main.id 19 | cidr_block = "10.0.2.0/24" 20 | availability_zone = "ap-northeast-1c" 21 | tags = { 22 | Name = "${var.project}-public-c" 23 | } 24 | } 25 | 26 | resource "aws_subnet" "public-d" { 27 | vpc_id = aws_vpc.main.id 28 | cidr_block = "10.0.3.0/24" 29 | availability_zone = "ap-northeast-1d" 30 | tags = { 31 | Name = "${var.project}-public-d" 32 | } 33 | } 34 | 35 | resource "aws_internet_gateway" "main" { 36 | vpc_id = aws_vpc.main.id 37 | tags = { 38 | Name = var.project 39 | } 40 | } 41 | 42 | resource "aws_route_table" "public" { 43 | vpc_id = aws_vpc.main.id 44 | route { 45 | cidr_block = "0.0.0.0/0" 46 | gateway_id = aws_internet_gateway.main.id 47 | } 48 | tags = { 49 | Name = "${var.project}-public" 50 | } 51 | } 52 | 53 | resource "aws_route_table_association" "public-a" { 54 | subnet_id = aws_subnet.public-a.id 55 | route_table_id = aws_route_table.public.id 56 | } 57 | 58 | resource "aws_route_table_association" "public-c" { 59 | subnet_id = aws_subnet.public-c.id 60 | route_table_id = aws_route_table.public.id 61 | } 62 | 63 | resource "aws_route_table_association" "public-d" { 64 | subnet_id = aws_subnet.public-d.id 65 | route_table_id = aws_route_table.public.id 66 | } 67 | -------------------------------------------------------------------------------- /transport_test.go: -------------------------------------------------------------------------------- 1 | package mirageecs_test 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | "net/http/httptest" 8 | "strings" 9 | "testing" 10 | "time" 11 | 12 | mirageecs "github.com/acidlemon/mirage-ecs/v2" 13 | ) 14 | 15 | func TestRoundTrip(t *testing.T) { 16 | tests := []struct { 17 | name string 18 | serverDelay time.Duration 19 | timeout time.Duration 20 | wantStatus int 21 | wantBody string 22 | bodyContains string 23 | requireAuthCookie bool 24 | sendCookie bool 25 | }{ 26 | { 27 | name: "Success pattern", 28 | serverDelay: 50 * time.Millisecond, 29 | timeout: 100 * time.Millisecond, 30 | wantStatus: http.StatusOK, 31 | wantBody: "OK", 32 | }, 33 | { 34 | name: "Timeout failure pattern", 35 | serverDelay: 150 * time.Millisecond, 36 | timeout: 100 * time.Millisecond, 37 | wantStatus: http.StatusGatewayTimeout, 38 | bodyContains: "test-subdomain upstream timeout: ", 39 | }, 40 | { 41 | name: "Success pattern with auth cookie", 42 | timeout: 100 * time.Millisecond, 43 | wantStatus: http.StatusOK, 44 | wantBody: "OK", 45 | requireAuthCookie: true, 46 | sendCookie: true, 47 | }, 48 | { 49 | name: "Forbidden pattern with auth cookie", 50 | timeout: 100 * time.Millisecond, 51 | wantStatus: http.StatusForbidden, 52 | wantBody: "Forbidden", 53 | requireAuthCookie: true, 54 | sendCookie: false, 55 | }, 56 | { 57 | name: "Large content", 58 | timeout: 100 * time.Second, 59 | wantStatus: http.StatusOK, 60 | wantBody: strings.Repeat("a", 1024*100), 61 | }, 62 | } 63 | 64 | for _, tt := range tests { 65 | t.Run(tt.name, func(t *testing.T) { 66 | // Setup mock server 67 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 68 | time.Sleep(tt.serverDelay) 69 | buf := strings.NewReader(tt.wantBody) 70 | io.Copy(w, buf) 71 | })) 72 | defer server.Close() 73 | 74 | // Setup transport 75 | tr := &mirageecs.Transport{ 76 | Counter: mirageecs.NewAccessCounter(time.Second), 77 | Transport: mirageecs.NewHTTPTransport(tt.timeout), 78 | Subdomain: "test-subdomain", 79 | } 80 | if tt.requireAuthCookie { 81 | tr.AuthCookieValidateFunc = func(c *http.Cookie) error { 82 | if c.Value == "ok" { 83 | return nil 84 | } 85 | return fmt.Errorf("invalid cookie value: %s", c.Value) 86 | } 87 | } 88 | 89 | req, _ := http.NewRequest("GET", server.URL, nil) 90 | if tt.sendCookie { 91 | req.AddCookie(&http.Cookie{ 92 | Name: "mirage-ecs-auth", 93 | Value: "ok", 94 | }) 95 | } 96 | 97 | resp, err := tr.RoundTrip(req) 98 | if err != nil { 99 | t.Fatalf("unexpected error: %v", err) 100 | } 101 | defer resp.Body.Close() 102 | 103 | buf := new(strings.Builder) 104 | if n, err := io.Copy(buf, resp.Body); err != nil { 105 | t.Fatalf("unexpected error: %v", err) 106 | } else { 107 | t.Logf("read body %d bytes", n) 108 | } 109 | body := buf.String() 110 | 111 | if resp.StatusCode != tt.wantStatus { 112 | t.Errorf("wanted status %v, got %v", tt.wantStatus, resp.StatusCode) 113 | } 114 | 115 | if tt.bodyContains != "" { 116 | if !strings.Contains(string(body), tt.bodyContains) { 117 | t.Errorf("wanted body to contain %v, got %v", tt.bodyContains, string(body)) 118 | } 119 | } else { 120 | if len(tt.wantBody) != len(string(body)) { 121 | t.Errorf("wanted body length %v, got %v", len(tt.wantBody), len(string(body))) 122 | } 123 | } 124 | }) 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /types.go: -------------------------------------------------------------------------------- 1 | package mirageecs 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/url" 7 | "regexp" 8 | "strings" 9 | "time" 10 | ) 11 | 12 | // APIListResponse is a response of /api/list 13 | type APIListResponse struct { 14 | Result []*APITaskInfo `json:"result"` 15 | } 16 | 17 | type APITaskInfo = Information 18 | 19 | // APILaunchResponse is a response of /api/launch, and /api/terminate 20 | type APICommonResponse struct { 21 | Result string `json:"result"` 22 | } 23 | 24 | type APILogsResponse struct { 25 | Result []string `json:"result"` 26 | } 27 | 28 | // APIAccessResponse is a response of /api/access 29 | type APIAccessResponse struct { 30 | Result string `json:"result"` 31 | Duration int64 `json:"duration"` 32 | Sum int64 `json:"sum"` 33 | } 34 | 35 | type APILaunchRequest struct { 36 | Subdomain string `json:"subdomain" form:"subdomain"` 37 | Branch string `json:"branch" form:"branch"` 38 | Taskdef []string `json:"taskdef" form:"taskdef"` 39 | Parameters map[string]string `json:"parameters" form:"parameters"` 40 | } 41 | 42 | func (r *APILaunchRequest) GetParameter(key string) string { 43 | if key == "branch" { 44 | return r.Branch 45 | } 46 | return r.Parameters[key] 47 | } 48 | 49 | func (r *APILaunchRequest) MergeForm(form url.Values) { 50 | if r.Parameters == nil { 51 | r.Parameters = make(map[string]string, len(form)) 52 | } 53 | for key, values := range form { 54 | if key == "branch" || key == "subdomain" || key == "taskdef" { 55 | continue 56 | } 57 | r.Parameters[key] = values[0] 58 | } 59 | } 60 | 61 | type APIPurgeRequest struct { 62 | Duration json.Number `json:"duration" form:"duration" yaml:"duration"` 63 | Excludes []string `json:"excludes" form:"excludes" yaml:"excludes"` 64 | ExcludeTags []string `json:"exclude_tags" form:"exclude_tags" yaml:"exclude_tags"` 65 | ExcludeRegexp string `json:"exclude_regexp" form:"exclude_regexp" yaml:"exclude_regexp"` 66 | } 67 | 68 | type PurgeParams struct { 69 | Duration time.Duration 70 | Excludes []string 71 | ExcludeTags []string 72 | ExcludeRegexp *regexp.Regexp 73 | 74 | excludesMap map[string]struct{} 75 | excludeTagsMap map[string]string 76 | } 77 | 78 | func (r *APIPurgeRequest) Validate() (*PurgeParams, error) { 79 | excludes := r.Excludes 80 | excludeTags := r.ExcludeTags 81 | di, err := r.Duration.Int64() 82 | if err != nil { 83 | return nil, fmt.Errorf("invalid duration %s", r.Duration) 84 | } 85 | minimum := int64(PurgeMinimumDuration.Seconds()) 86 | if di < minimum { 87 | return nil, fmt.Errorf("invalid duration %d (at least %d)", di, minimum) 88 | } 89 | 90 | excludesMap := make(map[string]struct{}, len(excludes)) 91 | for _, exclude := range excludes { 92 | excludesMap[exclude] = struct{}{} 93 | } 94 | excludeTagsMap := make(map[string]string, len(excludeTags)) 95 | for _, excludeTag := range excludeTags { 96 | p := strings.SplitN(excludeTag, ":", 2) 97 | if len(p) != 2 { 98 | return nil, fmt.Errorf("invalid exclude_tags format %s", excludeTag) 99 | } 100 | k, v := p[0], p[1] 101 | excludeTagsMap[k] = v 102 | } 103 | var excludeRegexp *regexp.Regexp 104 | if r.ExcludeRegexp != "" { 105 | var err error 106 | excludeRegexp, err = regexp.Compile(r.ExcludeRegexp) 107 | if err != nil { 108 | return nil, fmt.Errorf("invalid exclude_regexp %s", r.ExcludeRegexp) 109 | } 110 | } 111 | duration := time.Duration(di) * time.Second 112 | 113 | return &PurgeParams{ 114 | Duration: duration, 115 | Excludes: excludes, 116 | ExcludeTags: excludeTags, 117 | ExcludeRegexp: excludeRegexp, 118 | 119 | excludesMap: excludesMap, 120 | excludeTagsMap: excludeTagsMap, 121 | }, nil 122 | } 123 | 124 | type APITerminateRequest struct { 125 | ID string `json:"id" form:"id"` 126 | Subdomain string `json:"subdomain" form:"subdomain"` 127 | } 128 | -------------------------------------------------------------------------------- /webapi.go: -------------------------------------------------------------------------------- 1 | package mirageecs 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "html/template" 7 | "io" 8 | "log/slog" 9 | "net/http" 10 | "path" 11 | "regexp" 12 | "sort" 13 | "strconv" 14 | "strings" 15 | "sync" 16 | "time" 17 | "unicode/utf8" 18 | 19 | "github.com/labstack/echo/v4" 20 | "github.com/labstack/echo/v4/middleware" 21 | "github.com/samber/lo" 22 | ) 23 | 24 | var DNSNameRegexpWithPattern = regexp.MustCompile(`^[a-zA-Z*?\[\]][a-zA-Z0-9-*?\[\]]{0,61}[a-zA-Z0-9*?\[\]]$`) 25 | 26 | const PurgeMinimumDuration = 5 * time.Minute 27 | 28 | const APICallTimeout = 30 * time.Second 29 | 30 | type WebApi struct { 31 | *echo.Echo 32 | 33 | cfg *Config 34 | runner TaskRunner 35 | mu *sync.Mutex 36 | } 37 | 38 | type Template struct { 39 | templates *template.Template 40 | } 41 | 42 | func (t *Template) Render(w io.Writer, name string, data interface{}, c echo.Context) error { 43 | if m, ok := data.(map[string]interface{}); ok { 44 | m["Version"] = Version 45 | return t.templates.ExecuteTemplate(w, name, m) 46 | } else { 47 | return t.templates.ExecuteTemplate(w, name, data) 48 | } 49 | } 50 | 51 | func NewWebApi(cfg *Config, runner TaskRunner) *WebApi { 52 | app := &WebApi{ 53 | mu: &sync.Mutex{}, 54 | runner: runner, 55 | } 56 | app.cfg = cfg 57 | 58 | e := echo.New() 59 | e.Use(middleware.Logger()) 60 | 61 | web := e.Group("") 62 | web.Use(cfg.AuthMiddlewareForWeb) 63 | web.GET("/", app.Top) 64 | web.GET("/list", app.List) 65 | web.GET("/launcher", app.Launcher) 66 | web.GET("/trace/:taskid", app.Trace) 67 | web.POST("/launch", app.Launch) 68 | web.POST("/terminate", app.Terminate) 69 | 70 | api := e.Group("/api") 71 | api.Use(cfg.CompatMiddlewareForAPI) 72 | api.Use(cfg.AuthMiddlewareForAPI) 73 | api.GET("/list", app.ApiList) 74 | api.GET("/access", app.ApiAccess) 75 | api.GET("/logs", app.ApiLogs) 76 | api.POST("/launch", app.ApiLaunch) 77 | api.POST("/terminate", app.ApiTerminate) 78 | api.POST("/purge", app.ApiPurge) 79 | 80 | e.Renderer = &Template{ 81 | templates: template.Must(template.ParseGlob(cfg.HtmlDir + "/*")), 82 | } 83 | app.Echo = e 84 | 85 | return app 86 | } 87 | 88 | func (api *WebApi) Top(c echo.Context) error { 89 | return c.Render(http.StatusOK, "layout.html", map[string]interface{}{}) 90 | } 91 | 92 | func (api *WebApi) List(c echo.Context) error { 93 | ctx := c.Request().Context() 94 | infoRunning, err := api.runner.List(ctx, statusRunning) 95 | if err != nil { 96 | return c.String(http.StatusInternalServerError, err.Error()) 97 | } 98 | infoStopped, err := api.runner.List(ctx, statusStopped) 99 | if err != nil { 100 | return c.String(http.StatusInternalServerError, err.Error()) 101 | } 102 | sort.Slice(infoStopped, func(i, j int) bool { 103 | return infoStopped[i].Created.Before(infoStopped[j].Created) 104 | }) 105 | // stopped subdomains shows only one 106 | stoppedSubdomains := make(map[string]struct{}, len(infoStopped)) 107 | infoStopped = lo.Filter(infoStopped, func(info *Information, _ int) bool { 108 | if _, ok := stoppedSubdomains[info.SubDomain]; ok { 109 | // already seen 110 | return false 111 | } 112 | stoppedSubdomains[info.SubDomain] = struct{}{} 113 | return true 114 | }) 115 | info := append(infoRunning, infoStopped...) 116 | value := map[string]interface{}{ 117 | "info": info, 118 | "error": err, 119 | } 120 | return c.Render(http.StatusOK, "list.html", value) 121 | } 122 | 123 | func (api *WebApi) Launcher(c echo.Context) error { 124 | var taskdefs []string 125 | if api.cfg.Link.DefaultTaskDefinitions != nil { 126 | taskdefs = api.cfg.Link.DefaultTaskDefinitions 127 | } else { 128 | taskdefs = []string{api.cfg.ECS.DefaultTaskDefinition} 129 | } 130 | return c.Render(http.StatusOK, "launcher.html", map[string]interface{}{ 131 | "DefaultTaskDefinitions": taskdefs, 132 | "Parameters": api.cfg.Parameter, 133 | }) 134 | } 135 | 136 | func (api *WebApi) Launch(c echo.Context) error { 137 | code, err := api.launch(c) 138 | if err != nil { 139 | return c.String(code, err.Error()) 140 | } 141 | if c.Request().Header.Get("Hx-Request") == "true" { 142 | return c.String(code, "ok") 143 | } 144 | return c.Redirect(http.StatusSeeOther, "/") 145 | } 146 | 147 | func (api *WebApi) Terminate(c echo.Context) error { 148 | code, err := api.terminate(c) 149 | if err != nil { 150 | c.String(code, err.Error()) 151 | } 152 | return c.Redirect(http.StatusSeeOther, "/") 153 | } 154 | 155 | func (api *WebApi) Trace(c echo.Context) error { 156 | taskID := c.Param("taskid") 157 | if taskID == "" { 158 | return c.String(http.StatusBadRequest, "taskid required") 159 | } 160 | trace, err := api.runner.Trace(c.Request().Context(), taskID) 161 | if err != nil { 162 | return c.String(http.StatusInternalServerError, err.Error()) 163 | } 164 | return c.String(http.StatusOK, trace) 165 | } 166 | 167 | func (api *WebApi) ApiList(c echo.Context) error { 168 | info, err := api.runner.List(c.Request().Context(), statusRunning) 169 | if err != nil { 170 | return c.JSON(500, APIListResponse{}) 171 | } 172 | return c.JSON(200, APIListResponse{Result: info}) 173 | } 174 | 175 | func (api *WebApi) ApiLaunch(c echo.Context) error { 176 | code, err := api.launch(c) 177 | if err != nil { 178 | return c.JSON(code, APICommonResponse{Result: err.Error()}) 179 | } 180 | return c.JSON(code, APICommonResponse{Result: "ok"}) 181 | } 182 | 183 | func (api *WebApi) launch(c echo.Context) (int, error) { 184 | r := APILaunchRequest{} 185 | ps, _ := c.FormParams() 186 | r.MergeForm(ps) 187 | if err := c.Bind(&r); err != nil { 188 | return http.StatusBadRequest, err 189 | } 190 | 191 | subdomain := r.Subdomain 192 | subdomain = strings.ToLower(subdomain) 193 | if err := validateSubdomain(subdomain); err != nil { 194 | slog.Error(f("launch failed: %s", err)) 195 | return http.StatusBadRequest, err 196 | } 197 | taskdefs := r.Taskdef 198 | parameter, err := api.LoadParameter(r.GetParameter) 199 | if err != nil { 200 | slog.Error(f("failed to load parameter: %s", err)) 201 | return http.StatusBadRequest, err 202 | } 203 | 204 | if subdomain == "" || len(taskdefs) == 0 { 205 | return http.StatusBadRequest, fmt.Errorf("parameter required: subdomain=%s, taskdef=%v", subdomain, taskdefs) 206 | } else { 207 | ctx, cancel := context.WithTimeout(c.Request().Context(), APICallTimeout) 208 | defer cancel() 209 | err := api.runner.Launch(ctx, subdomain, parameter, taskdefs...) 210 | if err != nil { 211 | slog.Error(f("launch failed: %s", err)) 212 | return http.StatusInternalServerError, err 213 | } 214 | } 215 | return http.StatusOK, nil 216 | } 217 | 218 | func (api *WebApi) ApiLogs(c echo.Context) error { 219 | code, logs, err := api.logs(c) 220 | if err != nil { 221 | return c.JSON(code, APICommonResponse{Result: err.Error()}) 222 | } 223 | return c.JSON(code, APILogsResponse{Result: logs}) 224 | } 225 | 226 | func (api *WebApi) ApiTerminate(c echo.Context) error { 227 | code, err := api.terminate(c) 228 | if err != nil { 229 | return c.JSON(code, APICommonResponse{Result: err.Error()}) 230 | } 231 | return c.JSON(code, APICommonResponse{Result: "ok"}) 232 | } 233 | 234 | func (api *WebApi) ApiAccess(c echo.Context) error { 235 | code, sum, duration, err := api.accessCounter(c) 236 | if err != nil { 237 | return c.JSON(code, APICommonResponse{Result: err.Error()}) 238 | } 239 | return c.JSON(code, APIAccessResponse{Result: "ok", Sum: sum, Duration: duration}) 240 | } 241 | 242 | func (api *WebApi) ApiPurge(c echo.Context) error { 243 | r := APIPurgeRequest{} 244 | if err := c.Bind(&r); err != nil { 245 | return c.JSON(http.StatusBadRequest, APICommonResponse{Result: err.Error()}) 246 | } 247 | 248 | params, err := r.Validate() 249 | if err != nil { 250 | slog.Error(f("purge failed: %s", err)) 251 | return c.JSON(http.StatusBadRequest, APICommonResponse{Result: err.Error()}) 252 | } 253 | 254 | ctx := c.Request().Context() 255 | if err := api.purge(ctx, params); err != nil { 256 | return c.JSON(http.StatusInternalServerError, APICommonResponse{Result: err.Error()}) 257 | } 258 | return c.JSON(http.StatusOK, APICommonResponse{Result: "accepted"}) 259 | } 260 | 261 | func (api *WebApi) logs(c echo.Context) (int, []string, error) { 262 | subdomain := c.QueryParam("subdomain") 263 | since := c.QueryParam("since") 264 | tail := c.QueryParam("tail") 265 | 266 | if subdomain == "" { 267 | return http.StatusBadRequest, nil, fmt.Errorf("parameter required: subdomain") 268 | } 269 | 270 | var sinceTime time.Time 271 | if since != "" { 272 | var err error 273 | sinceTime, err = time.Parse(time.RFC3339, since) 274 | if err != nil { 275 | return http.StatusBadRequest, nil, fmt.Errorf("cannot parse since: %s", err) 276 | } 277 | } 278 | var tailN int 279 | if tail != "" { 280 | if tail == "all" { 281 | tailN = 0 282 | } else if n, err := strconv.Atoi(tail); err != nil { 283 | return http.StatusBadRequest, nil, fmt.Errorf("cannot parse tail: %s", err) 284 | } else { 285 | tailN = n 286 | } 287 | } 288 | 289 | ctx, cancel := context.WithTimeout(c.Request().Context(), APICallTimeout) 290 | defer cancel() 291 | logs, err := api.runner.Logs(ctx, subdomain, sinceTime, tailN) 292 | if err != nil { 293 | return http.StatusInternalServerError, nil, err 294 | } 295 | return http.StatusOK, logs, nil 296 | } 297 | 298 | func (api *WebApi) terminate(c echo.Context) (int, error) { 299 | r := APITerminateRequest{} 300 | if err := c.Bind(&r); err != nil { 301 | return http.StatusBadRequest, err 302 | } 303 | id := r.ID 304 | subdomain := r.Subdomain 305 | 306 | ctx, cancel := context.WithTimeout(c.Request().Context(), APICallTimeout) 307 | defer cancel() 308 | if id != "" { 309 | if err := api.runner.Terminate(ctx, id); err != nil { 310 | return http.StatusInternalServerError, err 311 | } 312 | } else if subdomain != "" { 313 | if err := api.runner.TerminateBySubdomain(ctx, subdomain); err != nil { 314 | return http.StatusInternalServerError, err 315 | } 316 | } else { 317 | return http.StatusBadRequest, fmt.Errorf("parameter required: id or subdomain") 318 | } 319 | return http.StatusOK, nil 320 | } 321 | 322 | func (api *WebApi) accessCounter(c echo.Context) (int, int64, int64, error) { 323 | subdomain := c.QueryParam("subdomain") 324 | duration := c.QueryParam("duration") 325 | durationInt, _ := strconv.ParseInt(duration, 10, 64) 326 | if durationInt == 0 { 327 | durationInt = 86400 // 24 hours 328 | } 329 | d := time.Duration(durationInt) * time.Second 330 | sum, err := api.runner.GetAccessCount(c.Request().Context(), subdomain, d) 331 | if err != nil { 332 | slog.Error(f("access counter failed: %s", err)) 333 | return http.StatusInternalServerError, 0, durationInt, err 334 | } 335 | return http.StatusOK, sum, durationInt, nil 336 | } 337 | 338 | func (api *WebApi) LoadParameter(getFunc func(string) string) (TaskParameter, error) { 339 | parameter := make(TaskParameter) 340 | 341 | for _, v := range api.cfg.Parameter { 342 | param := getFunc(v.Name) 343 | if param == "" && v.Default != "" { 344 | param = v.Default 345 | } 346 | if param == "" && v.Required { 347 | return nil, fmt.Errorf("lack require parameter: %s", v.Name) 348 | } else if param == "" { 349 | continue 350 | } 351 | 352 | if v.Rule != "" { 353 | if !v.Regexp.MatchString(param) { 354 | return nil, fmt.Errorf("parameter %s value is rule error", v.Name) 355 | } 356 | } 357 | if utf8.RuneCountInString(param) > 255 { 358 | return nil, fmt.Errorf("parameter %s value is too long(max 255 unicode characters)", v.Name) 359 | } 360 | parameter[v.Name] = param 361 | } 362 | 363 | return parameter, nil 364 | } 365 | 366 | func validateSubdomain(s string) error { 367 | if s == "" { 368 | return fmt.Errorf("subdomain is empty") 369 | } 370 | if len(s) < 2 { 371 | return fmt.Errorf("subdomain is too short") 372 | } 373 | if len(s) > 63 { 374 | return fmt.Errorf("subdomain is too long") 375 | } 376 | if !DNSNameRegexpWithPattern.MatchString(s) { 377 | return fmt.Errorf("subdomain %s includes invalid characters", s) 378 | } 379 | if _, err := path.Match(s, "x"); err != nil { 380 | return err 381 | } 382 | return nil 383 | } 384 | 385 | func (api *WebApi) purge(ctx context.Context, p *PurgeParams) error { 386 | infos, err := api.runner.List(ctx, statusRunning) 387 | if err != nil { 388 | slog.Error(f("list ecs failed: %s", err)) 389 | return fmt.Errorf("list tasks failed: %w", err) 390 | } 391 | slog.Info("purge subdomains", 392 | "duration", p.Duration, 393 | "excludes", p.Excludes, 394 | "exclude_tags", p.ExcludeTags, 395 | "exclude_regexp", p.ExcludeRegexp, 396 | ) 397 | terminates := []string{} 398 | for _, info := range infos { 399 | if info.ShouldBePurged(p) { 400 | terminates = append(terminates, info.SubDomain) 401 | } 402 | } 403 | terminates = lo.Uniq(terminates) 404 | if len(terminates) > 0 { 405 | slog.Info(f("purge %d subdomains", len(terminates))) 406 | // running in background. Don't cancel by client context. 407 | go api.purgeSubdomains(context.Background(), terminates, p.Duration) 408 | } 409 | 410 | slog.Info("no subdomains to purge") 411 | return nil 412 | } 413 | 414 | func (api *WebApi) purgeSubdomains(ctx context.Context, subdomains []string, duration time.Duration) { 415 | if api.mu.TryLock() { 416 | defer api.mu.Unlock() 417 | } else { 418 | slog.Info("skip purge subdomains, another purge is running") 419 | return 420 | } 421 | slog.Info(f("start purge subdomains %d", len(subdomains))) 422 | purged := 0 423 | for _, subdomain := range subdomains { 424 | sum, err := api.runner.GetAccessCount(ctx, subdomain, duration) 425 | if err != nil { 426 | slog.Warn(f("access count failed: %s %s", subdomain, err)) 427 | continue 428 | } 429 | if sum > 0 { 430 | slog.Info(f("skip purge %s %d access", subdomain, sum)) 431 | continue 432 | } 433 | if err := api.runner.TerminateBySubdomain(ctx, subdomain); err != nil { 434 | slog.Warn(f("terminate failed %s %s", subdomain, err)) 435 | } else { 436 | purged++ 437 | slog.Info(f("purged %s", subdomain)) 438 | } 439 | time.Sleep(3 * time.Second) 440 | } 441 | slog.Info(f("purge %d subdomains completed", purged)) 442 | } 443 | -------------------------------------------------------------------------------- /webapi_test.go: -------------------------------------------------------------------------------- 1 | package mirageecs_test 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "io/ioutil" 8 | "net/http" 9 | "net/url" 10 | "os" 11 | "strings" 12 | "testing" 13 | 14 | mirageecs "github.com/acidlemon/mirage-ecs/v2" 15 | "github.com/labstack/echo/v4" 16 | ) 17 | 18 | func TestLoadParameter(t *testing.T) { 19 | ctx := context.Background() 20 | 21 | testFile := "config_sample.yml" 22 | cfg, _ := mirageecs.NewConfig(ctx, &mirageecs.ConfigParams{Path: testFile}) 23 | app := mirageecs.NewWebApi(cfg, &mirageecs.LocalTaskRunner{}) 24 | 25 | params := url.Values{} 26 | params.Set("nick", "mirageman") 27 | params.Set("branch", "develop") 28 | params.Set("test", "dummy") 29 | 30 | req, err := http.NewRequest("POST", fmt.Sprintf("localhost?%s", params.Encode()), nil) 31 | if err != nil { 32 | t.Error(err) 33 | } 34 | 35 | e := echo.New() 36 | c := e.NewContext(req, nil) 37 | parameter, err := app.LoadParameter(c.FormValue) 38 | 39 | if err != nil { 40 | t.Error(err) 41 | } 42 | 43 | if len(parameter) != 1 { 44 | t.Error(errors.New("could not parse parameter")) 45 | } 46 | 47 | if parameter["branch"] != "develop" { 48 | t.Error(errors.New("could not parse parameter")) 49 | } 50 | 51 | if parameter["test"] != "" { 52 | t.Error(errors.New("could not parse parameter")) 53 | } 54 | 55 | f, err := ioutil.TempFile("", "") 56 | if err != nil { 57 | t.Error(err) 58 | } 59 | defer func() { 60 | f.Close() 61 | os.Remove(f.Name()) 62 | }() 63 | 64 | data := `--- 65 | parameters: 66 | - name: branch 67 | env: GIT_BRANCH 68 | rule: "[0-9a-z]{5,32}" 69 | required: true 70 | - name: nick 71 | env: NICK 72 | rule: "[0-9A-Za-z]{1,10}" 73 | required: false 74 | - name: test 75 | env: TEST 76 | rule: 77 | required: false 78 | ` 79 | if err := ioutil.WriteFile(f.Name(), []byte(data), 0644); err != nil { 80 | t.Error(err) 81 | } 82 | 83 | cfg, err = mirageecs.NewConfig(ctx, &mirageecs.ConfigParams{Path: f.Name()}) 84 | if err != nil { 85 | t.Error(err) 86 | } 87 | app = mirageecs.NewWebApi(cfg, &mirageecs.LocalTaskRunner{}) 88 | 89 | c = e.NewContext(req, nil) 90 | parameter, err = app.LoadParameter(c.FormValue) 91 | if err != nil { 92 | t.Error(err) 93 | } 94 | 95 | if len(parameter) != 3 { 96 | t.Error(errors.New("could not parse parameter")) 97 | } 98 | 99 | if parameter["test"] != "dummy" { 100 | t.Error(errors.New("could not parse parameter")) 101 | } 102 | 103 | params = url.Values{} 104 | params.Set("nick", "mirageman") 105 | params.Set("branch", "aaa") 106 | params.Set("test", "dummy") 107 | 108 | req, err = http.NewRequest("POST", fmt.Sprintf("localhost?%s", params.Encode()), nil) 109 | if err != nil { 110 | t.Error(err) 111 | } 112 | 113 | c = e.NewContext(req, nil) 114 | _, err = app.LoadParameter(c.FormValue) 115 | 116 | if err == nil { 117 | t.Error("Not apply parameter rule") 118 | } 119 | 120 | params = url.Values{} 121 | params.Set("nick", "mirageman") 122 | params.Set("test", "dummy") 123 | 124 | req, err = http.NewRequest("POST", fmt.Sprintf("localhost?%s", params.Encode()), nil) 125 | if err != nil { 126 | t.Error(err) 127 | } 128 | 129 | c = e.NewContext(req, nil) 130 | _, err = app.LoadParameter(c.FormValue) 131 | 132 | if err == nil { 133 | t.Error("Not apply parameter rule") 134 | } 135 | 136 | } 137 | 138 | var validSubdomains = []string{ 139 | "ab", 140 | "abc", 141 | "a-z", 142 | "AB-CD", 143 | "a-z-0-9", 144 | "a123456789", 145 | "www*", 146 | "foo[0-9]", 147 | "api-?-test", 148 | "*-xxx", 149 | strings.Repeat("a", 63), 150 | } 151 | 152 | var invalidSubdomains = []string{ 153 | "0abc", 154 | "a-", 155 | "-a", 156 | "a.b", 157 | "a+b", 158 | "a_b", 159 | "a^b", 160 | "a$b", 161 | "a%b", 162 | "www/xxx", 163 | "foo[0-9", 164 | strings.Repeat("a", 64), 165 | } 166 | 167 | func TestValidateSubdomain(t *testing.T) { 168 | for _, s := range validSubdomains { 169 | if err := mirageecs.ValidateSubdomain(s); err != nil { 170 | t.Errorf("%s should be valid", s) 171 | } 172 | } 173 | 174 | for _, s := range invalidSubdomains { 175 | if err := mirageecs.ValidateSubdomain(s); err == nil { 176 | t.Errorf("%s should be invalid", s) 177 | } 178 | } 179 | } 180 | --------------------------------------------------------------------------------