Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Issue #121: BasicAuth REST API #147

Merged
merged 1 commit into from
Oct 16, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 73 additions & 26 deletions api/restapi/restapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,18 @@ type RESTAPI struct {
wg sync.WaitGroup
}

// Config provide is used in the NewRESTAPI constructor to define the desired
// parameters for the RESTAPI. The only required field is apiMAddr, the rest
// of the fields are optional. Generally, if an optional field is empty
// the corresponding feature will not be used.
type Config struct {
// required
ApiMAddr ma.Multiaddr
// optional
TLS *tls.Config
BasicAuthCreds map[string]string
}

type route struct {
Name string
Method string
Expand All @@ -67,36 +79,23 @@ type peerAddBody struct {
PeerMultiaddr string `json:"peer_multiaddress"`
}

// NewRESTAPI creates a new REST API component. It receives
// the multiaddress on which the API listens.
func NewRESTAPI(apiMAddr ma.Multiaddr) (*RESTAPI, error) {
n, addr, err := manet.DialArgs(apiMAddr)
// NewRESTAPI creates a new REST API component. It receives the multiaddress on
// which the API listens and a Config object.
func NewRESTAPI(cfg *Config) (*RESTAPI, error) {
n, addr, err := manet.DialArgs(cfg.ApiMAddr)
if err != nil {
return nil, err
}
l, err := net.Listen(n, addr)
if err != nil {
return nil, err
var l net.Listener
if cfg.TLS != nil {
l, err = tls.Listen(n, addr, cfg.TLS)
} else {
l, err = net.Listen(n, addr)
}
return newRESTAPI(apiMAddr, l)
}

// NewTlsRESTAPI creates a new REST API component that uses TLS for security
// (authentication, encryption). It receives the multiaddress on which the API
// listens, as well as paths to certificate and private key files
func NewTLSRESTAPI(apiMAddr ma.Multiaddr, tlsCfg *tls.Config) (*RESTAPI, error) {
n, addr, err := manet.DialArgs(apiMAddr)
if err != nil {
return nil, err
}
l, err := tls.Listen(n, addr, tlsCfg)
if err != nil {
return nil, err
}
return newRESTAPI(apiMAddr, l)
}

func newRESTAPI(apiMAddr ma.Multiaddr, l net.Listener) (*RESTAPI, error) {
router := mux.NewRouter().StrictSlash(true)
s := &http.Server{
ReadTimeout: RESTAPIServerReadTimeout,
Expand All @@ -111,23 +110,71 @@ func newRESTAPI(apiMAddr ma.Multiaddr, l net.Listener) (*RESTAPI, error) {
api := &RESTAPI{
ctx: ctx,
cancel: cancel,
apiAddr: apiMAddr,
apiAddr: cfg.ApiMAddr,
listener: l,
server: s,
rpcReady: make(chan struct{}, 1),
}
api.addRoutes(router, cfg.BasicAuthCreds)
api.run()

return api, nil
}

func (api *RESTAPI) addRoutes(router *mux.Router, basicAuthCreds map[string]string) {
for _, route := range api.routes() {
if basicAuthCreds != nil {
route.HandlerFunc = basicAuth(route.HandlerFunc, basicAuthCreds)
}
router.
Methods(route.Method).
Path(route.Pattern).
Name(route.Name).
Handler(route.HandlerFunc)
}

api.router = router
api.run()
return api, nil
}

func basicAuth(h http.HandlerFunc, credentials map[string]string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
username, password, ok := r.BasicAuth()
if !ok {
resp, err := unauthorizedResp()
if err != nil {
logger.Error(err)
return
}
http.Error(w, resp, 401)
return
}

authorized := false
for u, p := range credentials {
if u == username && p == password {
authorized = true
}
}
if !authorized {
resp, err := unauthorizedResp()
if err != nil {
logger.Error(err)
return
}
http.Error(w, resp, 401)
return
}
h.ServeHTTP(w, r)
}
}

func unauthorizedResp() (string, error) {
apiError := api.Error{
Code: 401,
Message: "Unauthorized",
}
resp, err := json.Marshal(apiError)
return string(resp), err
}

func (rest *RESTAPI) routes() []route {
Expand Down
2 changes: 1 addition & 1 deletion api/restapi/restapi_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ var (
func testRESTAPI(t *testing.T) *RESTAPI {
//logging.SetDebugLogging()
apiMAddr, _ := ma.NewMultiaddr("/ip4/127.0.0.1/tcp/10002")
rest, err := NewRESTAPI(apiMAddr)
rest, err := NewRESTAPI(&Config{ApiMAddr: apiMAddr})
if err != nil {
t.Fatal("should be able to create a new Api: ", err)
}
Expand Down
14 changes: 14 additions & 0 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,12 @@ type Config struct {
// SSLCertFile.
SSLKeyFile string

// BasicAuthCredentials is a map of username, password pairs used to authorize
// access to the REST API. It is *highly recommended* that you use this in
// conjunction with SSL, as the username/password sent by the client are not
// encrypted when using HTTP without SSL.
BasicAuthCredentials map[string]string

// Listen parameters for the IPFS Proxy. Used by the IPFS
// connector component.
IPFSProxyAddr ma.Multiaddr
Expand Down Expand Up @@ -158,6 +164,12 @@ type JSONConfig struct {
// SSLCertFile.
SSLKeyFile string `json:"ssl_key_file,omitempty"`

// BasicAuthCredentials is a map of username, password pairs used to authorize
// access to the REST API. It is *highly recommended* that you use this in
// conjunction with SSL, as the username/password sent by the client are not
// encrypted when using HTTP without SSL.
BasicAuthCredentials map[string]string `json:"basic_auth_credentials"`

// Listen address for the IPFS Proxy, which forwards requests to
// an IPFS daemon.
IPFSProxyListenMultiaddress string `json:"ipfs_proxy_listen_multiaddress"`
Expand Down Expand Up @@ -234,6 +246,7 @@ func (cfg *Config) ToJSONConfig() (j *JSONConfig, err error) {
APIListenMultiaddress: cfg.APIAddr.String(),
SSLCertFile: cfg.SSLCertFile,
SSLKeyFile: cfg.SSLKeyFile,
BasicAuthCredentials: cfg.BasicAuthCredentials,
IPFSProxyListenMultiaddress: cfg.IPFSProxyAddr.String(),
IPFSNodeMultiaddress: cfg.IPFSNodeAddr.String(),
ConsensusDataFolder: cfg.ConsensusDataFolder,
Expand Down Expand Up @@ -347,6 +360,7 @@ func (jcfg *JSONConfig) ToConfig() (c *Config, err error) {
APIAddr: apiAddr,
SSLCertFile: jcfg.SSLCertFile,
SSLKeyFile: jcfg.SSLKeyFile,
BasicAuthCredentials: jcfg.BasicAuthCredentials,
IPFSProxyAddr: ipfsProxyAddr,
IPFSNodeAddr: ipfsNodeAddr,
ConsensusDataFolder: jcfg.ConsensusDataFolder,
Expand Down
1 change: 0 additions & 1 deletion ipfs-cluster-ctl/formatters.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,6 @@ func textFormatObject(body []byte, format int) {
var obj api.Error
textFormatDecodeOn(body, &obj)
textFormatPrintError(&obj)

default:
var obj interface{}
textFormatDecodeOn(body, &obj)
Expand Down
44 changes: 42 additions & 2 deletions ipfs-cluster-ctl/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ var (
defaultTimeout = 60
defaultProtocol = "http"
defaultTransport = http.DefaultTransport
defaultUsername = ""
defaultPassword = ""
)

var logger = logging.Logger("cluster-ctl")
Expand Down Expand Up @@ -89,7 +91,7 @@ func main() {
},
cli.BoolFlag{
Name: "no-check-certificate",
Usage: "do not verify server TLS certificate. only valid with `--https` flag.",
Usage: "do not verify server TLS certificate. only valid with --https flag",
},
cli.StringFlag{
Name: "encoding, enc",
Expand All @@ -105,11 +107,30 @@ func main() {
Name: "debug, d",
Usage: "set debug log level",
},
cli.StringFlag{
Name: "basic-auth",
Usage: `<username>[:<password>] specify BasicAuth credentials for server that
requires authorization. implies --https, which you can disable with --force-http`,
EnvVar: "CLUSTER_CREDENTIALS",
},
cli.BoolFlag{
Name: "force-http, f",
Usage: "force HTTP. only valid when using BasicAuth",
},
}

app.Before = func(c *cli.Context) error {
defaultHost = c.String("host")
defaultTimeout = c.Int("timeout")
// check for BasicAuth credentials
if c.IsSet("basic-auth") {
defaultUsername, defaultPassword = parseCredentials(c.String("basic-auth"))
// turn on HTTPS unless flag says not to
if !c.Bool("force-http") {
err := c.Set("https", "true")
checkErr("setting HTTPS flag for BasicAuth (this should never fail)", err)
}
}
if c.Bool("https") {
defaultProtocol = "https"
defaultTransport = newTLSTransport(c.Bool("no-check-certificate"))
Expand Down Expand Up @@ -447,9 +468,12 @@ func request(method, path string, body io.Reader, args ...string) *http.Response
checkErr("creating request", err)
r.WithContext(ctx)

if len(defaultUsername) != 0 {
r.SetBasicAuth(defaultUsername, defaultPassword)
}

client := &http.Client{Transport: defaultTransport}
resp, err := client.Do(r)
checkErr(fmt.Sprintf("performing request to %s", defaultHost), err)

return resp
}
Expand All @@ -472,6 +496,7 @@ func formatResponse(c *cli.Context, r *http.Response) {
case "text":
if r.StatusCode > 399 {
textFormat(body, formatError)
os.Exit(2)
} else {
textFormat(body, c.Int("parseAs"))
}
Expand All @@ -484,6 +509,21 @@ func formatResponse(c *cli.Context, r *http.Response) {
}
}

func parseCredentials(userInput string) (string, string) {
credentials := strings.SplitN(userInput, ":", 2)
switch len(credentials) {
case 1:
// only username passed in (with no trailing `:`), return empty password
return credentials[0], ""
case 2:
return credentials[0], credentials[1]
default:
err := fmt.Errorf("invalid <username>[:<password>] input")
checkErr("parsing credentials", err)
return "", ""
}
}

// JSON output is nice and allows users to build on top.
func prettyPrint(buf []byte) {
var dst bytes.Buffer
Expand Down
8 changes: 4 additions & 4 deletions ipfs-cluster-service/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -276,13 +276,13 @@ func run(c *cli.Context) error {
}

var api *restapi.RESTAPI
var tlsCfg *tls.Config
if len(cfg.SSLCertFile) != 0 || len(cfg.SSLKeyFile) != 0 {
tlsCfg, err := newTLSConfig(cfg.SSLCertFile, cfg.SSLKeyFile)
tlsCfg, err = newTLSConfig(cfg.SSLCertFile, cfg.SSLKeyFile)
checkErr("creating TLS config: ", err)
api, err = restapi.NewTLSRESTAPI(cfg.APIAddr, tlsCfg)
} else {
api, err = restapi.NewRESTAPI(cfg.APIAddr)
}
apiConfig := &restapi.Config{ApiMAddr: cfg.APIAddr, TLS: tlsCfg, BasicAuthCreds: cfg.BasicAuthCredentials}
api, err = restapi.NewRESTAPI(apiConfig)
checkErr("creating REST API component", err)

proxy, err := ipfshttp.NewConnector(
Expand Down
2 changes: 1 addition & 1 deletion ipfscluster_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ func createComponents(t *testing.T, i int, clusterSecret []byte) (*Config, API,
cfg.ReplicationFactor = -1
cfg.MonitoringIntervalSeconds = 2

api, err := restapi.NewRESTAPI(cfg.APIAddr)
api, err := restapi.NewRESTAPI(&restapi.Config{ApiMAddr: cfg.APIAddr})
checkErr(t, err)
ipfs, err := ipfshttp.NewConnector(
cfg.IPFSNodeAddr,
Expand Down
20 changes: 20 additions & 0 deletions sharness/config/basic_auth/service.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"id": "QmdEtBsfumeH2V6dnx1fgn8zuW7XYjWdgJF4NEYpEBcTsg",
"private_key": "CAASqAkwggSkAgEAAoIBAQC/ZmfWDbwyI0nJdRxgHcTdEaBFQo8sky9E+OOvtwZa5WKoLdHyHOLWxCAdpIHUBbhxz5rkMEWLwPI6ykqLIJToMPO8lJbKVzphOjv4JwpiAPdmeSiYMKLjx5V8MpqU2rwj/Uf3sRL8Gg9/Tei3PZ8cftxN1rkQQeeaOtk0CBxUFZSHEsyut1fbgIeL7TAY+4vCmXW0DBr4wh9fnoES/YivOvSiN9rScgWg6N65LfkI78hzaOJ4Nok2S4vYFCxjTAI9NWFUbhP5eJIFzTU+bZuQZxOn2qsoyw8pNZwuF+JClA/RcgBcCvVZcDH2ueVq/zT++bGCN+EWsAEdvJqJ5bsjAgMBAAECggEAaGDUZ6t94mnUJ4UyQEh7v4OJP7wYkFqEAL0qjfzl/lPyBX1XbQ3Ltwul6AR6uMGV4JszARZCFwDWGLGRDWZrTmTDxyfRQ+9l6vfzFFVWGDQmtz+Dn9uGOWnyX5TJMDxJNec+hBmRHOKpaOd37dYxGz0jr19V9UO7piRJp1J1AHUCypUGv5x1IekioSCu5fEyc7dyWwnmITHBjD08st+bCcjrIUFeXSdJKC8SymYeXdaVE3xH3zVEISKnrfT7bhuKZY1iibZIlXbVLNpyX36LkYJOiCqsMum3u70LH0VvTypkqiDbD4S6qfJ4vvUakpmKpOPutikiP7jkSP+AkaO0AQKBgQDkTuhnDK6+Y0a/HgpHJisji0coO+g2gsIszargHk8nNY2AB8t+EUn7C+Qu8cmrem5V8EXcdxS6z7iAXpJmY1Xepnsz+JP7Q91Lgt3OoqK5EybzUXXKkmNCD65n70Xxn2fEFzm6+GJP3c/HymlDKU2KBCYIyuUeaREjT0Fu3v6tgQKBgQDWnXppJwn4LJHhzFOCeO4zomDJDbLTZCabdKZoFP9r+vtEHAnclDDKx4AYbomSqgERe+DX6HR/tPHRVizP63RYPf7al2mJmPzt1nTkoc1/q5hQoD+oE154dADsW1pUp7AQjwCtys4iq5S0qAwIDpuY8M8bOHwZ+QmBvHYAigJCowKBgQC3HH6TX/2rH463bE2MARXqXSPGJj45sigwrQfW1xhe9zm1LQtN4mn2mvP5nt1D1l82OA6gIzYSGtX8x10eF5/ggqAf78goZ6bOkHh76b8fNzgvQO97eGt5qYAVRjhP8azU/lfEGMEpE1s5/6LrRe41utwSg0C+YkBnlIKDfQDAgQKBgDoBTCF5hK9H1JHzuKpt5uubuo78ndWWnvyrNYKyEirsJddNwLiWcO2NqChyT8qNGkbQdX/Fex89F5KduPTlTYfAEc6g18xxxgK+UM+uj60vArbf6PSTb5gculcnha2VuPdwvx050Cb8uu9s7/uJfzKB+2f/B0O51ID1H+ubYWsDAoGBAKrwGKHyqFTHSPg3XuRA1FgDAoOsfzP9ZJvMEXUWyu/VxjNt+0mRlyGeZ5qb9UZG+K/In4FbC/ux2P/PucCUIbgy/XGPtPXVavMwNbx0MquAcU0FihKXP0CUpi8zwiYc42MF7n/SztQnismxigBMSuJEDurcXXazjfcSRTypduNn",
"cluster_secret": "84399cd0be811c2ca372d6ca473ffd73c09034f991c5e306fe9ada6c5fcfb641",
"cluster_peers": [],
"bootstrap": [],
"leave_on_shutdown": false,
"cluster_multiaddress": "/ip4/0.0.0.0/tcp/9096",
"api_listen_multiaddress": "/ip4/127.0.0.1/tcp/9094",
"basic_auth_credentials": {
"testuser": "testpass"
},
"ipfs_proxy_listen_multiaddress": "/ip4/127.0.0.1/tcp/9095",
"ipfs_node_multiaddress": "/ip4/127.0.0.1/tcp/5001",
"state_sync_seconds": 60,
"ipfs_sync_seconds": 130,
"replication_factor": -1,
"monitoring_interval_seconds": 15,
"allocation_strategy": "reposize"
}
File renamed without changes.
File renamed without changes.
20 changes: 20 additions & 0 deletions sharness/config/ssl-basic_auth/service.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"id": "QmdEtBsfumeH2V6dnx1fgn8zuW7XYjWdgJF4NEYpEBcTsg",
"private_key": "CAASqAkwggSkAgEAAoIBAQC/ZmfWDbwyI0nJdRxgHcTdEaBFQo8sky9E+OOvtwZa5WKoLdHyHOLWxCAdpIHUBbhxz5rkMEWLwPI6ykqLIJToMPO8lJbKVzphOjv4JwpiAPdmeSiYMKLjx5V8MpqU2rwj/Uf3sRL8Gg9/Tei3PZ8cftxN1rkQQeeaOtk0CBxUFZSHEsyut1fbgIeL7TAY+4vCmXW0DBr4wh9fnoES/YivOvSiN9rScgWg6N65LfkI78hzaOJ4Nok2S4vYFCxjTAI9NWFUbhP5eJIFzTU+bZuQZxOn2qsoyw8pNZwuF+JClA/RcgBcCvVZcDH2ueVq/zT++bGCN+EWsAEdvJqJ5bsjAgMBAAECggEAaGDUZ6t94mnUJ4UyQEh7v4OJP7wYkFqEAL0qjfzl/lPyBX1XbQ3Ltwul6AR6uMGV4JszARZCFwDWGLGRDWZrTmTDxyfRQ+9l6vfzFFVWGDQmtz+Dn9uGOWnyX5TJMDxJNec+hBmRHOKpaOd37dYxGz0jr19V9UO7piRJp1J1AHUCypUGv5x1IekioSCu5fEyc7dyWwnmITHBjD08st+bCcjrIUFeXSdJKC8SymYeXdaVE3xH3zVEISKnrfT7bhuKZY1iibZIlXbVLNpyX36LkYJOiCqsMum3u70LH0VvTypkqiDbD4S6qfJ4vvUakpmKpOPutikiP7jkSP+AkaO0AQKBgQDkTuhnDK6+Y0a/HgpHJisji0coO+g2gsIszargHk8nNY2AB8t+EUn7C+Qu8cmrem5V8EXcdxS6z7iAXpJmY1Xepnsz+JP7Q91Lgt3OoqK5EybzUXXKkmNCD65n70Xxn2fEFzm6+GJP3c/HymlDKU2KBCYIyuUeaREjT0Fu3v6tgQKBgQDWnXppJwn4LJHhzFOCeO4zomDJDbLTZCabdKZoFP9r+vtEHAnclDDKx4AYbomSqgERe+DX6HR/tPHRVizP63RYPf7al2mJmPzt1nTkoc1/q5hQoD+oE154dADsW1pUp7AQjwCtys4iq5S0qAwIDpuY8M8bOHwZ+QmBvHYAigJCowKBgQC3HH6TX/2rH463bE2MARXqXSPGJj45sigwrQfW1xhe9zm1LQtN4mn2mvP5nt1D1l82OA6gIzYSGtX8x10eF5/ggqAf78goZ6bOkHh76b8fNzgvQO97eGt5qYAVRjhP8azU/lfEGMEpE1s5/6LrRe41utwSg0C+YkBnlIKDfQDAgQKBgDoBTCF5hK9H1JHzuKpt5uubuo78ndWWnvyrNYKyEirsJddNwLiWcO2NqChyT8qNGkbQdX/Fex89F5KduPTlTYfAEc6g18xxxgK+UM+uj60vArbf6PSTb5gculcnha2VuPdwvx050Cb8uu9s7/uJfzKB+2f/B0O51ID1H+ubYWsDAoGBAKrwGKHyqFTHSPg3XuRA1FgDAoOsfzP9ZJvMEXUWyu/VxjNt+0mRlyGeZ5qb9UZG+K/In4FbC/ux2P/PucCUIbgy/XGPtPXVavMwNbx0MquAcU0FihKXP0CUpi8zwiYc42MF7n/SztQnismxigBMSuJEDurcXXazjfcSRTypduNn",
"cluster_secret": "84399cd0be811c2ca372d6ca473ffd73c09034f991c5e306fe9ada6c5fcfb641",
"cluster_peers": [],
"bootstrap": [],
"leave_on_shutdown": false,
"cluster_multiaddress": "/ip4/0.0.0.0/tcp/9096",
"api_listen_multiaddress": "/ip4/127.0.0.1/tcp/9094",
"ssl_cert_file": "test-config/server.crt",
"ssl_key_file": "test-config/server.key",
"basic_auth_credentials": { "testuser" : "testpass" },
"ipfs_proxy_listen_multiaddress": "/ip4/127.0.0.1/tcp/9095",
"ipfs_node_multiaddress": "/ip4/127.0.0.1/tcp/5001",
"state_sync_seconds": 60,
"ipfs_sync_seconds": 130,
"replication_factor": -1,
"monitoring_interval_seconds": 15,
"allocation_strategy": "reposize"
}
24 changes: 24 additions & 0 deletions sharness/config/ssl/server.crt
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
-----BEGIN CERTIFICATE-----
MIID7TCCAtWgAwIBAgIJAMqpHdKRMzMLMA0GCSqGSIb3DQEBCwUAMIGCMQswCQYD
VQQGEwJVUzERMA8GA1UECAwIQ29sb3JhZG8xDzANBgNVBAcMBmdvbGRlbjEMMAoG
A1UECgwDQ1NNMREwDwYDVQQLDAhTZWN0b3IgNzEMMAoGA1UEAwwDQm9iMSAwHgYJ
KoZIhvcNAQkBFhFtaW5pc3RlckBtb3N3Lm9yZzAeFw0xNzA3MjExNjA5NTlaFw0y
NzA3MTkxNjA5NTlaMIGCMQswCQYDVQQGEwJVUzERMA8GA1UECAwIQ29sb3JhZG8x
DzANBgNVBAcMBmdvbGRlbjEMMAoGA1UECgwDQ1NNMREwDwYDVQQLDAhTZWN0b3Ig
NzEMMAoGA1UEAwwDQm9iMSAwHgYJKoZIhvcNAQkBFhFtaW5pc3RlckBtb3N3Lm9y
ZzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALuoP8PehGItmKPi3+8S
IV1qz8C3FiK85X/INxYLjyuzvpmDROtlkOvdmPCJrveKDZF7ECQpwIGApFbnKCCW
3zdOPQmAVzm4N8bvnzFtM9mTm8qKb9SwRi6ZLZ/qXo98t8C7CV6FaNKUkIw0lUes
ZiXEcmknrlPy3svaDQVoSOH8L38d0g4geqiNrMmZDaGe8FAYdpCoeYDIm/u0Ag9y
G3+XAbETxWhkfTyH3XcQ/Izg0wG9zFY8y/fyYwC+C7+xF75x4gbIzHAY2iFS2ua7
GTKa2GZhOXtMuzJ6cf+TZW460Z+O+PkA1aH01WrGL7iCW/6Cn9gPRKL+IP6iyDnh
9HMCAwEAAaNkMGIwDwYDVR0RBAgwBocEfwAAATAdBgNVHQ4EFgQU9mXv8mv/LlAa
jwr8X9hzk52cBagwHwYDVR0jBBgwFoAU9mXv8mv/LlAajwr8X9hzk52cBagwDwYD
VR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAIxqpKYzF6A9RlLso0lkF
nYfcyeVAvi03IBdiTNnpOe6ROa4gNwKH/JUJMCRDPzm/x78+srCmrcCCAJJTcqgi
b84vq3DegGPg2NXbn9qVUA1SdiXFelqMFwLitDn2KKizihEN4L5PEArHuDaNvLI+
kMr+yZSALWTdtfydj211c7hTBvFqO8l5MYDXCmfoS9sqniorlNHIaBim/SNfDsi6
8hAhvfRvk3e6dPjAPrIZYdQR5ROGewtD4F/anXgKY2BmBtWwd6gbGeMnnVi1SGRP
0UHc4O9aq9HrAOFL/72WVk/kyyPyJ/GtSaPYL1OFS12R/l0hNi+pER7xDtLOVHO2
iw==
-----END CERTIFICATE-----
Loading