diff options
author | Quentin <quentin@dufour.io> | 2024-06-24 10:15:56 +0000 |
---|---|---|
committer | Quentin <quentin@dufour.io> | 2024-06-24 10:15:56 +0000 |
commit | 9917429da3b06462969b41d511ad4daf27eaf197 (patch) | |
tree | 1a27c40a1ff9123b5552769fcf3cbb543d8dc959 | |
parent | e7e05ed929c92c2b9d193f8193878c1a8a74c43c (diff) | |
parent | bc7bc61f7449b1f41ed9eb46388ab0c149856f96 (diff) | |
download | guichet-9917429da3b06462969b41d511ad4daf27eaf197.tar.gz guichet-9917429da3b06462969b41d511ad4daf27eaf197.zip |
Merge pull request 'per-bucket keys' (#68) from feat-per-bucket-key into mainmain
Reviewed-on: https://git.deuxfleurs.fr/Deuxfleurs/guichet/pulls/68
-rw-r--r-- | config.json.example | 18 | ||||
-rw-r--r-- | flake.nix | 11 | ||||
-rw-r--r-- | garage.go | 220 | ||||
-rw-r--r-- | go.mod | 2 | ||||
-rw-r--r-- | go.sum | 2 | ||||
-rw-r--r-- | gomod2nix.toml | 4 | ||||
-rw-r--r-- | integration/docker-compose.yml | 10 | ||||
-rw-r--r-- | login.go | 8 | ||||
-rw-r--r-- | main.go | 1 | ||||
-rw-r--r-- | templates/garage_key.html | 234 | ||||
-rw-r--r-- | templates/garage_website_inspect.html | 296 | ||||
-rw-r--r-- | templates/home.html | 3 | ||||
-rw-r--r-- | website.go | 234 | ||||
-rw-r--r-- | webui_website.go | 178 |
14 files changed, 738 insertions, 483 deletions
diff --git a/config.json.example b/config.json.example index 1760685..2d40aac 100644 --- a/config.json.example +++ b/config.json.example @@ -10,25 +10,23 @@ "invitation_base_dn": "ou=invitations,dc=bottin,dc=eu", "invitation_name_attr": "cn", - "invited_mail_format": "{}@example.com", - "invited_auto_groups": [ - "cn=email,ou=groups,dc=bottin,dc=eu" - ], + "invited_mail_format": "{}@bottin.eu", + "invited_auto_groups": [ ], - "web_address": "http://guichet.localhost:9991", - "mail_from": "welcome@example.com", - "smtp_server": "smtp.example.com", + "web_address": "http://localhost:9991", + "mail_from": "welcome@bottin.eu", + "smtp_server": "smtp.bottin.eu", "smtp_username": "guichet", "smtp_password": "", "admin_account": "cn=admin,dc=bottin,dc=eu", - "group_can_admin": "gid=admin,ou=groups,dc=bottin,dc=eu", - "group_can_invite": "", + "group_can_admin": "cn=admin,ou=groups,dc=bottin,dc=eu", + "group_can_invite": "cn=admin,ou=groups,dc=bottin,dc=eu", "s3_admin_endpoint": "localhost:3903", "s3_admin_token": "GlXP43PWH3LuvEGSNxKYzZCyUss8VqZmarBU+HUlrxw=", - "s3_endpoint": "localhost", + "s3_endpoint": "localhost:3900", "s3_access_key": "", "s3_secret_key": "", "s3_region": "garage", @@ -12,9 +12,6 @@ system = "x86_64-linux"; overlays = [ (import "${gomod2nix}/overlay.nix") - /*(self: super: { - gomod = super.callPackage "${gomod2nix}/builder/" { }; - })*/ ]; }; src = ./.; @@ -38,10 +35,16 @@ platforms = platforms.linux; }; }; + + container = pkgs.dockerTools.buildImage { name = "dxflrs/guichet"; + copyToRoot = pkgs.buildEnv { + name = "guichet-env"; + paths = [ guichet pkgs.cacert ]; + }; config = { - Entrypoint = "${guichet}/bin/guichet"; + Entrypoint = "/bin/guichet"; }; }; in { @@ -4,10 +4,7 @@ import ( "context" "fmt" garage "git.deuxfleurs.fr/garage-sdk/garage-admin-sdk-golang" - "github.com/gorilla/mux" "log" - "net/http" - "strings" ) func gadmin() (*garage.APIClient, context.Context) { @@ -26,8 +23,9 @@ func gadmin() (*garage.APIClient, context.Context) { func grgCreateKey(name string) (*garage.KeyInfo, error) { client, ctx := gadmin() - kr := garage.AddKeyRequest{Name: &name} - resp, _, err := client.KeyApi.AddKey(ctx).AddKeyRequest(kr).Execute() + kr := garage.NewAddKeyRequest() + kr.SetName(name) + resp, _, err := client.KeyApi.AddKey(ctx).AddKeyRequest(*kr).Execute() if err != nil { fmt.Printf("%+v\n", err) return nil, err @@ -38,7 +36,7 @@ func grgCreateKey(name string) (*garage.KeyInfo, error) { func grgGetKey(accessKey string) (*garage.KeyInfo, error) { client, ctx := gadmin() - resp, _, err := client.KeyApi.GetKey(ctx, accessKey).Execute() + resp, _, err := client.KeyApi.GetKey(ctx).Id(accessKey).ShowSecretKey("true").Execute() if err != nil { fmt.Printf("%+v\n", err) return nil, err @@ -46,6 +44,28 @@ func grgGetKey(accessKey string) (*garage.KeyInfo, error) { return resp, nil } +func grgSearchKey(name string) (*garage.KeyInfo, error) { + client, ctx := gadmin() + + resp, _, err := client.KeyApi.GetKey(ctx).Search(name).ShowSecretKey("true").Execute() + if err != nil { + fmt.Printf("%+v\n", err) + return nil, err + } + return resp, nil +} + +func grgDelKey(accessKey string) error { + client, ctx := gadmin() + + _, err := client.KeyApi.DeleteKey(ctx).Id(accessKey).Execute() + if err != nil { + fmt.Printf("%+v\n", err) + return err + } + return nil +} + func grgCreateBucket(bucket string) (*garage.BucketInfo, error) { client, ctx := gadmin() @@ -61,14 +81,14 @@ func grgCreateBucket(bucket string) (*garage.BucketInfo, error) { return binfo, nil } -func grgAllowKeyOnBucket(bid, gkey string) (*garage.BucketInfo, error) { +func grgAllowKeyOnBucket(bid, gkey string, read, write, owner bool) (*garage.BucketInfo, error) { client, ctx := gadmin() // Allow user's key ar := garage.AllowBucketKeyRequest{ BucketId: bid, AccessKeyId: gkey, - Permissions: *garage.NewAllowBucketKeyRequestPermissions(true, true, true), + Permissions: *garage.NewAllowBucketKeyRequestPermissions(read, write, owner), } binfo, _, err := client.BucketApi.AllowBucketKey(ctx).AllowBucketKeyRequest(ar).Execute() if err != nil { @@ -91,7 +111,7 @@ func allowWebsiteDefault() *garage.UpdateBucketRequestWebsiteAccess { func grgUpdateBucket(bid string, ur *garage.UpdateBucketRequest) (*garage.BucketInfo, error) { client, ctx := gadmin() - binfo, _, err := client.BucketApi.UpdateBucket(ctx, bid).UpdateBucketRequest(*ur).Execute() + binfo, _, err := client.BucketApi.UpdateBucket(ctx).Id(bid).UpdateBucketRequest(*ur).Execute() if err != nil { fmt.Printf("%+v\n", err) return nil, err @@ -148,7 +168,7 @@ func grgDelLocalAlias(bid, key, alias string) (*garage.BucketInfo, error) { func grgGetBucket(bid string) (*garage.BucketInfo, error) { client, ctx := gadmin() - resp, _, err := client.BucketApi.GetBucketInfo(ctx, bid).Execute() + resp, _, err := client.BucketApi.GetBucketInfo(ctx).Id(bid).Execute() if err != nil { log.Println(err) return nil, err @@ -160,187 +180,9 @@ func grgGetBucket(bid string) (*garage.BucketInfo, error) { func grgDeleteBucket(bid string) error { client, ctx := gadmin() - _, err := client.BucketApi.DeleteBucket(ctx, bid).Execute() + _, err := client.BucketApi.DeleteBucket(ctx).Id(bid).Execute() if err != nil { log.Println(err) } return err } - -// --- Start page rendering functions - -func handleWebsiteConfigure(w http.ResponseWriter, r *http.Request) { - user := RequireUserHtml(w, r) - if user == nil { - return - } - - tKey := getTemplate("garage_key.html") - tKey.Execute(w, user) -} - -func handleWebsiteList(w http.ResponseWriter, r *http.Request) { - user := RequireUserHtml(w, r) - if user == nil { - return - } - - ctrl, err := NewWebsiteController(user) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - if len(ctrl.PrettyList) > 0 { - http.Redirect(w, r, "/website/inspect/"+ctrl.PrettyList[0], http.StatusFound) - } else { - http.Redirect(w, r, "/website/new", http.StatusFound) - } -} - -type WebsiteNewTpl struct { - Ctrl *WebsiteController - Err error -} - -func handleWebsiteNew(w http.ResponseWriter, r *http.Request) { - user := RequireUserHtml(w, r) - if user == nil { - return - } - - ctrl, err := NewWebsiteController(user) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - tpl := &WebsiteNewTpl{ctrl, nil} - - tWebsiteNew := getTemplate("garage_website_new.html") - if r.Method == "POST" { - r.ParseForm() - - bucket := strings.Join(r.Form["bucket"], "") - if bucket == "" { - bucket = strings.Join(r.Form["bucket2"], "") - } - - view, err := ctrl.Create(bucket) - if err != nil { - tpl.Err = err - tWebsiteNew.Execute(w, tpl) - return - } - - http.Redirect(w, r, "/website/inspect/"+view.Name.Pretty, http.StatusFound) - return - } - - tWebsiteNew.Execute(w, tpl) -} - -type WebsiteInspectTpl struct { - Describe *WebsiteDescribe - View *WebsiteView - Err error -} - -func handleWebsiteInspect(w http.ResponseWriter, r *http.Request) { - var processErr error - - user := RequireUserHtml(w, r) - if user == nil { - return - } - - ctrl, err := NewWebsiteController(user) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - bucketName := mux.Vars(r)["bucket"] - - if r.Method == "POST" { - r.ParseForm() - action := strings.Join(r.Form["action"], "") - switch action { - case "increase_quota": - _, processErr = ctrl.Patch(bucketName, &WebsitePatch{Size: &user.Quota.WebsiteSizeBursted}) - case "delete_bucket": - processErr = ctrl.Delete(bucketName) - if processErr == nil { - http.Redirect(w, r, "/website", http.StatusFound) - } - default: - processErr = fmt.Errorf("Unknown action") - } - - } - - view, err := ctrl.Inspect(bucketName) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - describe, err := ctrl.Describe() - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - tpl := &WebsiteInspectTpl{describe, view, processErr} - - tWebsiteInspect := getTemplate("garage_website_inspect.html") - tWebsiteInspect.Execute(w, &tpl) -} - -func handleWebsiteVhost(w http.ResponseWriter, r *http.Request) { - var processErr error - - user := RequireUserHtml(w, r) - if user == nil { - return - } - - ctrl, err := NewWebsiteController(user) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - bucketName := mux.Vars(r)["bucket"] - - if r.Method == "POST" { - r.ParseForm() - - bucket := strings.Join(r.Form["bucket"], "") - if bucket == "" { - bucket = strings.Join(r.Form["bucket2"], "") - } - - view, processErr := ctrl.Patch(bucketName, &WebsitePatch{Vhost: &bucket}) - if processErr == nil { - http.Redirect(w, r, "/website/inspect/"+view.Name.Pretty, http.StatusFound) - return - } - } - - view, err := ctrl.Inspect(bucketName) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - describe, err := ctrl.Describe() - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - tpl := &WebsiteInspectTpl{describe, view, processErr} - tWebsiteEdit := getTemplate("garage_website_edit.html") - tWebsiteEdit.Execute(w, &tpl) -} @@ -3,7 +3,7 @@ module git.deuxfleurs.fr/Deuxfleurs/guichet go 1.18 require ( - git.deuxfleurs.fr/garage-sdk/garage-admin-sdk-golang v0.0.0-20230131081355-c965fe7f7dc9 + git.deuxfleurs.fr/garage-sdk/garage-admin-sdk-golang v0.0.0-20231128153612-8b81fae65e5e github.com/emersion/go-sasl v0.0.0-20191210011802-430746ea8b9b github.com/emersion/go-smtp v0.12.1 github.com/go-ldap/ldap/v3 v3.1.6 @@ -33,6 +33,8 @@ cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9 dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= git.deuxfleurs.fr/garage-sdk/garage-admin-sdk-golang v0.0.0-20230131081355-c965fe7f7dc9 h1:ERg8KCpIKym98EOKa8Gq0NSBxsasD3sqb/R0gg1wOzU= git.deuxfleurs.fr/garage-sdk/garage-admin-sdk-golang v0.0.0-20230131081355-c965fe7f7dc9/go.mod h1:TlSL6QVxozmdRaSgP6Akspi0HCJv4HAkkq3Dldru4GM= +git.deuxfleurs.fr/garage-sdk/garage-admin-sdk-golang v0.0.0-20231128153612-8b81fae65e5e h1:h89CAh0qmUcGJykss/utXIw+yRGa3Gr6VyrZ5ZWN0kY= +git.deuxfleurs.fr/garage-sdk/garage-admin-sdk-golang v0.0.0-20231128153612-8b81fae65e5e/go.mod h1:TlSL6QVxozmdRaSgP6Akspi0HCJv4HAkkq3Dldru4GM= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= diff --git a/gomod2nix.toml b/gomod2nix.toml index 4f10838..21b9dbc 100644 --- a/gomod2nix.toml +++ b/gomod2nix.toml @@ -2,8 +2,8 @@ schema = 3 [mod] [mod."git.deuxfleurs.fr/garage-sdk/garage-admin-sdk-golang"] - version = "v0.0.0-20230131081355-c965fe7f7dc9" - hash = "sha256-qJN9yDMIh3xRk/3IYEWZca/biMVXXmDlPTzy0Cg11oc=" + version = "v0.0.0-20231128153612-8b81fae65e5e" + hash = "sha256-o9kbcJ25/cYYwWZz/LBF7ZDyW8bZAjdg5pPu0gvb5JQ=" [mod."github.com/emersion/go-sasl"] version = "v0.0.0-20191210011802-430746ea8b9b" hash = "sha256-bADpAn0ZhlTTsEB3MsG8J31cQjTtHTzohX/wkL1aMIc=" diff --git a/integration/docker-compose.yml b/integration/docker-compose.yml index ec855db..e44a723 100644 --- a/integration/docker-compose.yml +++ b/integration/docker-compose.yml @@ -1,11 +1,15 @@ version: '3' services: consul: - image: hashicorp/consul:1.16 + # sync with nixos stable packages assuming our stack is up to date + # https://search.nixos.org/packages?channel=24.05&from=0&size=50&sort=relevance&type=packages&query=consul + image: hashicorp/consul:1.18 restart: "always" expose: - 8500 bottin: + # sync with deuxfleurs/nixcfg/cluster/prod/app/core/deploy/bottin.hcl + # to ensure compatibility with prod image: dxflrs/bottin:7h18i30cckckaahv87d3c86pn4a7q41z #command: "-config /etc/bottin.json" restart: "always" @@ -15,7 +19,9 @@ services: volumes: - "./config/bottin.json:/config.json" garage: - image: dxflrs/garage:v0.8.2 + # sync with deuxfleurs/nixcfg/cluster/prod/app/garage/deploy/garage.hcl + # to ensure compatibility with prod + image: superboum/garage:v1.0.0-rc1-hotfix-red-ftr-wquorum ports: - "3900:3900" - "3902:3902" @@ -143,6 +143,7 @@ func NewCapabilities(login *LoginStatus, entry *ldap.Entry) *Capabilities { // --- Logged User --- type LoggedUser struct { + Username string Login *LoginStatus Entry *ldap.Entry Capabilities *Capabilities @@ -186,7 +187,9 @@ func NewLoggedUser(login *LoginStatus) (*LoggedUser, error) { } entry := sr.Entries[0] + username := login.Info.Username lu := &LoggedUser{ + Username: username, Login: login, Entry: entry, Capabilities: NewCapabilities(login, entry), @@ -204,6 +207,7 @@ func (lu *LoggedUser) WelcomeName() string { } return ret } + func (lu *LoggedUser) S3KeyInfo() (*garage.KeyInfo, error) { var err error var keyPair *garage.KeyInfo @@ -212,7 +216,7 @@ func (lu *LoggedUser) S3KeyInfo() (*garage.KeyInfo, error) { keyID := lu.Entry.GetAttributeValue("garage_s3_access_key") if keyID == "" { // If there is no S3Key in LDAP, generate it... - keyPair, err = grgCreateKey(lu.Login.Info.Username) + keyPair, err = grgCreateKey(lu.Username) if err != nil { return nil, err } @@ -221,7 +225,7 @@ func (lu *LoggedUser) S3KeyInfo() (*garage.KeyInfo, error) { // @FIXME compatibility feature for bagage (SFTP+webdav) // you can remove it once bagage will be updated to fetch the key from garage directly // or when bottin will be able to dynamically fetch it. - modify_request.Replace("garage_s3_secret_key", []string{*keyPair.SecretAccessKey}) + modify_request.Replace("garage_s3_secret_key", []string{*keyPair.SecretAccessKey.Get()}) err = lu.Login.conn.Modify(modify_request) if err != nil { return nil, err @@ -159,7 +159,6 @@ func server(args []string) { r.HandleFunc("/website", handleWebsiteList) r.HandleFunc("/website/new", handleWebsiteNew) - r.HandleFunc("/website/configure", handleWebsiteConfigure) r.HandleFunc("/website/inspect/{bucket}", handleWebsiteInspect) r.HandleFunc("/website/vhost/{bucket}", handleWebsiteVhost) diff --git a/templates/garage_key.html b/templates/garage_key.html deleted file mode 100644 index cf56822..0000000 --- a/templates/garage_key.html +++ /dev/null @@ -1,234 +0,0 @@ -{{define "title"}}Profile |{{end}} - -{{define "body"}} -<div class="d-flex"> - <h4>Mes identifiants</h4> - <a class="ml-auto btn btn-link" href="/website">Mes sites webs</a> - <a class="ml-4 btn btn-info" href="/">Menu principal</a> -</div> - -<ul class="nav nav-tabs" id="proto" role="tablist"> - <li class="nav-item"> - <a class="nav-link active" id="s3-tab" data-toggle="tab" href="#s3" role="tab" aria-controls="s3" aria-selected="true">S3</a> - </li> - <li class="nav-item"> - <a class="nav-link" id="sftp-tab" data-toggle="tab" href="#sftp" role="tab" aria-controls="sftp" aria-selected="false">SFTP</a> - </li> -</ul> - -<div class="tab-content" id="protocols"> - <div class="tab-pane fade show active" id="s3" role="tabpanel" aria-labelledby="s3-tab"> - <table class="table mt-4"> - <tbody> - <tr> - <th scope="row" class="col-md-2">Identifiant de clé</th> - <td>{{ .S3KeyInfo.AccessKeyId }}</td> - </tr> - <tr> - <th scope="row">Clé secrète</th> - <td><a href="#" onclick="document.getElementById('secret_key').style.display='inline'; this.style.display='none'">Cliquer pour afficher la clé secrète</a><span id="secret_key" style="display: none">{{ .S3KeyInfo.SecretAccessKey }}</span></td> - </tr> - <tr> - <th scope="row">Région</th> - <td>garage</td> - </tr> - <tr> - <th scope="row">Endpoint URL</th> - <td>https://garage.deuxfleurs.fr</td> - </tr> - <tr> - <th scope="row">Type d'URL</th> - <td>DNS et chemin (préférer chemin)</td> - </tr> - <tr> - <th scope="row">Signature</th> - <td>Version 4</td> - </tr> - </tbody> - </table> - - <p>Configurer votre logiciel :</p> - - <div class="accordion" id="softconfig"> - <div class="card"> - <div class="card-header" id="awscli-title"> - <h2 class="mb-0"> - <button class="btn btn-link btn-block text-left collapsed" type="button" data-toggle="collapse" data-target="#awscli" aria-expanded="false" aria-controls="awscli"> - awscli - </button> - </h2> - </div> - <div id="awscli" class="collapse" aria-labelledby="awscli-title" data-parent="#softconfig"> - <div class="card-body"> - <p>Créez un fichier nommé <code>~/.awsrc</code> :</p> - <pre> -export AWS_ACCESS_KEY_ID={{ .S3KeyInfo.AccessKeyId }} -export AWS_SECRET_ACCESS_KEY={{ .S3KeyInfo.SecretAccessKey }} -export AWS_DEFAULT_REGION='garage' - -function aws { command aws --endpoint-url https://garage.deuxfleurs.fr $@ ; } -aws --version - </pre> - <p>Ensuite vous pouvez utiliser awscli :</p> - <pre> -source ~/.awsrc -aws s3 ls -aws s3 ls s3://my-bucket -aws s3 cp /tmp/a.txt s3://my-bucket -... - </pre> - </div> - </div> - </div> - - <div class="card"> - <div class="card-header" id="minio-title"> - <h2 class="mb-0"> - <button class="btn btn-link btn-block text-left" type="button" data-toggle="collapse" data-target="#minio" aria-expanded="true" aria-controls="minio"> - Minio CLI - </button> - </h2> - </div> - - <div id="minio" class="collapse" aria-labelledby="minio-title" data-parent="#softconfig"> - <div class="card-body"> - <p>Vous pouvez configurer Minio CLI avec cette commande :</p> - <pre> -mc alias set \ - garage \ - https://garage.deuxfleurs.fr \ - {{ .S3KeyInfo.AccessKeyId }} \ - {{ .S3KeyInfo.SecretAccessKey }} \ - --api S3v4 - </pre> - <p>Et ensuite pour utiliser Minio CLI avec :</p> - <pre> -mc ls garage/ -mc cp /tmp/a.txt garage/my-bucket/a.txt -... - </pre> - </div> - </div> - </div> - - <div class="card"> - <div class="card-header" id="winscp-title"> - <h2 class="mb-0"> - <button class="btn btn-link btn-block text-left" type="button" data-toggle="collapse" data-target="#winscp" aria-expanded="true" aria-controls="winscp"> - WinSCP - </button> - </h2> - </div> - - <div id="winscp" class="collapse" aria-labelledby="winscp-title" data-parent="#softconfig"> - <div class="card-body"> - Reportez vous <a href="">au guide</a> - </div> - </div> - </div> - - <div class="card"> - <div class="card-header" id="hugo-title"> - <h2 class="mb-0"> - <button class="btn btn-link btn-block text-left collapsed" type="button" data-toggle="collapse" data-target="#hugo" aria-expanded="false" aria-controls="hugo"> - Hugo - </button> - </h2> - </div> - <div id="hugo" class="collapse" aria-labelledby="hugo-title" data-parent="#softconfig"> - <div class="card-body"> - <p>Dans votre fichier <code>config.toml</code>, rajoutez :</p> - <pre> -[[deployment.targets]] - URL = "s3://bucket?endpoint=garage.deuxfleurs.fr&s3ForcePathStyle=true&region=garage" - </pre> - <p>Assurez-vous d'avoir un fichier dans lequel les variables <code>AWS_ACCESS_KEY_ID</code> et <code>AWS_SECRET_ACCESS_KEY</code> sont définies, - ici on suppose que vous avez suivi les instructions de l'outil awscli (ci-dessus) et que vous avez un fichier <code>~/.awsrc</code> qui défini ces variables. - Ensuite : </p> - <pre> -source ~/.awsrc -hugo deploy - </pre> - </div> - </div> - </div> - - <div class="card"> - <div class="card-header" id="publii-title"> - <h2 class="mb-0"> - <button class="btn btn-link btn-block text-left collapsed" type="button" data-toggle="collapse" data-target="#publii" aria-expanded="false" aria-controls="publii"> - Publii - </button> - </h2> - </div> - <div id="publii" class="collapse" aria-labelledby="publii-title" data-parent="#softconfig"> - <div class="card-body"> - <em>Bientôt...</em> - </div> - </div> - </div> - </div> - </div> - - <!-- sftp --> - <div class="tab-pane fade" id="sftp" role="tabpanel" aria-labelledby="sftp-tab"> - <table class="table mt-4"> - <tbody> - <tr> - <th scope="row">Nom d'utilisateur-ice</th> - <td>{{ .Login.Info.Username }}</td> - </tr> - <tr> - <th scope="row">Mot de passe</th> - <td>(votre mot de passe guichet)</td> - </tr> - <tr> - <th scope="row">Hôte</th> - <td>sftp://bagage.deuxfleurs.fr</td> - </tr> - <tr> - <th scope="row">Port</th> - <td>2222</td> - </tr> - </tbody> - </table> - <p>Configurer votre logiciel :</p> - - <div class="accordion" id="softconfig2"> - <div class="card"> - <div class="card-header" id="filezilla-title"> - <h2 class="mb-0"> - <button class="btn btn-link btn-block text-left collapsed" type="button" data-toggle="collapse" data-target="#filezilla" aria-expanded="false" aria-controls="filezilla"> - scp - </button> - </h2> - </div> - <div id="filezilla" class="collapse show" aria-labelledby="filezilla-title" data-parent="#softconfig"> - <div class="card-body"> - <p>Un exemple avec SCP :</p> - <pre> -scp -oHostKeyAlgorithms=+ssh-rsa -P2222 -r ./public {{ .Login.Info.Username }}@bagage.deuxfleurs.fr:mon_bucket/ - </pre> - </div> - </div> - </div> - <div class="card"> - <div class="card-header" id="filezilla-title"> - <h2 class="mb-0"> - <button class="btn btn-link btn-block text-left collapsed" type="button" data-toggle="collapse" data-target="#filezilla" aria-expanded="false" aria-controls="filezilla"> - Filezilla - </button> - </h2> - </div> - <div id="filezilla" class="collapse" aria-labelledby="filezilla-title" data-parent="#softconfig"> - <div class="card-body"> - <em>Bientôt</em> - </div> - </div> - </div> - </div> - - </div> -</div> - -{{end}} diff --git a/templates/garage_website_inspect.html b/templates/garage_website_inspect.html index a8f463d..6182f0d 100644 --- a/templates/garage_website_inspect.html +++ b/templates/garage_website_inspect.html @@ -3,8 +3,8 @@ {{define "body"}} <div class="d-flex"> <!--<h4>Inspecter les sites webs</h4>--> - <a class="ml-auto btn btn-link" href="/website/configure">Mes identifiants</a> - <a class="ml-4 btn btn-info" href="/">Menu principal</a> + <a class="ml-auto btn btn-link" href="/website/configure">Mes identifiants</a> + <a class="ml-4 btn btn-info" href="/">Menu principal</a> </div> <div class="row"> @@ -15,9 +15,9 @@ {{ end }} <div class="col-md-3 mt-3"> - <a class="btn btn-primary btn-block" href="/website/new"> + <a class="btn btn-primary btn-block" href="/website/new"> <svg id="i-plus" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="18" height="18" fill="none" stroke="currentcolor" stroke-linecap="round" stroke-linejoin="round" stroke-width="6"> - <path d="M16 2 L16 30 M2 16 L30 16" /> + <path d="M16 2 L16 30 M2 16 L30 16" /> </svg> <span class="ml-1">Nouveau site web</span> </a> @@ -45,11 +45,13 @@ <div class="col-md-9"> <h2>{{ .View.Name.Url }}</h2> + <!-- QUOTAS --> + <h5 class="mt-3">Quotas</h5> <div class="progress mt-3"> <div class="progress-bar" role="progressbar" aria-valuenow="{{ .View.Size.Current }}" aria-valuemin="0" aria-valuemax="{{ .View.Size.Max }}" style="width: {{ .View.Size.Percent }}%; min-width: 2em;"> {{ .View.Size.Percent }}% - </div> + </div> </div> <p class="text-center"> @@ -59,23 +61,295 @@ {{ end }} </p> + <!-- ACTIONS --> <h5 class="mt-3">Actions</h5> <form action="" method="post"> <div class="btn-group" role="group" aria-label="Actions sur le site web"> - <button class="btn btn-secondary" name="action" value="increase_quota">Augmenter le quota</button> + <button class="btn btn-secondary" name="action" value="increase_quota">Augmenter le quota</button> + <button class="btn btn-secondary" name="action" value="rotate_key">Rotation de la clé</button> <a class="btn btn-secondary" href="/website/vhost/{{ .View.Name.Pretty }}">Changer le nom de domaine</a> - <button class="btn btn-danger" name="action" value="delete_bucket">Supprimer</button> + <button class="btn btn-danger" name="action" value="delete_bucket">Supprimer</button> </div> </form> - {{ if .View.Name.Expanded }} - <h5 class="mt-5">Vous ne savez pas comment configurer votre nom de domaine ?</h5> - <p> Le nom de domaine {{ .View.Name.Url }} n'est pas géré par Deuxfleurs, il vous revient donc de configurer la zone DNS. Vous devez ajouter une entrée <code>CNAME garage.deuxfleurs.fr</code> ou <code>ALIAS garage.deuxfleurs.fr</code> auprès de votre hébergeur DNS, qui est souvent aussi le bureau d'enregistrement (eg. Gandi, GoDaddy, BookMyName, etc.).</p> - {{ end }} + <!-- INFO --> + <h5 class="mt-3">Informations de connexion</h5> + <ul class="nav nav-tabs" id="proto" role="tablist"> + <li class="nav-item"> + <a class="nav-link active" id="s3-tab" data-toggle="tab" href="#s3" role="tab" aria-controls="s3" aria-selected="true">S3 (recommandé)</a> + </li> + <li class="nav-item"> + <a class="nav-link" id="sftp-tab" data-toggle="tab" href="#sftp" role="tab" aria-controls="sftp" aria-selected="false">SFTP</a> + </li> + <li class="nav-item"> + <a class="nav-link" id="dav-tab" data-toggle="tab" href="#dav" role="tab" aria-controls="dav" aria-selected="false">WebDAV</a> + </li> + </ul> + <div class="tab-content" id="protocols"> + <div class="tab-pane fade show active" id="s3" role="tabpanel" aria-labelledby="s3-tab"> + <table class="table mt-4"> + <tbody> + <tr> + <th scope="row" class="col-md-2">Identifiant de clé</th> + <td>{{ .View.AccessKeyId }}</td> + </tr> + <tr> + <th scope="row">Clé secrète</th> + <td> + <a href="#" onclick="document.getElementById('secret_key').style.display='inline'; this.style.display='none'">[Afficher la clé secrète]</a> + <span id="secret_key" style="display: none">{{ .View.SecretAccessKey }}</span> + </td> + </tr> + <tr> + <th scope="row">Région</th> + <td>garage</td> + </tr> + <tr> + <th scope="row">Endpoint URL</th> + <td>https://garage.deuxfleurs.fr</td> + </tr> + <tr> + <th scope="row">Type d'URL</th> + <td>DNS et chemin (préférer chemin)</td> + </tr> + <tr> + <th scope="row">Signature</th> + <td>Version 4</td> + </tr> + </tbody> + </table> + + <p>Configurer votre logiciel :</p> + + <div class="accordion" id="softconfig"> + <div class="card"> + <div class="card-header" id="awscli-title"> + <h2 class="mb-0"> + <button class="btn btn-link btn-block text-left collapsed" type="button" data-toggle="collapse" data-target="#awscli" aria-expanded="false" aria-controls="awscli"> + awscli (tout générateur de site statique) + </button> + </h2> + </div> + <div id="awscli" class="collapse show" aria-labelledby="awscli-title" data-parent="#softconfig"> + <div class="card-body"> + <p>Lancez la commande :</p> + <pre>aws --profile {{ .View.Name.Pretty }} configure</pre> + + <p>Entrez les informations suivantes quand elles vous sont demandées :</p> + <dl> + <dt>AWS Access Key ID [None]:</dt><dd>{{ .View.AccessKeyId }}</dd> + <dt>AWS Secret Access Key [None]:</dt><dd><a href="#" onclick="document.getElementById('aws_secret_key').style.display='inline'; this.style.display='none'">[Afficher la clé secrète]</a><span id="aws_secret_key" style="display: none">{{ .View.SecretAccessKey }}</span></dd> + <dt>Default region name [None]:</dt> <dd>garage</dd> + <dt>Default output format [None]:</dt> <dd>(laissez vide et appuyez sur entrée)</dd> + </dl> + + <p>Finalisez la configuration :</p> + <pre>aws --profile {{ .View.Name.Pretty }} configure set endpoint_url https://garage.deuxfleurs.fr</pre> + + + <p>Pour déployer votre dossier local <code>public</code> lancez :</p> + <pre> +aws --profile {{ .View.Name.Pretty }} s3 sync ./public s3://{{ .View.Name.Pretty }} + </pre> + </div> + </div> + </div> + + <div class="card"> + <div class="card-header" id="minio-title"> + <h2 class="mb-0"> + <button class="btn btn-link btn-block text-left" type="button" data-toggle="collapse" data-target="#minio" aria-expanded="true" aria-controls="minio"> + Minio CLI (tout générateur de site statique) + </button> + </h2> + </div> + + <div id="minio" class="collapse" aria-labelledby="minio-title" data-parent="#softconfig"> + <div class="card-body"> + <p>Vous pouvez configurer Minio CLI avec cette commande :</p> + <pre> +mc alias set \ + {{ .View.Name.Pretty }} \ + https://garage.deuxfleurs.fr \ + {{ .View.AccessKeyId }} \ + <a href="#" onclick="document.getElementById('minio_secret_key').style.display='inline'; this.style.display='none'">[Afficher la clé secrète]</a><span id="minio_secret_key" style="display: none">{{ .View.SecretAccessKey }}</span> \ + --api S3v4 + </pre> + <p>Et ensuite copiez votre site web avec la sous-commande mirror de Minio CLI :</p> + <pre> +mc mirror --overwrite ./public/ {{ .View.Name.Pretty }}/ + </pre> + </div> + </div> + </div> + + <div class="card"> + <div class="card-header" id="hugo-title"> + <h2 class="mb-0"> + <button class="btn btn-link btn-block text-left collapsed" type="button" data-toggle="collapse" data-target="#hugo" aria-expanded="false" aria-controls="hugo"> + Hugo + </button> + </h2> + </div> + <div id="hugo" class="collapse" aria-labelledby="hugo-title" data-parent="#softconfig"> + <div class="card-body"> + <p>Créez un fichier nommé <code>.deployment.secrets</code> (ne commitez pas ce fichier dans votre dépôt !) :</p> + <pre> +export AWS_ACCESS_KEY_ID={{ .View.AccessKeyId }} +export AWS_SECRET_ACCESS_KEY=<a href="#" onclick="document.getElementById('ugo_secret_key').style.display='inline'; this.style.display='none'">[Afficher la clé secrète]</a><span id="hugo_secret_key" style="display: none">{{ .View.SecretAccessKey }}</span> + </pre> + <p>Dans votre fichier de configuration Hugo <code>config.toml</code> (que vous pouvez commiter), rajoutez :</p> + <pre> +[[deployment.targets]] + URL = "s3://bucket?endpoint=garage.deuxfleurs.fr&s3ForcePathStyle=true&region=garage" + </pre> + + <p>Pour déployer, sourcez le fichier de configuration et laissez hugo faire : </p> + <pre> +source .deployment.secrets +hugo deploy + </pre> + </div> + </div> + </div> + </div> + </div> + <div class="tab-pane fade" id="sftp" role="tabpanel" aria-labelledby="sftp-tab"> + <br> + <div class="alert alert-danger" role="alert"> + N'automatisez pas votre déploiement en SFTP car vous risqueriez de faire fuiter votre mot de passe.<br> + Pour toute forme d'automatisation, préférez le protocole S3. + </div> + <div class="alert alert-warning" role="alert"> + L'algorithme de clé utilisé par le serveur est désactivé par défaut sur les clients SSH récents.<br> + Vous devez rajouter l'option -oHostKeyAlgorithms=+ssh-rsa pour vous connecter. + </div> + <table class="table mt-4"> + <tbody> + <tr> + <th scope="row">Nom d'utilisateur-ice</th> + <td>{{ .Describe.Username }}</td> + </tr> + <tr> + <th scope="row">Mot de passe</th> + <td>(votre mot de passe guichet)</td> + </tr> + <tr> + <th scope="row">Hôte</th> + <td>sftp://sftp.deuxfleurs.fr</td> + </tr> + <tr> + <th scope="row">Port</th> + <td>2222</td> + </tr> + </tbody> + </table> + <p>Configurez votre logiciel :</p> + + <div class="accordion" id="softconfig2"> + <div class="card"> + <div class="card-header" id="scp-title"> + <h2 class="mb-0"> + <button class="btn btn-link btn-block text-left collapsed" type="button" data-toggle="collapse" data-target="#filezilla" aria-expanded="false" aria-controls="filezilla"> + scp + </button> + </h2> + </div> + <div id="filezilla" class="collapse show" aria-labelledby="scp-title" data-parent="#softconfig2"> + <div class="card-body"> + <p>Déployer le dossier local <em>public</em> sur le site web {{ .View.Name.Pretty }} :</p> + <pre> +scp -oHostKeyAlgorithms=+ssh-rsa -P2222 -r ./public {{ .Describe.Username }}@sftp.deuxfleurs.fr:{{ .View.Name.Pretty }}/ + </pre> + </div> + </div> + </div> + <div class="card"> + <div class="card-header" id="filezilla-title"> + <h2 class="mb-0"> + <button class="btn btn-link btn-block text-left collapsed" type="button" data-toggle="collapse" data-target="#filezilla" aria-expanded="false" aria-controls="filezilla"> + Filezilla + </button> + </h2> + </div> + <div id="filezilla" class="collapse" aria-labelledby="filezilla-title" data-parent="#softconfig2"> + <div class="card-body"> + <p>Dans la barre de connexion rapide du haut, entrez :</p> + <dl> + <dt>Hôte</dt> <dd>sftp://sftp.deuxfleurs.fr</dd> + <dt>Nom d'utilisateur</dt> <dd>{{ .Describe.Username }}</dd> + <dt>Mot de passe</dt> <dd>(votre mot de passe guichet)</dd> + <dt>Port</dt> <dd>2222</dd> + </dl> + <p>Cliquez ensuite sur <strong>Connexion rapide</strong></p> + </div> + </div> + </div> + </div> + </div> + + <div class="tab-pane fade" id="dav" role="tabpanel" aria-labelledby="dav-tab"> + <br> + <div class="alert alert-danger" role="alert"> + N'automatisez pas votre déploiement en WebDAV car vous risqueriez de faire fuiter votre mot de passe.<br> + Pour toute forme d'automatisation, préférez le protocole S3. + </div> + + <table class="table mt-4"> + <tbody> + <tr> + <th scope="row">Nom d'utilisateur-ice</th> + <td>{{ .Describe.Username }}</td> + </tr> + <tr> + <th scope="row">Mot de passe</th> + <td>(votre mot de passe guichet)</td> + </tr> + <tr> + <th scope="row">Hôte</th> + <td>https://bagage.deuxfleurs.fr ou davs://bagage.deuxfleurs.fr</td> + </tr> + <tr> + <th scope="row">Port</th> + <td>443 (par défaut)</td> + </tr> + </tbody> + </table> + <p>Configurez votre logiciel :</p> + + <div class="accordion" id="softconfig3"> + <div class="card"> + <div class="card-header" id="drive-title"> + <h2 class="mb-0"> + <button class="btn btn-link btn-block text-left collapsed" type="button" data-toggle="collapse" data-target="#drive" aria-expanded="false" aria-controls="filezilla"> + Explorateur web + </button> + </h2> + </div> + <div id="drive" class="collapse show" aria-labelledby="drive-title" data-parent="#softconfig3"> + <div class="card-body"> + <p>Vous pouvez naviguer dans vos fichiers via l'explorateur web. + Utilisez simplement vos identifiants Guichet, l'explorateur est préconfiguré.</p> + + <p><a href="https://drive.deuxfleurs.fr">Accéder à l'explorateur</a></p> + </div> + </div> + </div> + </div> + </div> + + </div> </div> + + {{ if .View.Name.Expanded }} + <h5 class="mt-5">Vous ne savez pas comment configurer votre nom de domaine ?</h5> + <p> Le nom de domaine {{ .View.Name.Url }} n'est pas géré par Deuxfleurs, il vous revient donc de configurer la zone DNS. Vous devez ajouter une entrée <code>CNAME garage.deuxfleurs.fr</code> ou <code>ALIAS garage.deuxfleurs.fr</code> auprès de votre hébergeur DNS, qui est souvent aussi le bureau d'enregistrement (eg. Gandi, GoDaddy, BookMyName, etc.).</p> + {{ end }} + + </div> {{ end }} diff --git a/templates/home.html b/templates/home.html index dd88d13..3210a13 100644 --- a/templates/home.html +++ b/templates/home.html @@ -24,10 +24,9 @@ <div class="mt-3"> <div class="card"> <div class="card-header"> - Mon espace sur la toile + Mes services </div> <div class="list-group list-group-flush"> - <a class="list-group-item list-group-item-action" href="/website/configure">Mes identifiants</a> <a class="list-group-item list-group-item-action" href="/website">Mes sites Web</a> </div> </div> @@ -3,6 +3,7 @@ package main import ( "fmt" garage "git.deuxfleurs.fr/garage-sdk/garage-admin-sdk-golang" + "log" "sort" "strings" ) @@ -18,7 +19,9 @@ var ( ErrBucketDeleteNotEmpty = fmt.Errorf("You must remove all the files before deleting a bucket") ErrBucketDeleteUnfinishedUpload = fmt.Errorf("You must remove all the unfinished multipart uploads before deleting a bucket") ErrCantChangeVhost = fmt.Errorf("Can't change the vhost to the desired value. Maybe it's already used by someone else or an internal error occured") - ErrCantRemoveOldVhost = fmt.Errorf("The new vhost is bound to the bucket but the old one can't be removed, this is an internal error") + ErrCantRemoveOldVhost = fmt.Errorf("The new vhost is bound to the bucket but the old one can't be removed, it's an internal error") + ErrFetchDedicatedKey = fmt.Errorf("Bucket has no dedicated key while it's required, it's an internal error") + ErrDedicatedKeyInvariant = fmt.Errorf("A security invariant on the dedicated key has been violated, aborting.") ) type WebsiteId struct { @@ -49,8 +52,18 @@ func NewWebsiteIdFromBucketInfo(binfo *garage.BucketInfo) *WebsiteId { return NewWebsiteId(*binfo.Id, binfo.GlobalAliases) } +// ----- + +type WebsiteDescribe struct { + Username string `json:"username"` + AllowedWebsites *QuotaStat `json:"quota_website_count"` + BurstBucketQuotaSize string `json:"burst_bucket_quota_size"` + Websites []*WebsiteId `json:"vhosts"` +} + type WebsiteController struct { User *LoggedUser + RootKey *garage.KeyInfo WebsiteIdx map[string]*WebsiteId PrettyList []string WebsiteCount QuotaStat @@ -77,33 +90,154 @@ func NewWebsiteController(user *LoggedUser) (*WebsiteController, error) { maxW := user.Quota.WebsiteCount quota := NewQuotaStat(int64(len(wlist)), maxW, true) - return &WebsiteController{user, idx, wlist, quota}, nil + return &WebsiteController{user, keyInfo, idx, wlist, quota}, nil } -type WebsiteDescribe struct { - AccessKeyId string `json:"access_key_id"` - SecretAccessKey string `json:"secret_access_key"` - AllowedWebsites *QuotaStat `json:"quota_website_count"` - BurstBucketQuotaSize string `json:"burst_bucket_quota_size"` - Websites []*WebsiteId `json:"vhosts"` +func (w *WebsiteController) getDedicatedWebsiteKey(binfo *garage.BucketInfo) (*garage.KeyInfo, error) { + // Check bucket info is not null + if binfo == nil { + return nil, ErrFetchBucketInfo + } + + // Check the bucket is owned by the user's root key + usersRootKeyFound := false + for _, bucketKeyInfo := range binfo.Keys { + if *bucketKeyInfo.AccessKeyId == *w.RootKey.AccessKeyId && *bucketKeyInfo.Permissions.Owner { + usersRootKeyFound = true + break + } + } + if !usersRootKeyFound { + log.Printf("%s is not an owner of bucket %s. Invariant violated.\n", w.User.Username, *binfo.Id) + return nil, ErrDedicatedKeyInvariant + } + + // Check that username does not contain a ":" (should not be possible due to the invitation regex) + // We do this check as ":" is used as a separator + if strings.Contains(w.User.Username, ":") || w.User.Username == "" || *binfo.Id == "" { + log.Printf("Username (%s) or bucket identifier (%s) is invalid. Invariant violated.\n", w.User.Username, *binfo.Id) + return nil, ErrDedicatedKeyInvariant + } + + // Build the string template by concatening the username and the bucket identifier + dedicatedKeyName := fmt.Sprintf("%s:web:%s", w.User.Username, *binfo.Id) + + // Try to fetch the dedicated key + keyInfo, err := grgSearchKey(dedicatedKeyName) + if err != nil { + // On error, try to create it. + // @FIXME we should try to create only on 404 Not Found errors + keyInfo, err = grgCreateKey(dedicatedKeyName) + if err != nil { + // On error again, abort + return nil, err + } + log.Printf("Created dedicated key %s\n", dedicatedKeyName) + } + + // Check that the key name is *exactly* the one we requested + if *keyInfo.Name != dedicatedKeyName { + log.Printf("Expected key: %s, got %s. Invariant violated.\n", dedicatedKeyName, *keyInfo.Name) + return nil, ErrDedicatedKeyInvariant + } + + // Check that the dedicated key does not contain any other bucket than this one + // and report if this bucket key is found with correct permissions + permissionsOk := false + for _, buck := range keyInfo.Buckets { + if *buck.Id != *binfo.Id { + log.Printf("Key %s is used on bucket %s while it should be exclusive to %s. Invariant violated.\n", dedicatedKeyName, *buck.Id, *binfo.Id) + return nil, ErrDedicatedKeyInvariant + } + if *buck.Id == *binfo.Id && *buck.Permissions.Read && *buck.Permissions.Write { + permissionsOk = true + } + } + + // Allow this bucket on the key if it's not already the case + // (will be executed when 1) key is first created and 2) as an healing mechanism) + if !permissionsOk { + binfo, err = grgAllowKeyOnBucket(*binfo.Id, *keyInfo.AccessKeyId, true, true, false) + if err != nil { + return nil, err + } + log.Printf("Key %s was not properly allowed on bucket %s, fixing permissions. Intended behavior.", dedicatedKeyName, *binfo.Id) + + // Refresh the key to have an object with proper permissions + keyInfo, err = grgGetKey(*keyInfo.AccessKeyId) + if err != nil { + return nil, err + } + } + + // Return the key + return keyInfo, nil } -func (w *WebsiteController) Describe() (*WebsiteDescribe, error) { - s3key, err := w.User.S3KeyInfo() +func (w *WebsiteController) flushDedicatedWebsiteKey(binfo *garage.BucketInfo) error { + // Check bucket info is not null + if binfo == nil { + return ErrFetchBucketInfo + } + + // Check the bucket is owned by the user's root key + usersRootKeyFound := false + for _, bucketKeyInfo := range binfo.Keys { + if *bucketKeyInfo.AccessKeyId == *w.RootKey.AccessKeyId && *bucketKeyInfo.Permissions.Owner { + usersRootKeyFound = true + break + } + } + if !usersRootKeyFound { + log.Printf("%s is not an owner of bucket %s. Invariant violated.\n", w.User.Username, *binfo.Id) + return ErrDedicatedKeyInvariant + } + + // Build the string template by concatening the username and the bucket identifier + dedicatedKeyName := fmt.Sprintf("%s:web:%s", w.User.Username, *binfo.Id) + + // Fetch the dedicated key + keyInfo, err := grgSearchKey(dedicatedKeyName) if err != nil { - return nil, err + return err + } + + // Check that the key name is *exactly* the one we requested + if *keyInfo.Name != dedicatedKeyName { + log.Printf("Expected key: %s, got %s. Invariant violated.\n", dedicatedKeyName, *keyInfo.Name) + return ErrDedicatedKeyInvariant + } + + // Check that the dedicated key contains no other bucket than this one + // (can also be empty, useful to heal a partially created key) + for _, buck := range keyInfo.Buckets { + if *buck.Id != *binfo.Id { + log.Printf("Key %s is used on bucket %s while it should be exclusive to %s. Invariant violated.\n", dedicatedKeyName, *buck.Id, *binfo.Id) + return ErrDedicatedKeyInvariant + } + } + + // Finally delete this key + err = grgDelKey(*keyInfo.AccessKeyId) + if err != nil { + return err } + log.Printf("Deleted dedicated key %s", dedicatedKeyName) + return nil +} +func (w *WebsiteController) Describe() (*WebsiteDescribe, error) { r := make([]*WebsiteId, 0, len(w.PrettyList)) for _, k := range w.PrettyList { r = append(r, w.WebsiteIdx[k]) } + return &WebsiteDescribe{ - *s3key.AccessKeyId, - *s3key.SecretAccessKey, + w.User.Username, &w.WebsiteCount, w.User.Quota.WebsiteSizeBurstedPretty(), - r}, nil + r, + }, nil } func (w *WebsiteController) Inspect(pretty string) (*WebsiteView, error) { @@ -117,7 +251,12 @@ func (w *WebsiteController) Inspect(pretty string) (*WebsiteView, error) { return nil, ErrFetchBucketInfo } - return NewWebsiteView(binfo), nil + dedicatedKey, err := w.getDedicatedWebsiteKey(binfo) + if err != nil { + return nil, err + } + + return NewWebsiteView(binfo, dedicatedKey) } func (w *WebsiteController) Patch(pretty string, patch *WebsitePatch) (*WebsiteView, error) { @@ -161,7 +300,19 @@ func (w *WebsiteController) Patch(pretty string, patch *WebsitePatch) (*WebsiteV } } - return NewWebsiteView(binfo), nil + if patch.RotateKey != nil && *patch.RotateKey { + err = w.flushDedicatedWebsiteKey(binfo) + if err != nil { + return nil, err + } + } + + dedicatedKey, err := w.getDedicatedWebsiteKey(binfo) + if err != nil { + return nil, err + } + + return NewWebsiteView(binfo, dedicatedKey) } func (w *WebsiteController) Create(pretty string) (*WebsiteView, error) { @@ -173,21 +324,24 @@ func (w *WebsiteController) Create(pretty string) (*WebsiteView, error) { return nil, ErrWebsiteQuotaReached } + // Create bucket binfo, err := grgCreateBucket(pretty) if err != nil { return nil, ErrCantCreateBucket } + // Allow user's global key on bucket s3key, err := w.User.S3KeyInfo() if err != nil { return nil, err } - binfo, err = grgAllowKeyOnBucket(*binfo.Id, *s3key.AccessKeyId) + binfo, err = grgAllowKeyOnBucket(*binfo.Id, *s3key.AccessKeyId, true, true, true) if err != nil { return nil, ErrCantAllowKey } + // Set quota qr := w.User.Quota.DefaultWebsiteQuota() wr := allowWebsiteDefault() @@ -200,7 +354,13 @@ func (w *WebsiteController) Create(pretty string) (*WebsiteView, error) { return nil, ErrCantConfigureBucket } - return NewWebsiteView(binfo), nil + // Create a dedicated key + dedicatedKey, err := w.getDedicatedWebsiteKey(binfo) + if err != nil { + return nil, err + } + + return NewWebsiteView(binfo, dedicatedKey) } func (w *WebsiteController) Delete(pretty string) error { @@ -213,6 +373,7 @@ func (w *WebsiteController) Delete(pretty string) error { return ErrWebsiteNotFound } + // Error checking binfo, err := grgGetBucket(website.Internal) if err != nil { return ErrFetchBucketInfo @@ -226,26 +387,49 @@ func (w *WebsiteController) Delete(pretty string) error { return ErrBucketDeleteUnfinishedUpload } + // Delete dedicated key + err = w.flushDedicatedWebsiteKey(binfo) + if err != nil { + return err + } + + // Actually delete bucket err = grgDeleteBucket(website.Internal) return err } type WebsiteView struct { - Name *WebsiteId `json:"vhost"` - Size QuotaStat `json:"quota_size"` - Files QuotaStat `json:"quota_files"` + Name *WebsiteId `json:"vhost"` + AccessKeyId string `json:"access_key_id"` + SecretAccessKey string `json:"secret_access_key"` + Size QuotaStat `json:"quota_size"` + Files QuotaStat `json:"quota_files"` } -func NewWebsiteView(binfo *garage.BucketInfo) *WebsiteView { +func NewWebsiteView(binfo *garage.BucketInfo, s3key *garage.KeyInfo) (*WebsiteView, error) { + if binfo == nil { + return nil, ErrFetchBucketInfo + } + if s3key == nil { + return nil, ErrFetchDedicatedKey + } + q := binfo.GetQuotas() wid := NewWebsiteIdFromBucketInfo(binfo) size := NewQuotaStat(*binfo.Bytes, (&q).GetMaxSize(), true) objects := NewQuotaStat(*binfo.Objects, (&q).GetMaxObjects(), false) - return &WebsiteView{wid, size, objects} + return &WebsiteView{ + wid, + *s3key.AccessKeyId, + *s3key.SecretAccessKey.Get(), + size, + objects, + }, nil } type WebsitePatch struct { - Size *int64 `json:"quota_size"` - Vhost *string `json:"vhost"` + Size *int64 `json:"quota_size"` + Vhost *string `json:"vhost"` + RotateKey *bool `json:"rotate_key"` } diff --git a/webui_website.go b/webui_website.go new file mode 100644 index 0000000..642c837 --- /dev/null +++ b/webui_website.go @@ -0,0 +1,178 @@ +package main + +import ( + "fmt" + "github.com/gorilla/mux" + "net/http" + "strings" +) + +// --- Start page rendering functions +func handleWebsiteList(w http.ResponseWriter, r *http.Request) { + user := RequireUserHtml(w, r) + if user == nil { + return + } + + ctrl, err := NewWebsiteController(user) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if len(ctrl.PrettyList) > 0 { + http.Redirect(w, r, "/website/inspect/"+ctrl.PrettyList[0], http.StatusFound) + } else { + http.Redirect(w, r, "/website/new", http.StatusFound) + } +} + +type WebsiteNewTpl struct { + Ctrl *WebsiteController + Err error +} + +func handleWebsiteNew(w http.ResponseWriter, r *http.Request) { + user := RequireUserHtml(w, r) + if user == nil { + return + } + + ctrl, err := NewWebsiteController(user) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + tpl := &WebsiteNewTpl{ctrl, nil} + + tWebsiteNew := getTemplate("garage_website_new.html") + if r.Method == "POST" { + r.ParseForm() + + bucket := strings.Join(r.Form["bucket"], "") + if bucket == "" { + bucket = strings.Join(r.Form["bucket2"], "") + } + + view, err := ctrl.Create(bucket) + if err != nil { + tpl.Err = err + tWebsiteNew.Execute(w, tpl) + return + } + + http.Redirect(w, r, "/website/inspect/"+view.Name.Pretty, http.StatusFound) + return + } + + tWebsiteNew.Execute(w, tpl) +} + +type WebsiteInspectTpl struct { + Describe *WebsiteDescribe + View *WebsiteView + Err error +} + +func handleWebsiteInspect(w http.ResponseWriter, r *http.Request) { + var processErr error + + user := RequireUserHtml(w, r) + if user == nil { + return + } + + ctrl, err := NewWebsiteController(user) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + bucketName := mux.Vars(r)["bucket"] + + if r.Method == "POST" { + r.ParseForm() + action := strings.Join(r.Form["action"], "") + switch action { + case "increase_quota": + _, processErr = ctrl.Patch(bucketName, &WebsitePatch{Size: &user.Quota.WebsiteSizeBursted}) + case "delete_bucket": + processErr = ctrl.Delete(bucketName) + if processErr == nil { + http.Redirect(w, r, "/website", http.StatusFound) + } + case "rotate_key": + do_action := true + _, processErr = ctrl.Patch(bucketName, &WebsitePatch{RotateKey: &do_action}) + default: + processErr = fmt.Errorf("Unknown action") + } + + } + + view, err := ctrl.Inspect(bucketName) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + describe, err := ctrl.Describe() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + tpl := &WebsiteInspectTpl{describe, view, processErr} + + tWebsiteInspect := getTemplate("garage_website_inspect.html") + tWebsiteInspect.Execute(w, &tpl) +} + +func handleWebsiteVhost(w http.ResponseWriter, r *http.Request) { + var processErr error + + user := RequireUserHtml(w, r) + if user == nil { + return + } + + ctrl, err := NewWebsiteController(user) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + bucketName := mux.Vars(r)["bucket"] + + if r.Method == "POST" { + r.ParseForm() + + bucket := strings.Join(r.Form["bucket"], "") + if bucket == "" { + bucket = strings.Join(r.Form["bucket2"], "") + } + + view, processErr := ctrl.Patch(bucketName, &WebsitePatch{Vhost: &bucket}) + if processErr == nil { + http.Redirect(w, r, "/website/inspect/"+view.Name.Pretty, http.StatusFound) + return + } + } + + view, err := ctrl.Inspect(bucketName) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + describe, err := ctrl.Describe() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + tpl := &WebsiteInspectTpl{describe, view, processErr} + tWebsiteEdit := getTemplate("garage_website_edit.html") + tWebsiteEdit.Execute(w, &tpl) +} |