mirror of
https://github.com/goharbor/harbor.git
synced 2025-02-10 08:51:36 +01:00
Merge remote-tracking branch 'upstream/job-service' into job-service
This commit is contained in:
commit
c7a7eb29a0
1
.gitignore
vendored
1
.gitignore
vendored
@ -4,3 +4,4 @@ Deploy/config/ui/env
|
||||
Deploy/config/ui/app.conf
|
||||
Deploy/config/db/env
|
||||
Deploy/harbor.cfg
|
||||
ui/ui
|
||||
|
3
AUTHORS
3
AUTHORS
@ -1,5 +1,6 @@
|
||||
# This file lists all individuals having contributed content to the repository.
|
||||
|
||||
Alexander Zeitler <alexander.zeitler at pdmlab.com>
|
||||
Amanda Zhang <amzhang at vmware.com>
|
||||
Benniu Ji <benniuji at gmail.com>
|
||||
Bobby Zhang <junzhang at vmware.com>
|
||||
@ -9,8 +10,8 @@ Haining Henry Zhang <henryzhang at vmware.com>
|
||||
Hao Xia <haox at vmware.com>
|
||||
Jack Liu <ljack at vmware.com>
|
||||
Kun Wang <kunw at vmware.com>
|
||||
Peng Zhao <zhaopeng1988 at gmail.com>
|
||||
Shan Zhu <zhus at vmware.com>
|
||||
Victoria Zheng <vzheng at vmware.com>
|
||||
Wenkai Yin <yinw at vmware.com>
|
||||
Yan Wang <wangyan at vmware.com>
|
||||
|
||||
|
3
Deploy/kubernetes/dockerfiles/proxy-dockerfile
Normal file
3
Deploy/kubernetes/dockerfiles/proxy-dockerfile
Normal file
@ -0,0 +1,3 @@
|
||||
FROM library/nginx:1.9
|
||||
|
||||
ADD ./config/nginx /etc/nginx
|
33
Deploy/kubernetes/dockerfiles/registry-config.yml
Normal file
33
Deploy/kubernetes/dockerfiles/registry-config.yml
Normal file
@ -0,0 +1,33 @@
|
||||
version: 0.1
|
||||
log:
|
||||
level: debug
|
||||
fields:
|
||||
service: registry
|
||||
storage:
|
||||
cache:
|
||||
layerinfo: inmemory
|
||||
filesystem:
|
||||
rootdirectory: /storage
|
||||
maintenance:
|
||||
uploadpurging:
|
||||
enabled: false
|
||||
http:
|
||||
addr: :5000
|
||||
secret: placeholder
|
||||
debug:
|
||||
addr: localhost:5001
|
||||
auth:
|
||||
token:
|
||||
issuer: registry-token-issuer
|
||||
realm: http://harbor.caicloud.io/service/token
|
||||
rootcertbundle: /etc/registry/root.crt
|
||||
service: token-service
|
||||
|
||||
notifications:
|
||||
endpoints:
|
||||
- name: harbor
|
||||
disabled: false
|
||||
url: http://harbor.caicloud.io/service/notifications
|
||||
timeout: 500
|
||||
threshold: 5
|
||||
backoff: 1000
|
6
Deploy/kubernetes/dockerfiles/registry-dockerfile
Normal file
6
Deploy/kubernetes/dockerfiles/registry-dockerfile
Normal file
@ -0,0 +1,6 @@
|
||||
FROM library/registry:2.3.0
|
||||
|
||||
ADD ./config/registry/ /etc/registry/
|
||||
ADD ./kubernetes/dockerfiles/registry-config.yml /etc/registry/config.yml
|
||||
|
||||
CMD ["/etc/registry/config.yml"]
|
4
Deploy/kubernetes/dockerfiles/ui-dockerfile
Normal file
4
Deploy/kubernetes/dockerfiles/ui-dockerfile
Normal file
@ -0,0 +1,4 @@
|
||||
FROM deploy_ui
|
||||
|
||||
ADD ./config/ui/app.conf /etc/ui/app.conf
|
||||
ADD ./config/ui/private_key.pem /etc/ui/private_key.pem
|
30
Deploy/kubernetes/mysql-rc.yaml
Normal file
30
Deploy/kubernetes/mysql-rc.yaml
Normal file
@ -0,0 +1,30 @@
|
||||
apiVersion: v1
|
||||
kind: ReplicationController
|
||||
metadata:
|
||||
name: mysql
|
||||
labels:
|
||||
name: mysql
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
name: mysql
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
name: mysql
|
||||
spec:
|
||||
containers:
|
||||
- name: mysql
|
||||
image: caicloud/harbor_deploy_mysql:latest
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 3306
|
||||
env:
|
||||
- name: MYSQL_ROOT_PASSWORD
|
||||
value: root123
|
||||
volumeMounts:
|
||||
- name: mysql-storage
|
||||
mountPath: /var/lib/mysql
|
||||
volumes:
|
||||
- name: mysql-storage
|
||||
emptyDir: {}
|
11
Deploy/kubernetes/mysql-svc.yaml
Normal file
11
Deploy/kubernetes/mysql-svc.yaml
Normal file
@ -0,0 +1,11 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: mysql
|
||||
labels:
|
||||
name: mysql
|
||||
spec:
|
||||
ports:
|
||||
- port: 3306
|
||||
selector:
|
||||
name: mysql
|
22
Deploy/kubernetes/proxy-rc.yaml
Normal file
22
Deploy/kubernetes/proxy-rc.yaml
Normal file
@ -0,0 +1,22 @@
|
||||
apiVersion: v1
|
||||
kind: ReplicationController
|
||||
metadata:
|
||||
name: proxy
|
||||
labels:
|
||||
name: proxy
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
name: proxy
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
name: proxy
|
||||
spec:
|
||||
containers:
|
||||
- name: proxy
|
||||
image: caicloud/harbor_proxy:latest
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 80
|
||||
- containerPort: 443
|
15
Deploy/kubernetes/proxy-svc.yaml
Normal file
15
Deploy/kubernetes/proxy-svc.yaml
Normal file
@ -0,0 +1,15 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: proxy
|
||||
labels:
|
||||
name: proxy
|
||||
spec:
|
||||
type: LoadBalancer
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
- name: https
|
||||
port: 443
|
||||
selector:
|
||||
name: proxy
|
28
Deploy/kubernetes/registry-rc.yaml
Normal file
28
Deploy/kubernetes/registry-rc.yaml
Normal file
@ -0,0 +1,28 @@
|
||||
apiVersion: v1
|
||||
kind: ReplicationController
|
||||
metadata:
|
||||
name: registry
|
||||
labels:
|
||||
name: registry
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
name: registry
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
name: registry
|
||||
spec:
|
||||
containers:
|
||||
- name: registry
|
||||
image: caicloud/harbor_registry:2.3.0
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 5000
|
||||
- containerPort: 5001
|
||||
volumeMounts:
|
||||
- name: storage
|
||||
mountPath: /storage
|
||||
volumes:
|
||||
- name: storage
|
||||
emptyDir: {}
|
14
Deploy/kubernetes/registry-svc.yaml
Normal file
14
Deploy/kubernetes/registry-svc.yaml
Normal file
@ -0,0 +1,14 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: registry
|
||||
labels:
|
||||
name: registry
|
||||
spec:
|
||||
ports:
|
||||
- name: internal
|
||||
port: 5000
|
||||
- name: external
|
||||
port: 5001
|
||||
selector:
|
||||
name: registry
|
49
Deploy/kubernetes/ui-rc.yaml
Normal file
49
Deploy/kubernetes/ui-rc.yaml
Normal file
@ -0,0 +1,49 @@
|
||||
apiVersion: v1
|
||||
kind: ReplicationController
|
||||
metadata:
|
||||
name: ui
|
||||
labels:
|
||||
name: ui
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
name: ui
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
name: ui
|
||||
spec:
|
||||
containers:
|
||||
- name: ui
|
||||
image: caicloud/harbor_deploy_ui:latest
|
||||
imagePullPolicy: Always
|
||||
env:
|
||||
- name: MYSQL_HOST
|
||||
value: mysql
|
||||
- name: MYSQL_PORT
|
||||
value: "3306"
|
||||
- name: MYSQL_USR
|
||||
value: root
|
||||
- name: MYSQL_PWD
|
||||
value: root123
|
||||
- name: REGISTRY_URL
|
||||
value: http://registry:5000
|
||||
- name: CONFIG_PATH
|
||||
value: /etc/ui/app.conf
|
||||
- name: HARBOR_REG_URL
|
||||
value: localhost
|
||||
- name: HARBOR_ADMIN_PASSWORD
|
||||
value: Harbor12345
|
||||
- name: HARBOR_URL
|
||||
value: http://localhost
|
||||
- name: AUTH_MODE
|
||||
value: db_auth
|
||||
- name: LDAP_URL
|
||||
value: ldaps://ldap.mydomain.com
|
||||
- name: LDAP_BASE_DN
|
||||
value: uid=%s,ou=people,dc=mydomain,dc=com
|
||||
- name: LOG_LEVEL
|
||||
value: debug
|
||||
ports:
|
||||
- containerPort: 80
|
||||
|
11
Deploy/kubernetes/ui-svc.yaml
Normal file
11
Deploy/kubernetes/ui-svc.yaml
Normal file
@ -0,0 +1,11 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: ui
|
||||
labels:
|
||||
name: ui
|
||||
spec:
|
||||
ports:
|
||||
- port: 80
|
||||
selector:
|
||||
name: ui
|
@ -1,34 +1,43 @@
|
||||
#!/usr/bin/python
|
||||
|
||||
import ConfigParser
|
||||
import StringIO
|
||||
import os
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import print_function, unicode_literals # We require Python 2.6 or later
|
||||
from string import Template
|
||||
import os
|
||||
import sys
|
||||
from io import open
|
||||
|
||||
if sys.version_info[:3][0] == 2:
|
||||
import ConfigParser as ConfigParser
|
||||
import StringIO as StringIO
|
||||
|
||||
if sys.version_info[:3][0] == 3:
|
||||
import configparser as ConfigParser
|
||||
import io as StringIO
|
||||
|
||||
#Read configurations
|
||||
conf = StringIO.StringIO()
|
||||
conf.write("[configuration]\n")
|
||||
conf.write(open("harbor.cfg").read())
|
||||
conf.seek(0, os.SEEK_SET)
|
||||
cp = ConfigParser.RawConfigParser()
|
||||
cp.readfp(conf)
|
||||
rcp = ConfigParser.RawConfigParser()
|
||||
rcp.readfp(conf)
|
||||
|
||||
hostname = cp.get("configuration", "hostname")
|
||||
ui_url = cp.get("configuration", "ui_url_protocol") + "://" + hostname
|
||||
email_server = cp.get("configuration", "email_server")
|
||||
email_server_port = cp.get("configuration", "email_server_port")
|
||||
email_username = cp.get("configuration", "email_username")
|
||||
email_password = cp.get("configuration", "email_password")
|
||||
email_from = cp.get("configuration", "email_from")
|
||||
harbor_admin_password = cp.get("configuration", "harbor_admin_password")
|
||||
auth_mode = cp.get("configuration", "auth_mode")
|
||||
ldap_url = cp.get("configuration", "ldap_url")
|
||||
ldap_basedn = cp.get("configuration", "ldap_basedn")
|
||||
db_password = cp.get("configuration", "db_password")
|
||||
self_registration = cp.get("configuration", "self_registration")
|
||||
hostname = rcp.get("configuration", "hostname")
|
||||
ui_url = rcp.get("configuration", "ui_url_protocol") + "://" + hostname
|
||||
email_server = rcp.get("configuration", "email_server")
|
||||
email_server_port = rcp.get("configuration", "email_server_port")
|
||||
email_username = rcp.get("configuration", "email_username")
|
||||
email_password = rcp.get("configuration", "email_password")
|
||||
email_from = rcp.get("configuration", "email_from")
|
||||
harbor_admin_password = rcp.get("configuration", "harbor_admin_password")
|
||||
auth_mode = rcp.get("configuration", "auth_mode")
|
||||
ldap_url = rcp.get("configuration", "ldap_url")
|
||||
ldap_basedn = rcp.get("configuration", "ldap_basedn")
|
||||
db_password = rcp.get("configuration", "db_password")
|
||||
self_registration = rcp.get("configuration", "self_registration")
|
||||
########
|
||||
|
||||
base_dir = os.path.dirname(__file__)
|
||||
base_dir = os.path.dirname(__file__)
|
||||
config_dir = os.path.join(base_dir, "config")
|
||||
templates_dir = os.path.join(base_dir, "templates")
|
||||
|
||||
@ -45,17 +54,17 @@ def render(src, dest, **kw):
|
||||
t = Template(open(src, 'r').read())
|
||||
with open(dest, 'w') as f:
|
||||
f.write(t.substitute(**kw))
|
||||
print "Generated configuration file: %s" % dest
|
||||
print("Generated configuration file: %s" % dest)
|
||||
|
||||
ui_conf_env = os.path.join(config_dir, "ui", "env")
|
||||
ui_conf = os.path.join(config_dir, "ui", "app.conf")
|
||||
ui_conf = os.path.join(config_dir, "ui", "app.conf")
|
||||
registry_conf = os.path.join(config_dir, "registry", "config.yml")
|
||||
db_conf_env = os.path.join(config_dir, "db", "env")
|
||||
|
||||
conf_files = [ ui_conf, ui_conf_env, registry_conf, db_conf_env ]
|
||||
for f in conf_files:
|
||||
if os.path.exists(f):
|
||||
print "Clearing the configuration file: %s" % f
|
||||
print("Clearing the configuration file: %s" % f)
|
||||
os.remove(f)
|
||||
|
||||
render(os.path.join(templates_dir, "ui", "env"),
|
||||
@ -86,4 +95,4 @@ render(os.path.join(templates_dir, "db", "env"),
|
||||
db_conf_env,
|
||||
db_password=db_password)
|
||||
|
||||
print "The configuration files are ready, please use docker-compose to start the service."
|
||||
print("The configuration files are ready, please use docker-compose to start the service.")
|
||||
|
@ -11,6 +11,8 @@ storage:
|
||||
maintenance:
|
||||
uploadpurging:
|
||||
enabled: false
|
||||
delete:
|
||||
enabled: true
|
||||
http:
|
||||
addr: :5000
|
||||
secret: placeholder
|
||||
|
@ -46,7 +46,7 @@ The host must be connected to the Internet.
|
||||
|
||||
If everything works fine, you can open a browser to visit the admin portal at http://reg.yourdomain.com . The default administrator username and password are admin/Harbor12345 .
|
||||
|
||||
Create a new project, e.g. myproject, in the admin portal. You can then use docker commands to login and push images. The default port of Harbor registry server is 80:
|
||||
Log in to the admin portal and create a new project, e.g. myproject. You can then use docker commands to login and push images. The default port of Harbor registry server is 80:
|
||||
```sh
|
||||
$ docker login reg.yourdomain.com
|
||||
$ docker push reg.yourdomain.com/myproject/myrepo
|
||||
@ -57,6 +57,9 @@ To simplify the installation process, a pre-built installation package of Harbor
|
||||
|
||||
For information on how to use Harbor, please see [User Guide](docs/user_guide.md) .
|
||||
|
||||
### Deploy harbor on Kubernetes
|
||||
Detailed instruction about deploying harbor on Kubernetes is described [here](https://github.com/vmware/harbor/blob/master/kubernetes_deployment.md).
|
||||
|
||||
### Contribution
|
||||
We welcome contributions from the community. If you wish to contribute code and you have not signed our contributor license agreement (CLA), our bot will update the issue when you open a pull request. For any questions about the CLA process, please refer to our [FAQ](https://cla.vmware.com/faq).
|
||||
|
||||
@ -68,3 +71,6 @@ Harbor is available under the [Apache 2 license](LICENSE).
|
||||
|
||||
### Users
|
||||
<a href="https://www.madailicai.com/" border="0" target="_blank"><img alt="MaDaiLiCai" src="docs/img/UserMaDai.jpg"></a>
|
||||
|
||||
### Supporting Technologies
|
||||
<img alt="beego" src="docs/img/beegoLogo.png"> Harbor is powered by <a href="http://beego.me/">Beego</a>, an open source framework to build and develop applications in the Go way.
|
||||
|
@ -57,7 +57,10 @@ func (b *BaseAPI) ValidateUser() int {
|
||||
username, password, ok := b.Ctx.Request.BasicAuth()
|
||||
if ok {
|
||||
log.Infof("Requst with Basic Authentication header, username: %s", username)
|
||||
user, err := auth.Login(models.AuthModel{username, password})
|
||||
user, err := auth.Login(models.AuthModel{
|
||||
Principal: username,
|
||||
Password: password,
|
||||
})
|
||||
if err != nil {
|
||||
log.Errorf("Error while trying to login, username: %s, error: %v", username, err)
|
||||
user = nil
|
||||
|
@ -18,6 +18,7 @@ package api
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@ -26,6 +27,9 @@ import (
|
||||
"github.com/vmware/harbor/models"
|
||||
svc_utils "github.com/vmware/harbor/service/utils"
|
||||
"github.com/vmware/harbor/utils/log"
|
||||
"github.com/vmware/harbor/utils/registry"
|
||||
"github.com/vmware/harbor/utils/registry/auth"
|
||||
"github.com/vmware/harbor/utils/registry/errors"
|
||||
)
|
||||
|
||||
// RepositoryAPI handles request to /api/repositories /api/repositories/tags /api/repositories/manifests, the parm has to be put
|
||||
@ -36,6 +40,7 @@ type RepositoryAPI struct {
|
||||
BaseAPI
|
||||
userID int
|
||||
username string
|
||||
registry *registry.Registry
|
||||
}
|
||||
|
||||
// Prepare will set a non existent user ID in case the request tries to view repositories under a project he doesn't has permission.
|
||||
@ -53,6 +58,43 @@ func (ra *RepositoryAPI) Prepare() {
|
||||
} else {
|
||||
ra.username = username
|
||||
}
|
||||
|
||||
var client *http.Client
|
||||
|
||||
//no session, initialize a standard auth handler
|
||||
if ra.userID == dao.NonExistUserID && len(ra.username) == 0 {
|
||||
username, password, _ := ra.Ctx.Request.BasicAuth()
|
||||
|
||||
credential := auth.NewBasicAuthCredential(username, password)
|
||||
client = registry.NewClientStandardAuthHandlerEmbeded(credential)
|
||||
log.Debug("initializing standard auth handler")
|
||||
|
||||
} else {
|
||||
// session works, initialize a username auth handler
|
||||
username := ra.username
|
||||
if len(username) == 0 {
|
||||
user, err := dao.GetUser(models.User{
|
||||
UserID: ra.userID,
|
||||
})
|
||||
if err != nil {
|
||||
log.Errorf("error occurred whiling geting user for initializing a username auth handler: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
username = user.Username
|
||||
}
|
||||
|
||||
client = registry.NewClientUsernameAuthHandlerEmbeded(username)
|
||||
log.Debug("initializing username auth handler: %s", username)
|
||||
}
|
||||
|
||||
endpoint := os.Getenv("REGISTRY_URL")
|
||||
r, err := registry.New(endpoint, client)
|
||||
if err != nil {
|
||||
log.Fatalf("error occurred while initializing auth handler for repository API: %v", err)
|
||||
}
|
||||
|
||||
ra.registry = r
|
||||
}
|
||||
|
||||
// Get ...
|
||||
@ -77,11 +119,13 @@ func (ra *RepositoryAPI) Get() {
|
||||
ra.RenderError(http.StatusForbidden, "")
|
||||
return
|
||||
}
|
||||
|
||||
repoList, err := svc_utils.GetRepoFromCache()
|
||||
if err != nil {
|
||||
log.Errorf("Failed to get repo from cache, error: %v", err)
|
||||
ra.RenderError(http.StatusInternalServerError, "internal sever error")
|
||||
}
|
||||
|
||||
projectName := p.Name
|
||||
q := ra.GetString("q")
|
||||
var resp []string
|
||||
@ -105,6 +149,56 @@ func (ra *RepositoryAPI) Get() {
|
||||
ra.ServeJSON()
|
||||
}
|
||||
|
||||
// Delete ...
|
||||
func (ra *RepositoryAPI) Delete() {
|
||||
repoName := ra.GetString("repo_name")
|
||||
if len(repoName) == 0 {
|
||||
ra.CustomAbort(http.StatusBadRequest, "repo_name is nil")
|
||||
}
|
||||
|
||||
tags := []string{}
|
||||
tag := ra.GetString("tag")
|
||||
if len(tag) == 0 {
|
||||
tagList, err := ra.registry.ListTag(repoName)
|
||||
if err != nil {
|
||||
e, ok := errors.ParseError(err)
|
||||
if ok {
|
||||
log.Info(e)
|
||||
ra.CustomAbort(e.StatusCode, e.Message)
|
||||
} else {
|
||||
log.Error(err)
|
||||
ra.CustomAbort(http.StatusInternalServerError, "internal error")
|
||||
}
|
||||
|
||||
}
|
||||
tags = append(tags, tagList...)
|
||||
|
||||
} else {
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
|
||||
for _, t := range tags {
|
||||
if err := ra.registry.DeleteTag(repoName, t); err != nil {
|
||||
e, ok := errors.ParseError(err)
|
||||
if ok {
|
||||
ra.CustomAbort(e.StatusCode, e.Message)
|
||||
} else {
|
||||
log.Error(err)
|
||||
ra.CustomAbort(http.StatusInternalServerError, "internal error")
|
||||
}
|
||||
}
|
||||
log.Infof("delete tag: %s %s", repoName, t)
|
||||
}
|
||||
|
||||
go func() {
|
||||
log.Debug("refreshing catalog cache")
|
||||
if err := svc_utils.RefreshCatalogCache(); err != nil {
|
||||
log.Errorf("error occurred while refresh catalog cache: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
}
|
||||
|
||||
type tag struct {
|
||||
Name string `json:"name"`
|
||||
Tags []string `json:"tags"`
|
||||
@ -128,15 +222,19 @@ func (ra *RepositoryAPI) GetTags() {
|
||||
var tags []string
|
||||
|
||||
repoName := ra.GetString("repo_name")
|
||||
result, err := svc_utils.RegistryAPIGet(svc_utils.BuildRegistryURL(repoName, "tags", "list"), ra.username)
|
||||
|
||||
tags, err := ra.registry.ListTag(repoName)
|
||||
if err != nil {
|
||||
log.Errorf("Failed to get repo tags, repo name: %s, error: %v", repoName, err)
|
||||
ra.RenderError(http.StatusInternalServerError, "Failed to get repo tags")
|
||||
} else {
|
||||
t := tag{}
|
||||
json.Unmarshal(result, &t)
|
||||
tags = t.Tags
|
||||
e, ok := errors.ParseError(err)
|
||||
if ok {
|
||||
log.Info(e)
|
||||
ra.CustomAbort(e.StatusCode, e.Message)
|
||||
} else {
|
||||
log.Error(err)
|
||||
ra.CustomAbort(http.StatusInternalServerError, "internal error")
|
||||
}
|
||||
}
|
||||
|
||||
ra.Data["json"] = tags
|
||||
ra.ServeJSON()
|
||||
}
|
||||
@ -148,14 +246,20 @@ func (ra *RepositoryAPI) GetManifests() {
|
||||
|
||||
item := models.RepoItem{}
|
||||
|
||||
result, err := svc_utils.RegistryAPIGet(svc_utils.BuildRegistryURL(repoName, "manifests", tag), ra.username)
|
||||
_, _, payload, err := ra.registry.PullManifest(repoName, tag, registry.ManifestVersion1)
|
||||
if err != nil {
|
||||
log.Errorf("Failed to get manifests for repo, repo name: %s, tag: %s, error: %v", repoName, tag, err)
|
||||
ra.RenderError(http.StatusInternalServerError, "Internal Server Error")
|
||||
return
|
||||
e, ok := errors.ParseError(err)
|
||||
if ok {
|
||||
log.Info(e)
|
||||
ra.CustomAbort(e.StatusCode, e.Message)
|
||||
} else {
|
||||
log.Error(err)
|
||||
ra.CustomAbort(http.StatusInternalServerError, "internal error")
|
||||
}
|
||||
}
|
||||
|
||||
mani := manifest{}
|
||||
err = json.Unmarshal(result, &mani)
|
||||
err = json.Unmarshal(payload, &mani)
|
||||
if err != nil {
|
||||
log.Errorf("Failed to decode json from response for manifests, repo name: %s, tag: %s, error: %v", repoName, tag, err)
|
||||
ra.RenderError(http.StatusInternalServerError, "Internal Server Error")
|
||||
@ -169,7 +273,6 @@ func (ra *RepositoryAPI) GetManifests() {
|
||||
ra.RenderError(http.StatusInternalServerError, "Internal Server Error")
|
||||
return
|
||||
}
|
||||
item.CreatedStr = item.Created.Format("2006-01-02 15:04:05")
|
||||
item.DurationDays = strconv.Itoa(int(time.Since(item.Created).Hours()/24)) + " days"
|
||||
|
||||
ra.Data["json"] = item
|
||||
|
85
api/user.go
85
api/user.go
@ -17,7 +17,9 @@ package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/vmware/harbor/dao"
|
||||
"github.com/vmware/harbor/models"
|
||||
@ -27,13 +29,35 @@ import (
|
||||
// UserAPI handles request to /api/users/{}
|
||||
type UserAPI struct {
|
||||
BaseAPI
|
||||
currentUserID int
|
||||
userID int
|
||||
currentUserID int
|
||||
userID int
|
||||
SelfRegistration bool
|
||||
IsAdmin bool
|
||||
AuthMode string
|
||||
}
|
||||
|
||||
// Prepare validates the URL and parms
|
||||
func (ua *UserAPI) Prepare() {
|
||||
|
||||
authMode := strings.ToLower(os.Getenv("AUTH_MODE"))
|
||||
if authMode == "" {
|
||||
authMode = "db_auth"
|
||||
}
|
||||
ua.AuthMode = authMode
|
||||
|
||||
selfRegistration := strings.ToLower(os.Getenv("SELF_REGISTRATION"))
|
||||
if selfRegistration == "on" {
|
||||
ua.SelfRegistration = true
|
||||
}
|
||||
|
||||
if ua.Ctx.Input.IsPost() {
|
||||
sessionUserID := ua.GetSession("userId")
|
||||
_, _, ok := ua.Ctx.Request.BasicAuth()
|
||||
if sessionUserID == nil && !ok {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ua.currentUserID = ua.ValidateUser()
|
||||
id := ua.Ctx.Input.Param(":id")
|
||||
if id == "current" {
|
||||
@ -56,18 +80,20 @@ func (ua *UserAPI) Prepare() {
|
||||
ua.CustomAbort(http.StatusNotFound, "")
|
||||
}
|
||||
}
|
||||
|
||||
var err error
|
||||
ua.IsAdmin, err = dao.IsAdminRole(ua.currentUserID)
|
||||
if err != nil {
|
||||
log.Errorf("Error occurred in IsAdminRole:%v", err)
|
||||
ua.CustomAbort(http.StatusInternalServerError, "Internal error.")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Get ...
|
||||
func (ua *UserAPI) Get() {
|
||||
exist, err := dao.IsAdminRole(ua.currentUserID)
|
||||
if err != nil {
|
||||
log.Errorf("Error occurred in IsAdminRole, error: %v", err)
|
||||
ua.CustomAbort(http.StatusInternalServerError, "Internal error.")
|
||||
}
|
||||
|
||||
if ua.userID == 0 { //list users
|
||||
if !exist {
|
||||
if !ua.IsAdmin {
|
||||
log.Errorf("Current user, id: %d does not have admin role, can not list users", ua.currentUserID)
|
||||
ua.RenderError(http.StatusForbidden, "User does not have admin role")
|
||||
return
|
||||
@ -85,7 +111,7 @@ func (ua *UserAPI) Get() {
|
||||
}
|
||||
ua.Data["json"] = userList
|
||||
|
||||
} else if ua.userID == ua.currentUserID || exist {
|
||||
} else if ua.userID == ua.currentUserID || ua.IsAdmin {
|
||||
userQuery := models.User{UserID: ua.userID}
|
||||
u, err := dao.GetUser(userQuery)
|
||||
if err != nil {
|
||||
@ -103,12 +129,7 @@ func (ua *UserAPI) Get() {
|
||||
|
||||
// Put ...
|
||||
func (ua *UserAPI) Put() { //currently only for toggle admin, so no request body
|
||||
exist, err := dao.IsAdminRole(ua.currentUserID)
|
||||
if err != nil {
|
||||
log.Errorf("Error occurred in IsAdminRole, error: %v", err)
|
||||
ua.CustomAbort(http.StatusInternalServerError, "Internal error.")
|
||||
}
|
||||
if !exist {
|
||||
if !ua.IsAdmin {
|
||||
log.Warningf("current user, id: %d does not have admin role, can not update other user's role", ua.currentUserID)
|
||||
ua.RenderError(http.StatusForbidden, "User does not have admin role")
|
||||
return
|
||||
@ -117,18 +138,38 @@ func (ua *UserAPI) Put() { //currently only for toggle admin, so no request body
|
||||
dao.ToggleUserAdminRole(userQuery)
|
||||
}
|
||||
|
||||
// Post ...
|
||||
func (ua *UserAPI) Post() {
|
||||
|
||||
if !(ua.AuthMode == "db_auth") {
|
||||
ua.CustomAbort(http.StatusForbidden, "")
|
||||
}
|
||||
|
||||
if !(ua.SelfRegistration || ua.IsAdmin) {
|
||||
log.Warning("Registration can only be used by admin role user when self-registration is off.")
|
||||
ua.CustomAbort(http.StatusForbidden, "")
|
||||
}
|
||||
|
||||
user := models.User{}
|
||||
ua.DecodeJSONReq(&user)
|
||||
|
||||
_, err := dao.Register(user)
|
||||
if err != nil {
|
||||
log.Errorf("Error occurred in Register: %v", err)
|
||||
ua.RenderError(http.StatusInternalServerError, "Internal error.")
|
||||
return
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Delete ...
|
||||
func (ua *UserAPI) Delete() {
|
||||
exist, err := dao.IsAdminRole(ua.currentUserID)
|
||||
if err != nil {
|
||||
log.Errorf("Error occurred in IsAdminRole, error: %v", err)
|
||||
ua.CustomAbort(http.StatusInternalServerError, "Internal error.")
|
||||
}
|
||||
if !exist {
|
||||
if !ua.IsAdmin {
|
||||
log.Warningf("current user, id: %d does not have admin role, can not remove user", ua.currentUserID)
|
||||
ua.RenderError(http.StatusForbidden, "User does not have admin role")
|
||||
return
|
||||
}
|
||||
var err error
|
||||
err = dao.DeleteUser(ua.userID)
|
||||
if err != nil {
|
||||
log.Errorf("Failed to delete data from database, error: %v", err)
|
||||
|
@ -49,7 +49,10 @@ func (c *CommonController) Login() {
|
||||
principal := c.GetString("principal")
|
||||
password := c.GetString("password")
|
||||
|
||||
user, err := auth.Login(models.AuthModel{principal, password})
|
||||
user, err := auth.Login(models.AuthModel{
|
||||
Principal: principal,
|
||||
Password: password,
|
||||
})
|
||||
if err != nil {
|
||||
log.Errorf("Error occurred in UserLogin: %v", err)
|
||||
c.CustomAbort(http.StatusUnauthorized, "")
|
||||
|
@ -17,7 +17,6 @@ package controllers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/vmware/harbor/dao"
|
||||
"github.com/vmware/harbor/models"
|
||||
@ -65,33 +64,6 @@ func (ac *AddUserController) Get() {
|
||||
}
|
||||
}
|
||||
|
||||
// SignUp insert data into DB based on data in form.
|
||||
func (cc *CommonController) SignUp() {
|
||||
|
||||
if !(cc.AuthMode == "db_auth") {
|
||||
cc.CustomAbort(http.StatusForbidden, "")
|
||||
}
|
||||
|
||||
if !(cc.SelfRegistration || cc.IsAdmin) {
|
||||
log.Warning("Registration can only be used by admin role user when self-registration is off.")
|
||||
cc.CustomAbort(http.StatusForbidden, "")
|
||||
}
|
||||
|
||||
username := strings.TrimSpace(cc.GetString("username"))
|
||||
email := strings.TrimSpace(cc.GetString("email"))
|
||||
realname := strings.TrimSpace(cc.GetString("realname"))
|
||||
password := strings.TrimSpace(cc.GetString("password"))
|
||||
comment := strings.TrimSpace(cc.GetString("comment"))
|
||||
|
||||
user := models.User{Username: username, Email: email, Realname: realname, Password: password, Comment: comment}
|
||||
|
||||
_, err := dao.Register(user)
|
||||
if err != nil {
|
||||
log.Errorf("Error occurred in Register: %v", err)
|
||||
cc.CustomAbort(http.StatusInternalServerError, "Internal error.")
|
||||
}
|
||||
}
|
||||
|
||||
// UserExists checks if user exists when user input value in sign in form.
|
||||
func (cc *CommonController) UserExists() {
|
||||
target := cc.GetString("target")
|
||||
|
@ -82,7 +82,7 @@ func InitDB() {
|
||||
c.Close()
|
||||
ch <- 1
|
||||
} else {
|
||||
log.Info("failed to connect to db, retry after 2 seconds...")
|
||||
log.Errorf("failed to connect to db, retry after 2 seconds :%v", err)
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
}
|
||||
|
@ -206,7 +206,10 @@ func TestLoginByUserName(t *testing.T) {
|
||||
Password: "Abc12345",
|
||||
}
|
||||
|
||||
loginUser, err := LoginByDb(models.AuthModel{userQuery.Username, userQuery.Password})
|
||||
loginUser, err := LoginByDb(models.AuthModel{
|
||||
Principal: userQuery.Username,
|
||||
Password: userQuery.Password,
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("Error occurred in LoginByDb: %v", err)
|
||||
}
|
||||
@ -226,7 +229,10 @@ func TestLoginByEmail(t *testing.T) {
|
||||
Password: "Abc12345",
|
||||
}
|
||||
|
||||
loginUser, err := LoginByDb(models.AuthModel{userQuery.Email, userQuery.Password})
|
||||
loginUser, err := LoginByDb(models.AuthModel{
|
||||
Principal: userQuery.Email,
|
||||
Password: userQuery.Password,
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("Error occurred in LoginByDb: %v", err)
|
||||
}
|
||||
|
@ -58,6 +58,10 @@ func IsAdminRole(userIDOrUsername interface{}) (bool, error) {
|
||||
return false, fmt.Errorf("invalid parameter, only int and string are supported: %v", userIDOrUsername)
|
||||
}
|
||||
|
||||
if u.UserID == NonExistUserID && len(u.Username) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
user, err := GetUser(u)
|
||||
if err != nil {
|
||||
return false, err
|
||||
|
@ -109,7 +109,7 @@ func ListUsers(query models.User) ([]models.User, error) {
|
||||
return u, err
|
||||
}
|
||||
|
||||
// ToggleUserAdminRole gives a user admim role.
|
||||
// ToggleUserAdminRole gives a user admin role.
|
||||
func ToggleUserAdminRole(u models.User) error {
|
||||
o := orm.NewOrm()
|
||||
|
||||
|
@ -1,8 +1,8 @@
|
||||
#Configure Harbor with HTTPS Access
|
||||
#Configuring Harbor with HTTPS Access
|
||||
|
||||
Because Harbor does not ship with any certificates, it uses HTTP by default to serve registry requests. This makes it relatively simple to configure. However, it is highly recommended that security be enabled for any production environment. Harbor has an Nginx instance as a reverse proxy for all services, you can configure Nginx to enable https.
|
||||
|
||||
##Get a certificate
|
||||
##Getting a certificate
|
||||
|
||||
Assuming that your registry's **hostname** is **reg.yourdomain.com**, and that its DNS record points to the host where you are running Harbor. You first should get a certificate from a CA. The certificate usually contains a .crt file and a .key file, for example, **yourdomain.com.crt** and **yourdomain.com.key**.
|
||||
|
||||
@ -22,7 +22,7 @@ In a test or development environment, you may choose to use a self-signed certif
|
||||
```
|
||||
3) Generate the certificate of your registry host:
|
||||
|
||||
You need to configure openssl first. On Ubuntu, the config file locates at /etc/ssl/openssl.cnf. Refer to openssl document for more information. The default CA directory of openssl is called demoCA. Let's create necessary directories and files:
|
||||
You need to configure openssl first. On Ubuntu, the config file locates at **/etc/ssl/openssl.cnf**. Refer to openssl document for more information. The default CA directory of openssl is called demoCA. Let's create necessary directories and files:
|
||||
```
|
||||
mkdir demoCA
|
||||
cd demoCA
|
||||
@ -40,7 +40,11 @@ After obtaining the **yourdomain.com.crt** and **yourdomain.com.key** files, cha
|
||||
```
|
||||
cd Deploy/config/nginx
|
||||
```
|
||||
Create a new directory cert/, if it does not exist. Then copy **yourdomain.com.crt** and **yourdomain.com.key** to cert/.
|
||||
Create a new directory cert/, if it does not exist. Then copy **yourdomain.com.crt** and **yourdomain.com.key** to cert/, e.g. :
|
||||
```
|
||||
cp yourdomain.com.crt cert/
|
||||
cp yourdomain.com.key cert/
|
||||
```
|
||||
|
||||
Rename the existing configuration file of Nginx:
|
||||
```
|
||||
|
BIN
docs/img/beegoLogo.png
Normal file
BIN
docs/img/beegoLogo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.9 KiB |
@ -28,7 +28,7 @@ Before installing Harbor, you should configure the parameters in the file **harb
|
||||
At minimum, you need to change the **hostname** attribute in **harbor.cfg**. The description of each attribute is as follows:
|
||||
|
||||
**hostname**: The hostname for a user to access the user interface and the registry service. It should be the IP address or the fully qualified domain name (FQDN) of your target machine, for example 192.168.1.10 or reg.yourdomain.com . Do NOT use localhost or 127.0.0.1 for the hostname because the registry service needs to be accessed by external clients.
|
||||
**ui_url_protocol**: The protocol for accessing the user interface and the token/notification service, by default it is http.
|
||||
**ui_url_protocol**: The protocol for accessing the user interface and the token/notification service, by default it is http. To set up the https protocol, refer to [Configuring Harbor with HTTPS Access](configure_https.md).
|
||||
**Email settings**: the following 5 attributes are used to send an email to reset a user's password, they are not mandatory unless the password reset function is needed in Harbor.
|
||||
* email_server = smtp.mydomain.com
|
||||
* email_server_port = 25
|
||||
@ -40,8 +40,9 @@ At minimum, you need to change the **hostname** attribute in **harbor.cfg**. The
|
||||
**auth_mode**: The authentication mode of Harbor. By default it is *db_auth*, i.e. the credentials are stored in a database. Please set it to *ldap_auth* if you want to verify user's credentials against an LDAP server.
|
||||
**ldap_url**: The URL for LDAP endpoint, for example ldaps://ldap.mydomain.com. It is only used when **auth_mode** is set to *ldap_auth*.
|
||||
**ldap_basedn**: The basedn template for verifying the user's credentials against LDAP, for example uid=%s,ou=people,dc=mydomain,dc=com. It is only used when **auth_mode** is set to *ldap_auth*.
|
||||
**db_password**: The password of root user of mySQL database.
|
||||
**db_password**: The password of root user of mySQL database. Change this password for any production use.
|
||||
**self_registration**: The flag to turn on or off the user self-registration function. If this flag is turned off, only an admin user can create new users in Harbor. The default value is on.
|
||||
NOTE: When **auth_mode** is *ldap_auth*, the self-registration feature is always disabled, therefore, this flag is ignored.
|
||||
|
||||
#### Building and starting Harbor
|
||||
After configuring harbor.cfg, build and start Harbor by the following commands. Because it requires downloading necessary files from the Internet, it may take a while for the docker-compose process to finish.
|
||||
@ -61,7 +62,7 @@ After configuring harbor.cfg, build and start Harbor by the following commands.
|
||||
|
||||
If everything works fine, you can open a browser to visit the admin portal at http://reg.yourdomain.com . The default administrator username and password are admin/Harbor12345 .
|
||||
|
||||
Create a new project, e.g. myproject, in the admin portal. You can then use docker commands to login and push images. The default port of Harbor registry server is 80:
|
||||
Log in to the admin portal and create a new project, e.g. myproject. You can then use docker commands to login and push images. The default port of Harbor registry server is 80:
|
||||
```sh
|
||||
$ docker login reg.yourdomain.com
|
||||
$ docker push reg.yourdomain.com/myproject/myrepo
|
||||
@ -96,7 +97,7 @@ $ sudo docker-compose up -d
|
||||
......
|
||||
```
|
||||
|
||||
### Deploying Harbor to a target machine that does not have Internet access
|
||||
### Deploying Harbor to a host which does not have Internet access
|
||||
When you run *docker-compose up* to start Harbor, it will pull base images from Docker Hub and build new images for the containers. This process requires accessing the Internet. If you want to deploy Harbor to a host that is not connected to the Internet, you need to prepare Harbor on a machine that has access to the Internet. After that, you export the images as tgz files and transfer them to the target machine. Then load the tgz file into Docker's local image repo.
|
||||
|
||||
#### Building and saving images for offline installation
|
||||
@ -121,8 +122,10 @@ $ cd ../
|
||||
$ tar -cvzf harbor_offline-0.1.1.tgz harbor
|
||||
```
|
||||
|
||||
The file **harbor_offline-0.1.1.tgz** contains the images saved by previously steps and the files required to start Harbor.
|
||||
You can use tools such as scp to transfer the file **harbor_offline-0.1.1.tgz** to the target machine that does not have Internet connection. On the target machine, you can execute the following commands to start Harbor. Again, before running the **prepare** script, be sure to update **harbor.cfg** to reflect the right configuration of the target machine. (Refer to Section [Configure Harbor](#configuring-harbor) .)
|
||||
The file **harbor_offline-0.1.1.tgz** contains the images saved by previous steps and the other files required to start Harbor.
|
||||
You can use tools such as scp to transfer the file **harbor_offline-0.1.1.tgz** to the target machine that does not have Internet connection.
|
||||
On the target machine, you can execute the following commands to start Harbor. Again, before running the **prepare** script,
|
||||
be sure to update **harbor.cfg** to reflect the right configuration of the target machine. (Refer to Section [Configuring Harbor](#configuring-harbor) .)
|
||||
```
|
||||
$ tar -xzvf harbor_offline-0.1.1.tgz
|
||||
$ cd harbor
|
||||
|
@ -473,6 +473,36 @@ paths:
|
||||
description: Project ID does not exist.
|
||||
500:
|
||||
description: Unexpected internal errors.
|
||||
delete:
|
||||
summary: Delete a repository or a tag in a repository.
|
||||
description: |
|
||||
This endpoint let user delete repositories and tags with repo name and tag.
|
||||
parameters:
|
||||
- name: repo_name
|
||||
in: query
|
||||
type: string
|
||||
format: string
|
||||
required: true
|
||||
description: The name of repository which will be deleted.
|
||||
- name: tag
|
||||
in: query
|
||||
type: string
|
||||
format: string
|
||||
required: false
|
||||
description: Tag of a repository.
|
||||
tags:
|
||||
- Products
|
||||
responses:
|
||||
200:
|
||||
description: Delete repository or tag successfully.
|
||||
400:
|
||||
description: Invalid repo_name.
|
||||
401:
|
||||
description: Unauthorized.
|
||||
404:
|
||||
description: Repository or tag not found.
|
||||
403:
|
||||
description: Forbidden.
|
||||
/repositories/tags:
|
||||
get:
|
||||
summary: Get tags of a relevant repository.
|
||||
|
65
kubernetes_deployment.md
Normal file
65
kubernetes_deployment.md
Normal file
@ -0,0 +1,65 @@
|
||||
## Deploy harbor on kubernetes.
|
||||
For now, it's a little tricky to start harbor on kubernetes because
|
||||
1. Registry uses https, so we need cert or workaround to avoid errors like this:
|
||||
```
|
||||
Error response from daemon: invalid registry endpoint https://{HOST}/v0/: unable to ping registry endpoint https://{HOST}/v0/
|
||||
v2 ping attempt failed with error: Get https://{HOST}/v2/: EOF
|
||||
v1 ping attempt failed with error: Get https://{HOST}/v1/_ping: EOF. If this private registry supports only HTTP or HTTPS with an unknown CA certificate, please add `--insecure-registry {HOST}` to the daemon's arguments. In the case of HTTPS, if you have access to the registry's CA certificate, no need for the flag; simply place the CA certificate at /etc/docker/certs.d/{HOST}/ca.crt
|
||||
```
|
||||
There is a workaround if you don't have a cert. The workaround is to add the host into the list of insecure registry by editting the ```/etc/default/docker``` file:
|
||||
```
|
||||
sudo vi /etc/default/docker
|
||||
```
|
||||
add the line at the end of file:
|
||||
```
|
||||
DOCKER_OPTS="$DOCKER_OPTS --insecure-registry={HOST}"
|
||||
```
|
||||
restart docker service
|
||||
```
|
||||
sudo service docker restart
|
||||
```
|
||||
|
||||
2. The registry config file need to know the IP (or DNS name) of the registry, but on kubernetes, you won't know the IP before the service is created. There are several workarounds to solve this problem for now:
|
||||
- Use DNS name and link th DNS name with the IP after the service is created.
|
||||
- Rebuild the registry image with the service IP after the service is created and use ```kubectl rolling-update``` to update to the new image.
|
||||
|
||||
|
||||
To start harbor on kubernetes, you first need to build the docker images. The docker images for deploying Harbor on Kubernetes depends on the docker images to deploy Harbor with docker-compose. So the first step is to build docker images with docker-compose. Before actually building the images, you need to first adjust the [configuration](https://github.com/vmware/harbor/blob/master/Deploy/harbor.cfg):
|
||||
- Change the [hostname](https://github.com/vmware/harbor/blob/master/Deploy/harbor.cfg#L5) to ```localhost```
|
||||
- Adjust the [email settings](https://github.com/vmware/harbor/blob/master/Deploy/harbor.cfg#L11) according to your needs.
|
||||
|
||||
Then you can run the following commends to build docker images:
|
||||
```
|
||||
cd Deploy
|
||||
./prepare
|
||||
docker-compose build
|
||||
docker build -f kubernetes/dockerfiles/proxy-dockerfile -t {your_account}/proxy .
|
||||
docker build -f kubernetes/dockerfiles/registry-dockerfile -t {your_account}/registry .
|
||||
docker build -f kubernetes/dockerfiles/ui-dockerfile -t {your_account}/deploy_ui .
|
||||
docker tag deploy_mysql {your_account}/deploy_mysql
|
||||
docker push {your_account}/proxy
|
||||
docker push {your_account}/registry
|
||||
docker push {your_account}/deploy_ui
|
||||
docker push {your_account}/deploy_mysql
|
||||
```
|
||||
|
||||
where "your_account" is your own registry. Then you need to update the "image" field in the ```*-rc.yaml``` files at:
|
||||
```
|
||||
Deploy/kubernetes/mysql-rc.yaml
|
||||
Deploy/kubernetes/proxy-rc.yaml
|
||||
Deploy/kubernetes/registry-rc.yaml
|
||||
Deploy/kubernetes/ui-rc.yaml
|
||||
```
|
||||
|
||||
Further more, the following configuration could be changed according to your need:
|
||||
- **harbor_admin_password**: The password for the administrator of Harbor, by default the password is Harbor12345. You can changed it [here](https://github.com/vmware/harbor/blob/master/Deploy/kubernetes/ui-rc.yaml#L36).
|
||||
- **auth_mode**: The authentication mode of Harbor. By default it is *db_auth*, i.e. the credentials are stored in a database. Please set it to *ldap_auth* if you want to verify user's credentials against an LDAP server. You can change the configuration [here](https://github.com/vmware/harbor/blob/master/Deploy/kubernetes/ui-rc.yaml#L40).
|
||||
- **ldap_url**: The URL for LDAP endpoint, for example ldaps://ldap.mydomain.com. It is only used when **auth_mode** is set to *ldap_auth*. It could be changed [here](https://github.com/vmware/harbor/blob/master/Deploy/kubernetes/ui-rc.yaml#L42).
|
||||
- **ldap_basedn**: The basedn template for verifying the user's credentials against LDAP, for example uid=%s,ou=people,dc=mydomain,dc=com. It is only used when **auth_mode** is set to *ldap_auth*. It could be changed [here](https://github.com/vmware/harbor/blob/master/Deploy/kubernetes/ui-rc.yaml#L44).
|
||||
- **db_password**: The password of root user of mySQL database. Change this password for any production use. You need to change both [here](https://github.com/vmware/harbor/blob/master/Deploy/kubernetes/ui-rc.yaml#L28) and [here](https://github.com/vmware/harbor/blob/master/Deploy/harbor.cfg#L32) to make the change. Please note, you need to change the ```harbor.cfg``` before building the docker images.
|
||||
|
||||
Finally you can start the jobs by running:
|
||||
```
|
||||
kubectl create -f Deploy/kubernetes
|
||||
```
|
||||
|
@ -29,7 +29,6 @@ type RepoItem struct {
|
||||
ID string `json:"Id"`
|
||||
Parent string `json:"Parent"`
|
||||
Created time.Time `json:"Created"`
|
||||
CreatedStr string `json:"CreatedStr"`
|
||||
DurationDays string `json:"Duration Days"`
|
||||
Author string `json:"Author"`
|
||||
Architecture string `json:"Architecture"`
|
||||
|
@ -22,11 +22,11 @@ import (
|
||||
// User holds the details of a user.
|
||||
type User struct {
|
||||
UserID int `orm:"column(user_id)" json:"UserId"`
|
||||
Username string `orm:"column(username)"`
|
||||
Email string `orm:"column(email)"`
|
||||
Password string `orm:"column(password)"`
|
||||
Realname string `orm:"column(realname)"`
|
||||
Comment string `orm:"column(comment)"`
|
||||
Username string `orm:"column(username)" json:"username"`
|
||||
Email string `orm:"column(email)" json:"email"`
|
||||
Password string `orm:"column(password)" json:"password"`
|
||||
Realname string `orm:"column(realname)" json:"realname"`
|
||||
Comment string `orm:"column(comment)" json:"comment"`
|
||||
Deleted int `orm:"column(deleted)"`
|
||||
Rolename string
|
||||
RoleID int `json:"RoleId"`
|
||||
|
@ -38,7 +38,7 @@ const manifestPattern = `^application/vnd.docker.distribution.manifest.v\d\+json
|
||||
// Post handles POST request, and records audit log or refreshes cache based on event.
|
||||
func (n *NotificationHandler) Post() {
|
||||
var notification models.Notification
|
||||
// log.Info("Notification Handler triggered!\n")
|
||||
//log.Info("Notification Handler triggered!\n")
|
||||
// log.Infof("request body in string: %s", string(n.Ctx.Input.CopyBody()))
|
||||
err := json.Unmarshal(n.Ctx.Input.CopyBody(1<<32), ¬ification)
|
||||
|
||||
|
@ -13,7 +13,7 @@
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package utils
|
||||
package token
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
@ -80,7 +80,7 @@ func FilterAccess(username string, authenticated bool, a *token.ResourceActions)
|
||||
return
|
||||
}
|
||||
if exist {
|
||||
permission = "RW"
|
||||
permission = "RWM"
|
||||
} else {
|
||||
permission = ""
|
||||
log.Infof("project %s does not exist, set empty permission for admin\n", projectName)
|
||||
@ -96,6 +96,9 @@ func FilterAccess(username string, authenticated bool, a *token.ResourceActions)
|
||||
if strings.Contains(permission, "W") {
|
||||
a.Actions = append(a.Actions, "push")
|
||||
}
|
||||
if strings.Contains(permission, "M") {
|
||||
a.Actions = append(a.Actions, "*")
|
||||
}
|
||||
if strings.Contains(permission, "R") || dao.IsProjectPublic(projectName) {
|
||||
a.Actions = append(a.Actions, "pull")
|
||||
}
|
@ -13,53 +13,53 @@
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package service
|
||||
package token
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/vmware/harbor/auth"
|
||||
"github.com/vmware/harbor/models"
|
||||
svc_utils "github.com/vmware/harbor/service/utils"
|
||||
//svc_utils "github.com/vmware/harbor/service/utils"
|
||||
"github.com/vmware/harbor/utils/log"
|
||||
|
||||
"github.com/astaxie/beego"
|
||||
"github.com/docker/distribution/registry/auth/token"
|
||||
)
|
||||
|
||||
// TokenHandler handles request on /service/token, which is the auth provider for registry.
|
||||
type TokenHandler struct {
|
||||
// Handler handles request on /service/token, which is the auth provider for registry.
|
||||
type Handler struct {
|
||||
beego.Controller
|
||||
}
|
||||
|
||||
// Get handles GET request, it checks the http header for user credentials
|
||||
// and parse service and scope based on docker registry v2 standard,
|
||||
// checkes the permission agains local DB and generates jwt token.
|
||||
func (a *TokenHandler) Get() {
|
||||
func (h *Handler) Get() {
|
||||
|
||||
request := a.Ctx.Request
|
||||
request := h.Ctx.Request
|
||||
log.Infof("request url: %v", request.URL.String())
|
||||
username, password, _ := request.BasicAuth()
|
||||
authenticated := authenticate(username, password)
|
||||
service := a.GetString("service")
|
||||
scopes := a.GetStrings("scope")
|
||||
service := h.GetString("service")
|
||||
scopes := h.GetStrings("scope")
|
||||
log.Debugf("scopes: %+v", scopes)
|
||||
|
||||
if len(scopes) == 0 && !authenticated {
|
||||
log.Info("login request with invalid credentials")
|
||||
a.CustomAbort(http.StatusUnauthorized, "")
|
||||
h.CustomAbort(http.StatusUnauthorized, "")
|
||||
}
|
||||
access := svc_utils.GetResourceActions(scopes)
|
||||
access := GetResourceActions(scopes)
|
||||
for _, a := range access {
|
||||
svc_utils.FilterAccess(username, authenticated, a)
|
||||
FilterAccess(username, authenticated, a)
|
||||
}
|
||||
a.serveToken(username, service, access)
|
||||
h.serveToken(username, service, access)
|
||||
}
|
||||
|
||||
func (a *TokenHandler) serveToken(username, service string, access []*token.ResourceActions) {
|
||||
writer := a.Ctx.ResponseWriter
|
||||
func (h *Handler) serveToken(username, service string, access []*token.ResourceActions) {
|
||||
writer := h.Ctx.ResponseWriter
|
||||
//create token
|
||||
rawToken, err := svc_utils.MakeToken(username, service, access)
|
||||
rawToken, err := MakeToken(username, service, access)
|
||||
if err != nil {
|
||||
log.Errorf("Failed to make token, error: %v", err)
|
||||
writer.WriteHeader(http.StatusInternalServerError)
|
||||
@ -67,12 +67,15 @@ func (a *TokenHandler) serveToken(username, service string, access []*token.Reso
|
||||
}
|
||||
tk := make(map[string]string)
|
||||
tk["token"] = rawToken
|
||||
a.Data["json"] = tk
|
||||
a.ServeJSON()
|
||||
h.Data["json"] = tk
|
||||
h.ServeJSON()
|
||||
}
|
||||
|
||||
func authenticate(principal, password string) bool {
|
||||
user, err := auth.Login(models.AuthModel{principal, password})
|
||||
user, err := auth.Login(models.AuthModel{
|
||||
Principal: principal,
|
||||
Password: password,
|
||||
})
|
||||
if err != nil {
|
||||
log.Errorf("Error occurred in UserLogin: %v", err)
|
||||
return false
|
@ -16,11 +16,11 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/vmware/harbor/models"
|
||||
"github.com/vmware/harbor/utils/log"
|
||||
"github.com/vmware/harbor/utils/registry"
|
||||
|
||||
"github.com/astaxie/beego/cache"
|
||||
)
|
||||
@ -28,6 +28,8 @@ import (
|
||||
// Cache is the global cache in system.
|
||||
var Cache cache.Cache
|
||||
|
||||
var registryClient *registry.Registry
|
||||
|
||||
const catalogKey string = "catalog"
|
||||
|
||||
func init() {
|
||||
@ -36,20 +38,39 @@ func init() {
|
||||
if err != nil {
|
||||
log.Errorf("Failed to initialize cache, error:%v", err)
|
||||
}
|
||||
|
||||
endpoint := os.Getenv("REGISTRY_URL")
|
||||
client := registry.NewClientUsernameAuthHandlerEmbeded("admin")
|
||||
registryClient, err = registry.New(endpoint, client)
|
||||
if err != nil {
|
||||
log.Fatalf("error occurred while initializing authentication handler used by cache: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// RefreshCatalogCache calls registry's API to get repository list and write it to cache.
|
||||
func RefreshCatalogCache() error {
|
||||
result, err := RegistryAPIGet(BuildRegistryURL("_catalog"), "")
|
||||
log.Debug("refreshing catalog cache...")
|
||||
rs, err := registryClient.Catalog()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
repoResp := models.Repo{}
|
||||
err = json.Unmarshal(result, &repoResp)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
repos := []string{}
|
||||
|
||||
for _, repo := range rs {
|
||||
tags, err := registryClient.ListTag(repo)
|
||||
if err != nil {
|
||||
log.Errorf("error occurred while list tag for %s: %v", repo, err)
|
||||
return err
|
||||
}
|
||||
|
||||
if len(tags) != 0 {
|
||||
repos = append(repos, repo)
|
||||
log.Debugf("add %s to catalog cache", repo)
|
||||
}
|
||||
}
|
||||
Cache.Put(catalogKey, repoResp.Repositories, 600*time.Second)
|
||||
|
||||
Cache.Put(catalogKey, repos, 600*time.Second)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -1,114 +0,0 @@
|
||||
/*
|
||||
Copyright (c) 2016 VMware, Inc. All Rights Reserved.
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package utils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
|
||||
"github.com/vmware/harbor/utils/log"
|
||||
)
|
||||
|
||||
// BuildRegistryURL ...
|
||||
func BuildRegistryURL(segments ...string) string {
|
||||
registryURL := os.Getenv("REGISTRY_URL")
|
||||
if registryURL == "" {
|
||||
registryURL = "http://localhost:5000"
|
||||
}
|
||||
url := registryURL + "/v2"
|
||||
for _, s := range segments {
|
||||
if s == "v2" {
|
||||
log.Debugf("unnecessary v2 in %v", segments)
|
||||
continue
|
||||
}
|
||||
url += "/" + s
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
// RegistryAPIGet triggers GET request to the URL which is the endpoint of registry and returns the response body.
|
||||
// It will attach a valid jwt token to the request if registry requires.
|
||||
func RegistryAPIGet(url, username string) ([]byte, error) {
|
||||
|
||||
log.Debugf("Registry API url: %s", url)
|
||||
response, err := http.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, err := ioutil.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode == http.StatusOK {
|
||||
return result, nil
|
||||
} else if response.StatusCode == http.StatusUnauthorized {
|
||||
authenticate := response.Header.Get("WWW-Authenticate")
|
||||
log.Debugf("authenticate header: %s", authenticate)
|
||||
var service string
|
||||
var scopes []string
|
||||
//Disregard the case for hanlding multiple scopes for http call initiated from UI, as there's refactor planned.
|
||||
re := regexp.MustCompile(`service=\"(.*?)\".*scope=\"(.*?)\"`)
|
||||
res := re.FindStringSubmatch(authenticate)
|
||||
if len(res) > 2 {
|
||||
service = res[1]
|
||||
scopes = append(scopes, res[2])
|
||||
}
|
||||
token, err := GenTokenForUI(username, service, scopes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request.Header.Add("Authorization", "Bearer "+token)
|
||||
client := &http.Client{}
|
||||
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
// log.Infof("via length: %d\n", len(via))
|
||||
if len(via) >= 10 {
|
||||
return fmt.Errorf("too many redirects")
|
||||
}
|
||||
for k, v := range via[0].Header {
|
||||
if _, ok := req.Header[k]; !ok {
|
||||
req.Header[k] = v
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
response, err = client.Do(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if response.StatusCode != http.StatusOK {
|
||||
errMsg := fmt.Sprintf("Unexpected return code from registry: %d", response.StatusCode)
|
||||
log.Error(errMsg)
|
||||
return nil, fmt.Errorf(errMsg)
|
||||
}
|
||||
result, err = ioutil.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
return result, nil
|
||||
} else {
|
||||
return nil, errors.New(string(result))
|
||||
}
|
||||
}
|
@ -82,4 +82,4 @@ index_desc_2 = 2. Efficiency: A private registry server is set up within the org
|
||||
index_desc_3 = 3. Access Control: RBAC (Role Based Access Control) is provided. User management can be integrated with existing enterprise identity services like AD/LDAP.
|
||||
index_desc_4 = 4. Audit: All access to the registry are logged and can be used for audit purpose.
|
||||
index_desc_5 = 5. GUI: User friendly single-pane-of-glass management console.
|
||||
|
||||
index_title = An enterprise-class registry server
|
||||
|
@ -82,3 +82,4 @@ index_desc_2 = 2. 效率: 搭建组织内部的私有容器Registry服务,可
|
||||
index_desc_3 = 3. 访问控制: 提供基于角色的访问控制,可集成企业目前拥有的用户管理系统(如:AD/LDAP)。
|
||||
index_desc_4 = 4. 审计: 所有访问Registry服务的操作均被记录,便于日后审计。
|
||||
index_desc_5 = 5. 管理界面: 具有友好易用图形管理界面。
|
||||
index_title = 企业级 Registry 服务
|
@ -130,9 +130,8 @@ jQuery(function(){
|
||||
data[i] = "N/A";
|
||||
}
|
||||
}
|
||||
data.Created = data.CreatedStr;
|
||||
delete data.CreatedStr;
|
||||
|
||||
data.Created = moment(new Date(data.Created)).format("YYYY-MM-DD HH:mm:ss");
|
||||
|
||||
$("#dlgModal").dialogModal({"title": i18n.getMessage("image_details"), "content": data});
|
||||
}
|
||||
}
|
||||
@ -246,7 +245,7 @@ jQuery(function(){
|
||||
|
||||
var userId = userList[i].UserId;
|
||||
var roleId = userList[i].RoleId;
|
||||
var username = userList[i].Username;
|
||||
var username = userList[i].username;
|
||||
var roleNameList = [];
|
||||
|
||||
for(var j = i; j < userList.length; i++, j++){
|
||||
|
@ -38,14 +38,25 @@ jQuery(function(){
|
||||
var comment = $.trim($("#Comment").val());
|
||||
var isAdmin = $("#isAdmin").val();
|
||||
|
||||
$.ajax({
|
||||
url : '/signUp',
|
||||
data:{username: username, password: password, realname: realname, comment: comment, email: email},
|
||||
new AjaxUtil({
|
||||
url : "/api/users",
|
||||
data: {"username": username, "password": password, "realname": realname, "comment": comment, "email": email},
|
||||
type: "POST",
|
||||
beforeSend: function(e){
|
||||
$("#btnPageSignUp").prop("disabled", true);
|
||||
},
|
||||
success: function(data, status, xhr){
|
||||
error:function(jqxhr, status, error){
|
||||
$("#dlgModal")
|
||||
.dialogModal({
|
||||
"title": i18n.getMessage("title_sign_up"),
|
||||
"content": i18n.getMessage("internal_error"),
|
||||
"callback": function(){
|
||||
return;
|
||||
}
|
||||
});
|
||||
},
|
||||
complete: function(xhr, status){
|
||||
$("#btnPageSignUp").prop("disabled", false);
|
||||
if(xhr && xhr.status == 200){
|
||||
$("#dlgModal")
|
||||
.dialogModal({
|
||||
@ -60,21 +71,8 @@ jQuery(function(){
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
error:function(jqxhr, status, error){
|
||||
$("#dlgModal")
|
||||
.dialogModal({
|
||||
"title": i18n.getMessage("title_sign_up"),
|
||||
"content": i18n.getMessage("internal_error"),
|
||||
"callback": function(){
|
||||
return;
|
||||
}
|
||||
});
|
||||
},
|
||||
complete: function(){
|
||||
$("#btnPageSignUp").prop("disabled", false);
|
||||
}
|
||||
});
|
||||
}).exec();
|
||||
});
|
||||
});
|
||||
});
|
@ -19,6 +19,7 @@ import (
|
||||
"github.com/vmware/harbor/api"
|
||||
"github.com/vmware/harbor/controllers"
|
||||
"github.com/vmware/harbor/service"
|
||||
"github.com/vmware/harbor/service/token"
|
||||
|
||||
"github.com/astaxie/beego"
|
||||
)
|
||||
@ -32,7 +33,6 @@ func initRouters() {
|
||||
beego.Router("/login", &controllers.CommonController{}, "post:Login")
|
||||
beego.Router("/logout", &controllers.CommonController{}, "get:Logout")
|
||||
beego.Router("/language", &controllers.CommonController{}, "get:SwitchLanguage")
|
||||
beego.Router("/signUp", &controllers.CommonController{}, "post:SignUp")
|
||||
beego.Router("/userExists", &controllers.CommonController{}, "post:UserExists")
|
||||
beego.Router("/reset", &controllers.CommonController{}, "post:ResetPassword")
|
||||
beego.Router("/sendEmail", &controllers.CommonController{}, "get:SendEmail")
|
||||
@ -56,6 +56,7 @@ func initRouters() {
|
||||
beego.Router("/api/projects/:pid/members/?:mid", &api.ProjectMemberAPI{})
|
||||
beego.Router("/api/projects/?:id", &api.ProjectAPI{})
|
||||
beego.Router("/api/projects/:id/logs/filter", &api.ProjectAPI{}, "post:FilterAccessLog")
|
||||
beego.Router("/api/users", &api.UserAPI{})
|
||||
beego.Router("/api/users/?:id", &api.UserAPI{})
|
||||
beego.Router("/api/repositories", &api.RepositoryAPI{})
|
||||
beego.Router("/api/repositories/tags", &api.RepositoryAPI{}, "get:GetTags")
|
||||
@ -63,5 +64,5 @@ func initRouters() {
|
||||
|
||||
//external service that hosted on harbor process:
|
||||
beego.Router("/service/notifications", &service.NotificationHandler{})
|
||||
beego.Router("/service/token", &service.TokenHandler{})
|
||||
beego.Router("/service/token", &token.Handler{})
|
||||
}
|
||||
|
32
utils/registry/auth/challenge.go
Normal file
32
utils/registry/auth/challenge.go
Normal file
@ -0,0 +1,32 @@
|
||||
/*
|
||||
Copyright (c) 2016 VMware, Inc. All Rights Reserved.
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
au "github.com/docker/distribution/registry/client/auth"
|
||||
"github.com/vmware/harbor/utils/log"
|
||||
)
|
||||
|
||||
// ParseChallengeFromResponse ...
|
||||
func ParseChallengeFromResponse(resp *http.Response) []au.Challenge {
|
||||
challenges := au.ResponseChallenges(resp)
|
||||
|
||||
log.Debugf("challenges: %v", challenges)
|
||||
|
||||
return challenges
|
||||
}
|
197
utils/registry/auth/handler.go
Normal file
197
utils/registry/auth/handler.go
Normal file
@ -0,0 +1,197 @@
|
||||
/*
|
||||
Copyright (c) 2016 VMware, Inc. All Rights Reserved.
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
token_util "github.com/vmware/harbor/service/token"
|
||||
"github.com/vmware/harbor/utils/log"
|
||||
registry_errors "github.com/vmware/harbor/utils/registry/errors"
|
||||
)
|
||||
|
||||
const (
|
||||
// credential type
|
||||
basicAuth string = "basic_auth"
|
||||
secretKey string = "secret_key"
|
||||
)
|
||||
|
||||
// Handler authorizes the request when encounters a 401 error
|
||||
type Handler interface {
|
||||
// Schema : basic, bearer
|
||||
Schema() string
|
||||
//AuthorizeRequest adds basic auth or token auth to the header of request
|
||||
AuthorizeRequest(req *http.Request, params map[string]string) error
|
||||
}
|
||||
|
||||
// Credential ...
|
||||
type Credential interface {
|
||||
// AddAuthorization adds authorization information to request
|
||||
AddAuthorization(req *http.Request)
|
||||
}
|
||||
|
||||
type basicAuthCredential struct {
|
||||
username string
|
||||
password string
|
||||
}
|
||||
|
||||
// NewBasicAuthCredential ...
|
||||
func NewBasicAuthCredential(username, password string) Credential {
|
||||
return &basicAuthCredential{
|
||||
username: username,
|
||||
password: password,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *basicAuthCredential) AddAuthorization(req *http.Request) {
|
||||
req.SetBasicAuth(b.username, b.password)
|
||||
}
|
||||
|
||||
type token struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
type standardTokenHandler struct {
|
||||
client *http.Client
|
||||
credential Credential
|
||||
}
|
||||
|
||||
// NewStandardTokenHandler returns a standard token handler. The handler will request a token
|
||||
// from token server whose URL is specified in the "WWW-authentication" header and add it to
|
||||
// the origin request
|
||||
// TODO deal with https
|
||||
func NewStandardTokenHandler(credential Credential) Handler {
|
||||
return &standardTokenHandler{
|
||||
client: &http.Client{
|
||||
Transport: http.DefaultTransport,
|
||||
},
|
||||
credential: credential,
|
||||
}
|
||||
}
|
||||
|
||||
// Schema implements the corresponding method in interface AuthHandler
|
||||
func (t *standardTokenHandler) Schema() string {
|
||||
return "bearer"
|
||||
}
|
||||
|
||||
// AuthorizeRequest implements the corresponding method in interface AuthHandler
|
||||
func (t *standardTokenHandler) AuthorizeRequest(req *http.Request, params map[string]string) error {
|
||||
realm, ok := params["realm"]
|
||||
if !ok {
|
||||
return errors.New("no realm")
|
||||
}
|
||||
|
||||
service := params["service"]
|
||||
scope := params["scope"]
|
||||
|
||||
u, err := url.Parse(realm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
q := u.Query()
|
||||
q.Add("service", service)
|
||||
|
||||
for _, s := range strings.Split(scope, " ") {
|
||||
q.Add("scope", s)
|
||||
}
|
||||
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
r, err := http.NewRequest("GET", u.String(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
t.credential.AddAuthorization(r)
|
||||
|
||||
resp, err := t.client.Do(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
b, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return registry_errors.Error{
|
||||
StatusCode: resp.StatusCode,
|
||||
Message: string(b),
|
||||
}
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(resp.Body)
|
||||
|
||||
tk := &token{}
|
||||
if err = decoder.Decode(tk); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Add(http.CanonicalHeaderKey("Authorization"), fmt.Sprintf("Bearer %s", tk.Token))
|
||||
|
||||
log.Debugf("standardTokenHandler generated token successfully | %s %s", req.Method, req.URL)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type usernameTokenHandler struct {
|
||||
username string
|
||||
}
|
||||
|
||||
// NewUsernameTokenHandler returns a handler which will generate
|
||||
// a token according the user's privileges
|
||||
func NewUsernameTokenHandler(username string) Handler {
|
||||
return &usernameTokenHandler{
|
||||
username: username,
|
||||
}
|
||||
}
|
||||
|
||||
// Schema implements the corresponding method in interface AuthHandler
|
||||
func (u *usernameTokenHandler) Schema() string {
|
||||
return "bearer"
|
||||
}
|
||||
|
||||
// AuthorizeRequest implements the corresponding method in interface AuthHandler
|
||||
func (u *usernameTokenHandler) AuthorizeRequest(req *http.Request, params map[string]string) error {
|
||||
service := params["service"]
|
||||
|
||||
scopes := []string{}
|
||||
scope := params["scope"]
|
||||
if len(scope) != 0 {
|
||||
scopes = strings.Split(scope, " ")
|
||||
}
|
||||
|
||||
token, err := token_util.GenTokenForUI(u.username, service, scopes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Add(http.CanonicalHeaderKey("Authorization"), fmt.Sprintf("Bearer %s", token))
|
||||
|
||||
log.Debugf("usernameTokenHandler generated token successfully | %s %s", req.Method, req.URL)
|
||||
|
||||
return nil
|
||||
}
|
38
utils/registry/errors/error.go
Normal file
38
utils/registry/errors/error.go
Normal file
@ -0,0 +1,38 @@
|
||||
/*
|
||||
Copyright (c) 2016 VMware, Inc. All Rights Reserved.
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package errors
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Error : if response's status code is not 200 or does not meet requirement,
|
||||
// an Error instance will be returned
|
||||
type Error struct {
|
||||
StatusCode int
|
||||
Message string
|
||||
}
|
||||
|
||||
// Error ...
|
||||
func (e Error) Error() string {
|
||||
return fmt.Sprintf("%d %s", e.StatusCode, e.Message)
|
||||
}
|
||||
|
||||
// ParseError parses err, if err is type Error, convert it to Error
|
||||
func ParseError(err error) (Error, bool) {
|
||||
e, ok := err.(Error)
|
||||
return e, ok
|
||||
}
|
116
utils/registry/httpclient.go
Normal file
116
utils/registry/httpclient.go
Normal file
@ -0,0 +1,116 @@
|
||||
/*
|
||||
Copyright (c) 2016 VMware, Inc. All Rights Reserved.
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package registry
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/vmware/harbor/utils/log"
|
||||
"github.com/vmware/harbor/utils/registry/auth"
|
||||
)
|
||||
|
||||
// NewClient returns a http.Client according to the handlers provided
|
||||
func NewClient(handlers []auth.Handler) *http.Client {
|
||||
transport := NewAuthTransport(http.DefaultTransport, handlers)
|
||||
|
||||
return &http.Client{
|
||||
Transport: transport,
|
||||
}
|
||||
}
|
||||
|
||||
// NewClientStandardAuthHandlerEmbeded return a http.Client which will authorize the request
|
||||
// according to the credential provided and send it again when encounters a 401 error
|
||||
func NewClientStandardAuthHandlerEmbeded(credential auth.Credential) *http.Client {
|
||||
handlers := []auth.Handler{}
|
||||
|
||||
tokenHandler := auth.NewStandardTokenHandler(credential)
|
||||
|
||||
handlers = append(handlers, tokenHandler)
|
||||
|
||||
return NewClient(handlers)
|
||||
}
|
||||
|
||||
// NewClientUsernameAuthHandlerEmbeded return a http.Client which will authorize the request
|
||||
// according to the user's privileges and send it again when encounters a 401 error
|
||||
func NewClientUsernameAuthHandlerEmbeded(username string) *http.Client {
|
||||
handlers := []auth.Handler{}
|
||||
|
||||
tokenHandler := auth.NewUsernameTokenHandler(username)
|
||||
|
||||
handlers = append(handlers, tokenHandler)
|
||||
|
||||
return NewClient(handlers)
|
||||
}
|
||||
|
||||
type authTransport struct {
|
||||
transport http.RoundTripper
|
||||
handlers []auth.Handler
|
||||
}
|
||||
|
||||
// NewAuthTransport wraps the AuthHandlers to be http.RounTripper
|
||||
func NewAuthTransport(transport http.RoundTripper, handlers []auth.Handler) http.RoundTripper {
|
||||
return &authTransport{
|
||||
transport: transport,
|
||||
handlers: handlers,
|
||||
}
|
||||
}
|
||||
|
||||
// RoundTrip ...
|
||||
func (a *authTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
originResp, originErr := a.transport.RoundTrip(req)
|
||||
|
||||
if originErr != nil {
|
||||
return originResp, originErr
|
||||
}
|
||||
|
||||
log.Debugf("%d | %s %s", originResp.StatusCode, req.Method, req.URL)
|
||||
|
||||
if originResp.StatusCode != http.StatusUnauthorized {
|
||||
return originResp, nil
|
||||
}
|
||||
|
||||
challenges := auth.ParseChallengeFromResponse(originResp)
|
||||
|
||||
reqChanged := false
|
||||
for _, challenge := range challenges {
|
||||
|
||||
scheme := challenge.Scheme
|
||||
|
||||
for _, handler := range a.handlers {
|
||||
if scheme != handler.Schema() {
|
||||
log.Debugf("scheme not match: %s %s, skip", scheme, handler.Schema())
|
||||
continue
|
||||
}
|
||||
|
||||
if err := handler.AuthorizeRequest(req, challenge.Parameters); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reqChanged = true
|
||||
}
|
||||
}
|
||||
|
||||
if !reqChanged {
|
||||
log.Warning("no handler match scheme")
|
||||
return originResp, nil
|
||||
}
|
||||
|
||||
resp, err := a.transport.RoundTrip(req)
|
||||
if err == nil {
|
||||
log.Debugf("%d | %s %s", resp.StatusCode, req.Method, req.URL)
|
||||
}
|
||||
|
||||
return resp, err
|
||||
}
|
25
utils/registry/manifest.go
Normal file
25
utils/registry/manifest.go
Normal file
@ -0,0 +1,25 @@
|
||||
/*
|
||||
Copyright (c) 2016 VMware, Inc. All Rights Reserved.
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package registry
|
||||
|
||||
import (
|
||||
"github.com/docker/distribution"
|
||||
)
|
||||
|
||||
// UnMarshal converts []byte to be distribution.Manifest
|
||||
func UnMarshal(mediaType string, data []byte) (distribution.Manifest, distribution.Descriptor, error) {
|
||||
return distribution.UnmarshalManifest(mediaType, data)
|
||||
}
|
316
utils/registry/registry.go
Normal file
316
utils/registry/registry.go
Normal file
@ -0,0 +1,316 @@
|
||||
/*
|
||||
Copyright (c) 2016 VMware, Inc. All Rights Reserved.
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package registry
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/docker/distribution/manifest"
|
||||
"github.com/docker/distribution/manifest/schema1"
|
||||
"github.com/docker/distribution/manifest/schema2"
|
||||
"github.com/vmware/harbor/utils/registry/errors"
|
||||
)
|
||||
|
||||
// Registry holds information of a registry entiry
|
||||
type Registry struct {
|
||||
Endpoint *url.URL
|
||||
client *http.Client
|
||||
ub *uRLBuilder
|
||||
}
|
||||
|
||||
type uRLBuilder struct {
|
||||
root *url.URL
|
||||
}
|
||||
|
||||
var (
|
||||
// ManifestVersion1 : schema 1
|
||||
ManifestVersion1 = manifest.Versioned{
|
||||
SchemaVersion: 1,
|
||||
MediaType: schema1.MediaTypeManifest,
|
||||
}
|
||||
// ManifestVersion2 : schema 2
|
||||
ManifestVersion2 = manifest.Versioned{
|
||||
SchemaVersion: 2,
|
||||
MediaType: schema2.MediaTypeManifest,
|
||||
}
|
||||
)
|
||||
|
||||
// New returns an instance of Registry
|
||||
func New(endpoint string, client *http.Client) (*Registry, error) {
|
||||
u, err := url.Parse(endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Registry{
|
||||
Endpoint: u,
|
||||
client: client,
|
||||
ub: &uRLBuilder{
|
||||
root: u,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Catalog ...
|
||||
func (r *Registry) Catalog() ([]string, error) {
|
||||
repos := []string{}
|
||||
req, err := http.NewRequest("GET", r.ub.buildCatalogURL(), nil)
|
||||
if err != nil {
|
||||
return repos, err
|
||||
}
|
||||
|
||||
resp, err := r.client.Do(req)
|
||||
if err != nil {
|
||||
return repos, err
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
b, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return repos, err
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
catalogResp := struct {
|
||||
Repositories []string `json:"repositories"`
|
||||
}{}
|
||||
|
||||
if err := json.Unmarshal(b, &catalogResp); err != nil {
|
||||
return repos, err
|
||||
}
|
||||
|
||||
repos = catalogResp.Repositories
|
||||
|
||||
return repos, nil
|
||||
}
|
||||
|
||||
return repos, errors.Error{
|
||||
StatusCode: resp.StatusCode,
|
||||
Message: string(b),
|
||||
}
|
||||
}
|
||||
|
||||
// ListTag ...
|
||||
func (r *Registry) ListTag(name string) ([]string, error) {
|
||||
tags := []string{}
|
||||
req, err := http.NewRequest("GET", r.ub.buildTagListURL(name), nil)
|
||||
if err != nil {
|
||||
return tags, err
|
||||
}
|
||||
|
||||
resp, err := r.client.Do(req)
|
||||
if err != nil {
|
||||
return tags, err
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
b, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return tags, err
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
tagsResp := struct {
|
||||
Tags []string `json:"tags"`
|
||||
}{}
|
||||
|
||||
if err := json.Unmarshal(b, &tagsResp); err != nil {
|
||||
return tags, err
|
||||
}
|
||||
|
||||
tags = tagsResp.Tags
|
||||
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
return tags, errors.Error{
|
||||
StatusCode: resp.StatusCode,
|
||||
Message: string(b),
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// ManifestExist ...
|
||||
func (r *Registry) ManifestExist(name, reference string) (digest string, exist bool, err error) {
|
||||
req, err := http.NewRequest("HEAD", r.ub.buildManifestURL(name, reference), nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// request Schema 2 manifest, if the registry does not support it,
|
||||
// Schema 1 manifest will be returned
|
||||
req.Header.Set(http.CanonicalHeaderKey("Accept"), schema2.MediaTypeManifest)
|
||||
|
||||
resp, err := r.client.Do(req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
exist = true
|
||||
digest = resp.Header.Get(http.CanonicalHeaderKey("Docker-Content-Digest"))
|
||||
return
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
b, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = errors.Error{
|
||||
StatusCode: resp.StatusCode,
|
||||
Message: string(b),
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// PullManifest ...
|
||||
func (r *Registry) PullManifest(name, reference string, version manifest.Versioned) (digest, mediaType string, payload []byte, err error) {
|
||||
req, err := http.NewRequest("GET", r.ub.buildManifestURL(name, reference), nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// if the registry does not support schema 2, schema 1 manifest will be returned
|
||||
req.Header.Set(http.CanonicalHeaderKey("Accept"), version.MediaType)
|
||||
|
||||
resp, err := r.client.Do(req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
b, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
digest = resp.Header.Get(http.CanonicalHeaderKey("Docker-Content-Digest"))
|
||||
mediaType = resp.Header.Get(http.CanonicalHeaderKey("Content-Type"))
|
||||
payload = b
|
||||
return
|
||||
}
|
||||
|
||||
err = errors.Error{
|
||||
StatusCode: resp.StatusCode,
|
||||
Message: string(b),
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// DeleteManifest ...
|
||||
func (r *Registry) DeleteManifest(name, digest string) error {
|
||||
req, err := http.NewRequest("DELETE", r.ub.buildManifestURL(name, digest), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := r.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusAccepted {
|
||||
return nil
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
b, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return errors.Error{
|
||||
StatusCode: resp.StatusCode,
|
||||
Message: string(b),
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteTag ...
|
||||
func (r *Registry) DeleteTag(name, tag string) error {
|
||||
digest, exist, err := r.ManifestExist(name, tag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !exist {
|
||||
return errors.Error{
|
||||
StatusCode: http.StatusNotFound,
|
||||
}
|
||||
}
|
||||
|
||||
return r.DeleteManifest(name, digest)
|
||||
}
|
||||
|
||||
// DeleteBlob ...
|
||||
func (r *Registry) DeleteBlob(name, digest string) error {
|
||||
req, err := http.NewRequest("DELETE", r.ub.buildBlobURL(name, digest), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := r.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusAccepted {
|
||||
return nil
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
b, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return errors.Error{
|
||||
StatusCode: resp.StatusCode,
|
||||
Message: string(b),
|
||||
}
|
||||
}
|
||||
|
||||
func (u *uRLBuilder) buildCatalogURL() string {
|
||||
return fmt.Sprintf("%s/v2/_catalog", u.root.String())
|
||||
}
|
||||
|
||||
func (u *uRLBuilder) buildTagListURL(name string) string {
|
||||
return fmt.Sprintf("%s/v2/%s/tags/list", u.root.String(), name)
|
||||
}
|
||||
|
||||
func (u *uRLBuilder) buildManifestURL(name, reference string) string {
|
||||
return fmt.Sprintf("%s/v2/%s/manifests/%s", u.root.String(), name, reference)
|
||||
}
|
||||
|
||||
func (u *uRLBuilder) buildBlobURL(name, reference string) string {
|
||||
return fmt.Sprintf("%s/v2/%s/blobs/%s", u.root.String(), name, reference)
|
||||
}
|
@ -16,7 +16,7 @@
|
||||
<div class="jumbotron">
|
||||
<div class="container">
|
||||
<img class="pull-left" src="static/resources/image/Harbor_Logo_rec.png" alt="Harbor's Logo" height="180" width="360"/>
|
||||
<p class="pull-left" style="margin-top: 85px; color: #245580; font-size: 30pt;">An enterprise-class registry server</p>
|
||||
<p class="pull-left" style="margin-top: 85px; color: #245580; font-size: 30pt; text-align: center; width: 60%;">{{i18n .Lang "index_title"}}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user