Merge branch 'release/20.10'

* release/20.10:
  Cosmetic fix to orders list
  Bump boulder version to release-2020-10-13
  Set high rate limit for our domain; add rate-limits page
  Update jQuery and other vendor js
  Update acme pages to show requested/issued domain name
This commit is contained in:
Arjan H
2020-10-19 18:53:28 +02:00
20 changed files with 1349 additions and 298 deletions

View File

@@ -33,7 +33,7 @@ index 5f93fe866..b4a0b75e0 100644
+ max-file: "5"
+ restart: always
bmysql:
image: mariadb:10.3
image: mariadb:10.5
+ volumes:
+ - dbdata:/var/lib/mysql
networks:

View File

@@ -6,6 +6,7 @@ import (
"net"
"net/http"
"strconv"
"strings"
)
// BaseList is the generic base struct for showing lists of data
@@ -73,10 +74,11 @@ func GetAccounts(w http.ResponseWriter, r *http.Request) (AccountList, error) {
type Order struct {
ID int
RegistrationID int
Expires string
CertSerial string
RequestedName string
BeganProc bool
Created string
Expires string
}
// OrderList is a list of Order records
@@ -117,7 +119,7 @@ func GetAccount(w http.ResponseWriter, r *http.Request, id int) (AccountShow, er
defer db.Close()
rows, err := db.Query("SELECT c.id, c.registrationID, c.serial, CASE WHEN cs.notAfter < NOW() THEN 'expired' ELSE cs.status END AS status, c.issued, c.expires FROM certificates c JOIN certificateStatus cs ON cs.id = c.id WHERE registrationID=?", strconv.Itoa(id))
rows, err := db.Query("SELECT c.id, c.registrationID, c.serial, n.reversedName, CASE WHEN cs.notAfter < NOW() THEN 'expired' ELSE cs.status END AS status, c.issued, c.expires FROM certificates c JOIN certificateStatus cs ON cs.id = c.id JOIN issuedNames n ON n.serial = c.serial WHERE registrationID=?", strconv.Itoa(id))
if err != nil {
errorHandler(w, r, err, http.StatusInternalServerError)
return AccountShow{}, err
@@ -127,22 +129,23 @@ func GetAccount(w http.ResponseWriter, r *http.Request, id int) (AccountShow, er
BaseList: BaseList{
Title: "Certificates",
TableClass: "rel_certificates_list",
Header: []template.HTML{"ID", "Account ID", "Serial", "Status", "Issued", "Expires"},
Header: []template.HTML{"ID", "Account ID", "Serial", "Issued Name", "Status", "Issued", "Expires"},
},
Rows: []Certificate{},
}
for rows.Next() {
row := Certificate{}
err = rows.Scan(&row.ID, &row.RegistrationID, &row.Serial, &row.Status, &row.Issued, &row.Expires)
err = rows.Scan(&row.ID, &row.RegistrationID, &row.Serial, &row.IssuedName, &row.Status, &row.Issued, &row.Expires)
if err != nil {
errorHandler(w, r, err, http.StatusInternalServerError)
return AccountShow{}, err
}
row.IssuedName = ReverseName(row.IssuedName)
Certificates.Rows = append(Certificates.Rows, row)
}
rows, err = db.Query("SELECT id, registrationID, expires, certificateSerial, beganProcessing, created FROM orders WHERE registrationID=?", strconv.Itoa(id))
rows, err = db.Query("SELECT o.id, o.registrationID, o.certificateSerial, n.reversedName, o.beganProcessing, o.created, o.expires FROM orders o JOIN requestedNames n ON n.orderID = o.id WHERE o.registrationID=?", strconv.Itoa(id))
if err != nil {
errorHandler(w, r, err, http.StatusInternalServerError)
return AccountShow{}, err
@@ -152,18 +155,19 @@ func GetAccount(w http.ResponseWriter, r *http.Request, id int) (AccountShow, er
BaseList: BaseList{
Title: "Orders",
TableClass: "rel_orders_list",
Header: []template.HTML{"ID", "Account ID", "Expires", "Certificate Serial", "Began Processing?", "Created"},
Header: []template.HTML{"ID", "Account ID", "Certificate Serial", "Requested Name", "Began Processing?", "Created", "Expires", },
},
Rows: []Order{},
}
for rows.Next() {
row := Order{}
err = rows.Scan(&row.ID, &row.RegistrationID, &row.Expires, &row.CertSerial, &row.BeganProc, &row.Created)
err = rows.Scan(&row.ID, &row.RegistrationID, &row.CertSerial, &row.RequestedName, &row.BeganProc, &row.Created, &row.Expires)
if err != nil {
errorHandler(w, r, err, http.StatusInternalServerError)
return AccountShow{}, err
}
row.RequestedName = ReverseName(row.RequestedName)
Orders.Rows = append(Orders.Rows, row)
}
@@ -211,7 +215,7 @@ func GetOrders(w http.ResponseWriter, r *http.Request) (OrderList, error) {
defer db.Close()
rows, err := db.Query("SELECT id, registrationID, expires, certificateSerial, beganProcessing, created FROM orders")
rows, err := db.Query("SELECT o.id, o.registrationID, o.certificateSerial, n.reversedName, o.beganProcessing, o.created, o.expires FROM orders o JOIN requestedNames n ON n.orderID = o.id")
if err != nil {
errorHandler(w, r, err, http.StatusInternalServerError)
return OrderList{}, err
@@ -221,18 +225,19 @@ func GetOrders(w http.ResponseWriter, r *http.Request) (OrderList, error) {
BaseList: BaseList{
Title: "Orders",
TableClass: "orders_list",
Header: []template.HTML{"ID", "Account ID", "Expires", "Certificate Serial", "Began Processing?", "Created"},
Header: []template.HTML{"ID", "Account ID", "Certificate Serial", "Requested Name", "Began Processing?", "Created", "Expires"},
},
Rows: []Order{},
}
for rows.Next() {
row := Order{}
err = rows.Scan(&row.ID, &row.RegistrationID, &row.Expires, &row.CertSerial, &row.BeganProc, &row.Created)
err = rows.Scan(&row.ID, &row.RegistrationID, &row.CertSerial, &row.RequestedName, &row.BeganProc, &row.Created, &row.Expires)
if err != nil {
errorHandler(w, r, err, http.StatusInternalServerError)
return OrderList{}, err
}
row.RequestedName = ReverseName(row.RequestedName)
Orders.Rows = append(Orders.Rows, row)
}
@@ -241,12 +246,11 @@ func GetOrders(w http.ResponseWriter, r *http.Request) (OrderList, error) {
// Auth contains the data representing an ACME auth
type Auth struct {
ID string
Identifier string
RegistrationID int
Status string
Expires string
Combinations string
ID string
Identifier string
RegistrationID int
Status string
Expires string
}
// AuthList is a list of Auth records
@@ -262,6 +266,15 @@ type OrderShow struct {
Related2 []BaseList
}
// Helper method from sa/model.go
var uintToStatus = map[int]string{
0: "pending",
1: "valid",
2: "invalid",
3: "deactivated",
4: "revoked",
}
// GetOrder returns an order with the given id
func GetOrder(w http.ResponseWriter, r *http.Request, id int) (OrderShow, error) {
db, err := sql.Open(dbType, dbConn)
@@ -272,9 +285,11 @@ func GetOrder(w http.ResponseWriter, r *http.Request, id int) (OrderShow, error)
defer db.Close()
partial := "SELECT id, identifier, registrationID, status, expires, combinations FROM "
partial := "SELECT id, identifier, registrationID, status, expires FROM "
where := " WHERE id IN (SELECT authzID FROM orderToAuthz WHERE orderID=?)"
rows, err := db.Query(partial+"authz"+where+" UNION "+partial+"pendingAuthorizations"+where, strconv.Itoa(id), strconv.Itoa(id))
partial2 := "SELECT id, identifierValue, registrationID, status, expires FROM "
where2 := " WHERE id IN (SELECT authzID FROM orderToAuthz2 WHERE orderID=?)"
rows, err := db.Query(partial+"authz"+where+" UNION "+partial+"pendingAuthorizations"+where+" UNION "+partial2+"authz2"+where2, strconv.Itoa(id), strconv.Itoa(id), strconv.Itoa(id))
if err != nil {
errorHandler(w, r, err, http.StatusInternalServerError)
return OrderShow{}, err
@@ -284,22 +299,25 @@ func GetOrder(w http.ResponseWriter, r *http.Request, id int) (OrderShow, error)
BaseList: BaseList{
Title: "Authorizations",
TableClass: "rel_authz_list",
Header: []template.HTML{"ID", "Identifier", "Account ID", "Status", "Expires", "Combinations"},
Header: []template.HTML{"ID", "Identifier", "Account ID", "Status", "Expires"},
},
Rows: []Auth{},
}
for rows.Next() {
row := Auth{}
err = rows.Scan(&row.ID, &row.Identifier, &row.RegistrationID, &row.Status, &row.Expires, &row.Combinations)
err = rows.Scan(&row.ID, &row.Identifier, &row.RegistrationID, &row.Status, &row.Expires)
if err != nil {
errorHandler(w, r, err, http.StatusInternalServerError)
return OrderShow{}, err
}
if s, err := strconv.Atoi(row.Status); err == nil {
row.Status = uintToStatus[s]
}
Authz.Rows = append(Authz.Rows, row)
}
rows, err = db.Query("SELECT id, registrationID, expires, certificateSerial, beganProcessing, created FROM orders WHERE id=?", strconv.Itoa(id))
rows, err = db.Query("SELECT o.id, o.registrationID, o.certificateSerial, n.reversedName, o.beganProcessing, o.created, o.expires FROM orders o JOIN requestedNames n ON n.orderID = o.id WHERE o.id=?", strconv.Itoa(id))
if err != nil {
errorHandler(w, r, err, http.StatusInternalServerError)
return OrderShow{}, err
@@ -317,21 +335,22 @@ func GetOrder(w http.ResponseWriter, r *http.Request, id int) (OrderShow, error)
for rows.Next() {
row := Order{}
err = rows.Scan(&row.ID, &row.RegistrationID, &row.Expires, &row.CertSerial, &row.BeganProc, &row.Created)
err = rows.Scan(&row.ID, &row.RegistrationID, &row.CertSerial, &row.RequestedName, &row.BeganProc, &row.Created, &row.Expires)
if err != nil {
errorHandler(w, r, err, http.StatusInternalServerError)
return OrderShow{}, err
}
OrderDetails.Rows = append(OrderDetails.Rows, NameValue{"ID", strconv.Itoa(row.ID)})
OrderDetails.Rows = append(OrderDetails.Rows, NameValue{"Expires", row.Expires})
v := "false"
if row.BeganProc {
v = "true"
}
OrderDetails.Rows = append(OrderDetails.Rows, NameValue{"Began Processing?", v})
OrderDetails.Rows = append(OrderDetails.Rows, NameValue{"Created", row.Created})
OrderDetails.Rows = append(OrderDetails.Rows, NameValue{"Expires", row.Expires})
OrderDetails.Links = append(OrderDetails.Links, NameValHTML{"Certificate", template.HTML("<a href=\"" + r.Header.Get("X-Request-Base") + "/certificates/" + row.CertSerial + "\">" + row.CertSerial + "</a>")})
OrderDetails.Rows = append(OrderDetails.Rows, NameValue{"Requested Name", ReverseName(row.RequestedName)})
OrderDetails.Links = append(OrderDetails.Links, NameValHTML{"Account", template.HTML("<a href=\"" + r.Header.Get("X-Request-Base") + "/accounts/" + strconv.Itoa(row.RegistrationID) + "\">" + strconv.Itoa(row.RegistrationID) + "</a>")})
}
@@ -348,7 +367,7 @@ func GetAuthz(w http.ResponseWriter, r *http.Request) (AuthList, error) {
defer db.Close()
rows, err := db.Query("SELECT id, identifier, registrationID, status, expires, combinations FROM authz UNION SELECT id, identifier, registrationID, status, expires, combinations FROM pendingAuthorizations")
rows, err := db.Query("SELECT id, identifier, registrationID, status, expires FROM authz UNION SELECT id, identifier, registrationID, status, expires FROM pendingAuthorizations UNION SELECT id, identifierValue, registrationID, status, expires FROM authz2")
if err != nil {
errorHandler(w, r, err, http.StatusInternalServerError)
return AuthList{}, err
@@ -358,18 +377,21 @@ func GetAuthz(w http.ResponseWriter, r *http.Request) (AuthList, error) {
BaseList: BaseList{
Title: "Authorizations",
TableClass: "authz_list",
Header: []template.HTML{"ID", "Identifier", "Account ID", "Status", "Expires", "Combinations"},
Header: []template.HTML{"ID", "Identifier", "Account ID", "Status", "Expires"},
},
Rows: []Auth{},
}
for rows.Next() {
row := Auth{}
err = rows.Scan(&row.ID, &row.Identifier, &row.RegistrationID, &row.Status, &row.Expires, &row.Combinations)
err = rows.Scan(&row.ID, &row.Identifier, &row.RegistrationID, &row.Status, &row.Expires)
if err != nil {
errorHandler(w, r, err, http.StatusInternalServerError)
return AuthList{}, err
}
if s, err := strconv.Atoi(row.Status); err == nil {
row.Status = uintToStatus[s]
}
Authz.Rows = append(Authz.Rows, row)
}
@@ -441,9 +463,11 @@ func GetAuth(w http.ResponseWriter, r *http.Request, id string) (AuthShow, error
Challenges.Rows = append(Challenges.Rows, row)
}
partial := "SELECT id, identifier, registrationID, status, expires, combinations FROM "
partial := "SELECT id, identifier, registrationID, status, expires, '', '' FROM "
where := " WHERE id IN (SELECT authzID FROM orderToAuthz WHERE id=?)"
rows, err = db.Query(partial+"authz"+where+" UNION "+partial+"pendingAuthorizations"+where, id, id)
partial2 := "SELECT id, identifierValue, registrationID, status, expires, validationError, validationRecord FROM "
where2 := " WHERE id IN (SELECT authzID FROM orderToAuthz2 WHERE id=?)"
rows, err = db.Query(partial+"authz"+where+" UNION "+partial+"pendingAuthorizations"+where+" UNION "+partial2+"authz2"+where2, id, id, id)
if err != nil {
errorHandler(w, r, err, http.StatusInternalServerError)
return AuthShow{}, err
@@ -461,16 +485,26 @@ func GetAuth(w http.ResponseWriter, r *http.Request, id string) (AuthShow, error
for rows.Next() {
row := Auth{}
err = rows.Scan(&row.ID, &row.Identifier, &row.RegistrationID, &row.Status, &row.Expires, &row.Combinations)
validationError := sql.NullString{}
validationRecord := sql.NullString{}
err = rows.Scan(&row.ID, &row.Identifier, &row.RegistrationID, &row.Status, &row.Expires, &validationError, &validationRecord)
if err != nil {
errorHandler(w, r, err, http.StatusInternalServerError)
return AuthShow{}, err
}
AuthDetails.Rows = append(AuthDetails.Rows, NameValue{"ID", row.ID})
AuthDetails.Rows = append(AuthDetails.Rows, NameValue{"Identifier", row.Identifier})
if s, err := strconv.Atoi(row.Status); err == nil {
row.Status = uintToStatus[s]
}
AuthDetails.Rows = append(AuthDetails.Rows, NameValue{"Status", row.Status})
AuthDetails.Rows = append(AuthDetails.Rows, NameValue{"Expires", row.Expires})
AuthDetails.Rows = append(AuthDetails.Rows, NameValue{"Combinations", row.Combinations})
if validationError.Valid && validationError.String != "" {
AuthDetails.Rows = append(AuthDetails.Rows, NameValue{"Validation Error", validationError.String})
}
if validationRecord.Valid && validationRecord.String != "" {
AuthDetails.Rows = append(AuthDetails.Rows, NameValue{"Validation Record", validationRecord.String})
}
Link := NameValHTML{"Account", template.HTML("<a href=\"" + r.Header.Get("X-Request-Base") + "/accounts/" + strconv.Itoa(row.RegistrationID) + "\">" + strconv.Itoa(row.RegistrationID) + "</a>")}
AuthDetails.Links = append(AuthDetails.Links, Link)
@@ -576,6 +610,7 @@ type Certificate struct {
ID int
RegistrationID int
Serial string
IssuedName string
Status string
Issued string
Expires string
@@ -587,6 +622,15 @@ type CertificateList struct {
Rows []Certificate
}
// domains are stored in reverse order...
func ReverseName(domain string) string {
labels := strings.Split(domain, ".")
for i, j := 0, len(labels)-1; i < j; i, j = i+1, j-1 {
labels[i], labels[j] = labels[j], labels[i]
}
return strings.Join(labels, ".")
}
// GetCertificates returns the list of certificates
func GetCertificates(w http.ResponseWriter, r *http.Request) (CertificateList, error) {
db, err := sql.Open(dbType, dbConn)
@@ -606,7 +650,7 @@ func GetCertificates(w http.ResponseWriter, r *http.Request) (CertificateList, e
where = " WHERE cs.revokedDate<>'0000-00-00 00:00:00'"
}
rows, err := db.Query("SELECT c.id, c.registrationID, c.serial, CASE WHEN cs.notAfter < NOW() THEN 'expired' ELSE cs.status END AS status, c.issued, c.expires FROM certificates c JOIN certificateStatus cs ON cs.id = c.id" + where)
rows, err := db.Query("SELECT c.id, c.registrationID, c.serial, n.reversedName, CASE WHEN cs.notAfter < NOW() THEN 'expired' ELSE cs.status END AS status, c.issued, c.expires FROM certificates c JOIN certificateStatus cs ON cs.id = c.id JOIN issuedNames n ON n.serial = c.serial" + where)
if err != nil {
errorHandler(w, r, err, http.StatusInternalServerError)
return CertificateList{}, err
@@ -616,18 +660,19 @@ func GetCertificates(w http.ResponseWriter, r *http.Request) (CertificateList, e
BaseList: BaseList{
Title: "Certificates",
TableClass: "certificates_list",
Header: []template.HTML{"ID", "Account ID", "Serial", "Status", "Issued", "Expires"},
Header: []template.HTML{"ID", "Account ID", "Serial", "Issued Name", "Status", "Issued", "Expires"},
},
Rows: []Certificate{},
}
for rows.Next() {
row := Certificate{}
err = rows.Scan(&row.ID, &row.RegistrationID, &row.Serial, &row.Status, &row.Issued, &row.Expires)
err = rows.Scan(&row.ID, &row.RegistrationID, &row.Serial, &row.IssuedName, &row.Status, &row.Issued, &row.Expires)
if err != nil {
errorHandler(w, r, err, http.StatusInternalServerError)
return CertificateList{}, err
}
row.IssuedName = ReverseName(row.IssuedName)
Certificates.Rows = append(Certificates.Rows, row)
}
@@ -646,6 +691,7 @@ type CertificateExtra struct {
ID int
RegistrationID int
Serial string
IssuedName string
Digest string
Issued string
Expires string
@@ -700,7 +746,7 @@ func GetCertificate(w http.ResponseWriter, r *http.Request, id int, serial strin
defer db.Close()
var rows *sql.Rows
selectWhere := "SELECT c.id, c.registrationID, c.serial, c.digest, c.issued, c.expires, cs.subscriberApproved, CASE WHEN cs.notAfter < NOW() THEN 'expired' ELSE cs.status END AS status, cs.ocspLastUpdated, cs.revokedDate, cs.revokedReason, cs.lastExpirationNagSent, cs.notAfter, cs.isExpired FROM certificates c JOIN certificateStatus cs ON cs.id = c.id WHERE "
selectWhere := "SELECT c.id, c.registrationID, c.serial, n.reversedName, c.digest, c.issued, c.expires, cs.subscriberApproved, CASE WHEN cs.notAfter < NOW() THEN 'expired' ELSE cs.status END AS status, cs.ocspLastUpdated, cs.revokedDate, cs.revokedReason, cs.lastExpirationNagSent, cs.notAfter, cs.isExpired FROM certificates c JOIN certificateStatus cs ON cs.id = c.id JOIN issuedNames n ON n.serial = c.serial WHERE "
if serial != "" {
rows, err = db.Query(selectWhere+"c.serial=?", serial)
@@ -723,13 +769,14 @@ func GetCertificate(w http.ResponseWriter, r *http.Request, id int, serial strin
for rows.Next() {
row := CertificateExtra{}
err = rows.Scan(&row.ID, &row.RegistrationID, &row.Serial, &row.Digest, &row.Issued, &row.Expires, &row.SubscriberApproved, &row.Status, &row.OCSPLastUpdate, &row.Revoked, &row.RevokedReason, &row.LastNagSent, &row.NotAfter, &row.IsExpired)
err = rows.Scan(&row.ID, &row.RegistrationID, &row.Serial, &row.IssuedName, &row.Digest, &row.Issued, &row.Expires, &row.SubscriberApproved, &row.Status, &row.OCSPLastUpdate, &row.Revoked, &row.RevokedReason, &row.LastNagSent, &row.NotAfter, &row.IsExpired)
if err != nil {
errorHandler(w, r, err, http.StatusInternalServerError)
return CertificateShow{}, err
}
CertificateDetails.Rows = append(CertificateDetails.Rows, NameValue{"ID", strconv.Itoa(row.ID)})
CertificateDetails.Rows = append(CertificateDetails.Rows, NameValue{"Serial", row.Serial})
CertificateDetails.Rows = append(CertificateDetails.Rows, NameValue{"Issued Name", ReverseName(row.IssuedName)})
CertificateDetails.Rows = append(CertificateDetails.Rows, NameValue{"Digest", row.Digest})
CertificateDetails.Rows = append(CertificateDetails.Rows, NameValue{"Issued", row.Issued})
CertificateDetails.Rows = append(CertificateDetails.Rows, NameValue{"Expires", row.Expires})

View File

@@ -27,6 +27,9 @@ fi
if [ "$PKI_DOMAIN_MODE" == "lockdown" ] || [ "$PKI_DOMAIN_MODE" == "whitelist" ]; then
sed -i -e "s/^\(.*\)\(\"n_subject_common_name_included\"\)/\1\2,\n\1\"e_dnsname_not_valid_tld\"/" config/ca-a.json
sed -i -e "s/^\(.*\)\(\"n_subject_common_name_included\"\)/\1\2,\n\1\"e_dnsname_not_valid_tld\"/" config/ca-b.json
sed -i -e "s/\( registrationOverrides:\)/ $PKI_LOCKDOWN_DOMAINS: 10000\n\1/" rate-limit-policies.yml
echo " $PKI_LOCKDOWN_DOMAINS: 10000" >> rate-limit-policies.yml
fi
if [ "$PKI_EXTENDED_TIMEOUT" == "1" ]; then
@@ -85,4 +88,4 @@ cp -p $PKI_ROOT_CERT_BASE.pem test-root.pem
openssl rsa -in $PKI_ROOT_CERT_BASE.key -pubout > test-root.pubkey.pem
openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in test-root.key -out test-root.p8
chown -R `ls -l rate-limit-policies.yml | cut -d" " -f 3,4 | sed 's/ /:/g'` .
chown -R `ls -l PKI.md | cut -d" " -f 3,4 | sed 's/ /:/g'` .

View File

@@ -2255,7 +2255,7 @@ func activeNav(active string, uri string, requestBase string) []navItem {
Name: "Public Area",
Icon: "fa-home",
Attrs: map[template.HTMLAttr]string{
"href": "/",
"href": "http://" + viper.GetString("labca.fqdn"),
"title": "The non-Admin pages of this LabCA instance",
},
}

View File

@@ -17,7 +17,7 @@
<p>Also if you are developing your own client application or integrating one into your own application, a local test ACME can be very handy. There is a lot of information on the internet about setting up your own PKI (Public Key Infrastructure) but those are usually not automated.</p>
<p>Getting Boulder up and running has quite a learning curve though and that is where <b>LabCA</b> comes in. It is a self-contained installation with a nice web GUI built on top of Boulder so you can quickly start using it. All regular management tasks can be done from the web interface. It is best installed in a Virtual Machine and uses Debian Linux as a base.</p>
<p>Getting Boulder up and running has quite a learning curve though and that is where <b><a href="https://lab-ca.net/">LabCA</a></b> comes in. It is a self-contained installation with a nice web GUI built on top of Boulder so you can quickly start using it. All regular management tasks can be done from the web interface. It is best installed in a Virtual Machine and uses Debian Linux as a base.</p>
<p>NOTE: although LabCA tries to be as robust as possible, use it at your own risk. If you depend on it, make sure that you know what you are doing!</p>
{{ end }}

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# LabCA: a private Certificate Authority for internal lab usage
# (c) 2018 Arjan Hakkesteegt
# (c) 2018-2020 Arjan Hakkesteegt
#
# Install with this command from a Linux machine (only tested with Debian 9):
# curl -sSL https://raw.githubusercontent.com/hakwerk/labca/master/install | bash
@@ -24,7 +24,7 @@ dockerComposeVersion="1.22.0"
labcaUrl="https://github.com/hakwerk/labca/"
boulderUrl="https://github.com/letsencrypt/boulder/"
boulderTag="release-2020-09-09"
boulderTag="release-2020-10-13"
#
# Color configuration

View File

@@ -1,5 +1,5 @@
diff --git a/mail/mailer.go b/mail/mailer.go
index de6b1de20..60c58128b 100644
index bb5bacaf2..946992dca 100644
--- a/mail/mailer.go
+++ b/mail/mailer.go
@@ -20,10 +20,14 @@ import (
@@ -17,7 +17,7 @@ index de6b1de20..60c58128b 100644
)
type idGenerator interface {
@@ -119,6 +123,7 @@ func New(
@@ -121,6 +125,7 @@ func New(
username,
password string,
rootCAs *x509.CertPool,
@@ -25,7 +25,7 @@ index de6b1de20..60c58128b 100644
from mail.Address,
logger blog.Logger,
stats prometheus.Registerer,
@@ -138,6 +143,7 @@ func New(
@@ -140,6 +145,7 @@ func New(
server: server,
port: port,
rootCAs: rootCAs,
@@ -33,7 +33,7 @@ index de6b1de20..60c58128b 100644
},
log: logger,
from: from,
@@ -178,7 +184,7 @@ func (m *MailerImpl) generateMessage(to []string, subject, body string) ([]byte,
@@ -180,7 +186,7 @@ func (m *MailerImpl) generateMessage(to []string, subject, body string) ([]byte,
fmt.Sprintf("To: %s", strings.Join(addrs, ", ")),
fmt.Sprintf("From: %s", m.from.String()),
fmt.Sprintf("Subject: %s", subject),
@@ -42,7 +42,7 @@ index de6b1de20..60c58128b 100644
fmt.Sprintf("Message-Id: <%s.%s.%s>", now.Format("20060102T150405"), mid.String(), m.from.Address),
"MIME-Version: 1.0",
"Content-Type: text/plain; charset=UTF-8",
@@ -235,23 +241,32 @@ func (m *MailerImpl) Connect() error {
@@ -237,23 +243,32 @@ func (m *MailerImpl) Connect() error {
type dialerImpl struct {
username, password, server, port string
rootCAs *x509.CertPool

View File

@@ -23,6 +23,10 @@ server {
proxy_pass http://127.0.0.1:4002/;
}
location /rate-limits {
try_files $uri $uri.html $uri/ =404;
}
location /terms/ {
try_files $uri $uri.html $uri/ =404;
}
@@ -77,6 +81,10 @@ server {
proxy_pass http://127.0.0.1:4002/;
}
location /rate-limits {
try_files $uri $uri.html $uri/ =404;
}
location /terms/ {
try_files $uri $uri.html $uri/ =404;
}

View File

@@ -1,8 +1,8 @@
diff --git a/cmd/notify-mailer/main.go b/cmd/notify-mailer/main.go
index 0445a04c0..ba2be9e2f 100644
index e00541cb1..39af62530 100644
--- a/cmd/notify-mailer/main.go
+++ b/cmd/notify-mailer/main.go
@@ -37,6 +37,7 @@ type mailer struct {
@@ -38,6 +38,7 @@ type mailer struct {
destinations []recipient
targetRange interval
sleepInterval time.Duration
@@ -10,7 +10,7 @@ index 0445a04c0..ba2be9e2f 100644
}
// interval defines a range of email addresses to send to, alphabetically.
@@ -146,7 +147,7 @@ func (m *mailer) run() error {
@@ -147,7 +148,7 @@ func (m *mailer) run() error {
m.log.Debugf("skipping %q: out of target range")
continue
}

View File

@@ -1,16 +1,16 @@
diff --git a/ra/ra.go b/ra/ra.go
index a92965189..aeccb9c3c 100644
index ca21ace0e..6d90d7eff 100644
--- a/ra/ra.go
+++ b/ra/ra.go
@@ -28,7 +28,6 @@ import (
"github.com/letsencrypt/boulder/identifier"
@@ -29,7 +29,6 @@ import (
"github.com/letsencrypt/boulder/issuance"
blog "github.com/letsencrypt/boulder/log"
"github.com/letsencrypt/boulder/metrics"
- "github.com/letsencrypt/boulder/policy"
"github.com/letsencrypt/boulder/probs"
rapb "github.com/letsencrypt/boulder/ra/proto"
"github.com/letsencrypt/boulder/ratelimit"
@@ -399,7 +398,7 @@ func (ra *RegistrationAuthorityImpl) validateContacts(ctx context.Context, conta
@@ -400,7 +399,7 @@ func (ra *RegistrationAuthorityImpl) validateContacts(ctx context.Context, conta
contact,
)
}

View File

@@ -4,14 +4,14 @@ index be064a52e..e7ef8fcf6 100644
+++ b/test/config/ca-a.json
@@ -30,11 +30,7 @@
},
"Issuers": [{
"ConfigFile": "test/test-ca.key-pkcs11.json",
- "CertFile": "/tmp/intermediate-cert-rsa-a.pem",
- "NumSessions": 2
"issuers": [{
"configFile": "test/test-ca.key-pkcs11.json",
- "certFile": "/tmp/intermediate-cert-rsa-a.pem",
- "numSessions": 2
- },{
- "ConfigFile": "test/test-ca.key-pkcs11.json",
- "CertFile": "/tmp/intermediate-cert-rsa-b.pem",
+ "CertFile": "test/test-ca.pem",
"NumSessions": 2
- "configFile": "test/test-ca.key-pkcs11.json",
- "certFile": "/tmp/intermediate-cert-rsa-b.pem",
+ "certFile": "test/test-ca.pem",
"numSessions": 2
}],
"expiry": "2160h",

View File

@@ -4,14 +4,14 @@ index ed2498f1a..4d24ffa94 100644
+++ b/test/config/ca-b.json
@@ -30,11 +30,7 @@
},
"Issuers": [{
"ConfigFile": "test/test-ca.key-pkcs11.json",
- "CertFile": "/tmp/intermediate-cert-rsa-a.pem",
- "NumSessions": 2
"issuers": [{
"configFile": "test/test-ca.key-pkcs11.json",
- "certFile": "/tmp/intermediate-cert-rsa-a.pem",
- "numSessions": 2
- },{
- "ConfigFile": "test/test-ca.key-pkcs11.json",
- "CertFile": "/tmp/intermediate-cert-rsa-b.pem",
+ "CertFile": "test/test-ca.pem",
"NumSessions": 2
- "configFile": "test/test-ca.key-pkcs11.json",
- "certFile": "/tmp/intermediate-cert-rsa-b.pem",
+ "certFile": "test/test-ca.pem",
"numSessions": 2
}],
"expiry": "2160h",

View File

@@ -118,7 +118,7 @@
<small></small>
</div>
<div class="col-sm-6 footer text-muted text-right" id="footer">
<small>Copyright &copy; 2018 LabCA</small>
<small>Copyright &copy; 2018-2020 LabCA</small>
</div>
</div>
</div>

View File

@@ -151,3 +151,10 @@ a.update:hover {
.rel-notes-title {
font-weight: bold;
}
pre.json {
background-color: transparent;
border: none;
margin: 0px;
padding: 0px;
}

View File

@@ -1,4 +1,14 @@
/*! DataTables Bootstrap 3 integration
* ©2011-2015 SpryMedia Ltd - datatables.net/license
*/
(function(a){if(typeof define==="function"&&define.amd){define(["jquery","datatables.net"],function(b){return a(b,window,document)})}else{if(typeof exports==="object"){module.exports=function(b,c){if(!b){b=window}if(!c||!c.fn.dataTable){c=require("datatables.net")(b,c).$}return a(c,b,b.document)}}else{a(jQuery,window,document)}}}(function(d,b,a,e){var c=d.fn.dataTable;d.extend(true,c.defaults,{dom:"<'row'<'col-sm-6'l><'col-sm-6'f>><'row'<'col-sm-12'tr>><'row'<'col-sm-5'i><'col-sm-7'p>>",renderer:"bootstrap"});d.extend(c.ext.classes,{sWrapper:"dataTables_wrapper form-inline dt-bootstrap",sFilterInput:"form-control input-sm",sLengthSelect:"form-control input-sm",sProcessing:"dataTables_processing panel panel-default"});c.ext.renderer.pageButton.bootstrap=function(l,u,t,r,q,j){var o=new c.Api(l);var m=l.oClasses;var i=l.oLanguage.oPaginate;var s=l.oLanguage.oAria.paginate||{};var h,g,f=0;var n=function(w,A){var y,v,z,x;var B=function(C){C.preventDefault();if(!d(C.currentTarget).hasClass("disabled")&&o.page()!=C.data.action){o.page(C.data.action).draw("page")}};for(y=0,v=A.length;y<v;y++){x=A[y];if(d.isArray(x)){n(w,x)}else{h="";g="";switch(x){case"ellipsis":h="&#x2026;";g="disabled";break;case"first":h=i.sFirst;g=x+(q>0?"":" disabled");break;case"previous":h=i.sPrevious;g=x+(q>0?"":" disabled");break;case"next":h=i.sNext;g=x+(q<j-1?"":" disabled");break;case"last":h=i.sLast;g=x+(q<j-1?"":" disabled");break;default:h=x+1;g=q===x?"active":"";break}if(h){z=d("<li>",{"class":m.sPageButton+" "+g,id:t===0&&typeof x==="string"?l.sTableId+"_"+x:null}).append(d("<a>",{href:"#","aria-controls":l.sTableId,"aria-label":s[x],"data-dt-idx":f,tabindex:l.iTabIndex}).html(h)).appendTo(w);l.oApi._fnBindAction(z,{action:x},B);f++}}}};var k;try{k=d(u).find(a.activeElement).data("dt-idx")}catch(p){}n(d(u).empty().html('<ul class="pagination"/>').children("ul"),r);if(k!==e){d(u).find("[data-dt-idx="+k+"]").focus()}};return c}));
/*!
DataTables Bootstrap 3 integration
©2011-2015 SpryMedia Ltd - datatables.net/license
*/
var $jscomp=$jscomp||{};$jscomp.scope={};$jscomp.findInternal=function(a,b,c){a instanceof String&&(a=String(a));for(var e=a.length,d=0;d<e;d++){var f=a[d];if(b.call(c,f,d,a))return{i:d,v:f}}return{i:-1,v:void 0}};$jscomp.ASSUME_ES5=!1;$jscomp.ASSUME_NO_NATIVE_MAP=!1;$jscomp.ASSUME_NO_NATIVE_SET=!1;$jscomp.SIMPLE_FROUND_POLYFILL=!1;$jscomp.ISOLATE_POLYFILLS=!1;
$jscomp.defineProperty=$jscomp.ASSUME_ES5||"function"==typeof Object.defineProperties?Object.defineProperty:function(a,b,c){if(a==Array.prototype||a==Object.prototype)return a;a[b]=c.value;return a};$jscomp.getGlobal=function(a){a=["object"==typeof globalThis&&globalThis,a,"object"==typeof window&&window,"object"==typeof self&&self,"object"==typeof global&&global];for(var b=0;b<a.length;++b){var c=a[b];if(c&&c.Math==Math)return c}throw Error("Cannot find global object");};$jscomp.global=$jscomp.getGlobal(this);
$jscomp.IS_SYMBOL_NATIVE="function"===typeof Symbol&&"symbol"===typeof Symbol("x");$jscomp.TRUST_ES6_POLYFILLS=!$jscomp.ISOLATE_POLYFILLS||$jscomp.IS_SYMBOL_NATIVE;$jscomp.polyfills={};$jscomp.propertyToPolyfillSymbol={};$jscomp.POLYFILL_PREFIX="$jscp$";var $jscomp$lookupPolyfilledValue=function(a,b){var c=$jscomp.propertyToPolyfillSymbol[b];if(null==c)return a[b];c=a[c];return void 0!==c?c:a[b]};
$jscomp.polyfill=function(a,b,c,e){b&&($jscomp.ISOLATE_POLYFILLS?$jscomp.polyfillIsolated(a,b,c,e):$jscomp.polyfillUnisolated(a,b,c,e))};$jscomp.polyfillUnisolated=function(a,b,c,e){c=$jscomp.global;a=a.split(".");for(e=0;e<a.length-1;e++){var d=a[e];if(!(d in c))return;c=c[d]}a=a[a.length-1];e=c[a];b=b(e);b!=e&&null!=b&&$jscomp.defineProperty(c,a,{configurable:!0,writable:!0,value:b})};
$jscomp.polyfillIsolated=function(a,b,c,e){var d=a.split(".");a=1===d.length;e=d[0];e=!a&&e in $jscomp.polyfills?$jscomp.polyfills:$jscomp.global;for(var f=0;f<d.length-1;f++){var l=d[f];if(!(l in e))return;e=e[l]}d=d[d.length-1];c=$jscomp.IS_SYMBOL_NATIVE&&"es6"===c?e[d]:null;b=b(c);null!=b&&(a?$jscomp.defineProperty($jscomp.polyfills,d,{configurable:!0,writable:!0,value:b}):b!==c&&($jscomp.propertyToPolyfillSymbol[d]=$jscomp.IS_SYMBOL_NATIVE?$jscomp.global.Symbol(d):$jscomp.POLYFILL_PREFIX+d,d=
$jscomp.propertyToPolyfillSymbol[d],$jscomp.defineProperty(e,d,{configurable:!0,writable:!0,value:b})))};$jscomp.polyfill("Array.prototype.find",function(a){return a?a:function(b,c){return $jscomp.findInternal(this,b,c).v}},"es6","es3");
(function(a){"function"===typeof define&&define.amd?define(["jquery","datatables.net"],function(b){return a(b,window,document)}):"object"===typeof exports?module.exports=function(b,c){b||(b=window);c&&c.fn.dataTable||(c=require("datatables.net")(b,c).$);return a(c,b,b.document)}:a(jQuery,window,document)})(function(a,b,c,e){var d=a.fn.dataTable;a.extend(!0,d.defaults,{dom:"<'row'<'col-sm-6'l><'col-sm-6'f>><'row'<'col-sm-12'tr>><'row'<'col-sm-5'i><'col-sm-7'p>>",renderer:"bootstrap"});a.extend(d.ext.classes,
{sWrapper:"dataTables_wrapper form-inline dt-bootstrap",sFilterInput:"form-control input-sm",sLengthSelect:"form-control input-sm",sProcessing:"dataTables_processing panel panel-default"});d.ext.renderer.pageButton.bootstrap=function(f,l,A,B,m,t){var u=new d.Api(f),C=f.oClasses,n=f.oLanguage.oPaginate,D=f.oLanguage.oAria.paginate||{},h,k,v=0,y=function(q,w){var x,E=function(p){p.preventDefault();a(p.currentTarget).hasClass("disabled")||u.page()==p.data.action||u.page(p.data.action).draw("page")};
var r=0;for(x=w.length;r<x;r++){var g=w[r];if(Array.isArray(g))y(q,g);else{k=h="";switch(g){case "ellipsis":h="&#x2026;";k="disabled";break;case "first":h=n.sFirst;k=g+(0<m?"":" disabled");break;case "previous":h=n.sPrevious;k=g+(0<m?"":" disabled");break;case "next":h=n.sNext;k=g+(m<t-1?"":" disabled");break;case "last":h=n.sLast;k=g+(m<t-1?"":" disabled");break;default:h=g+1,k=m===g?"active":""}if(h){var F=a("<li>",{"class":C.sPageButton+" "+k,id:0===A&&"string"===typeof g?f.sTableId+"_"+g:null}).append(a("<a>",
{href:"#","aria-controls":f.sTableId,"aria-label":D[g],"data-dt-idx":v,tabindex:f.iTabIndex}).html(h)).appendTo(q);f.oApi._fnBindAction(F,{action:g},E);v++}}}};try{var z=a(l).find(c.activeElement).data("dt-idx")}catch(q){}y(a(l).empty().html('<ul class="pagination"/>').children("ul"),B);z!==e&&a(l).find("[data-dt-idx="+z+"]").trigger("focus")};return d});

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -54,6 +54,100 @@ $(function() {
return '<span class="ellipsis" title="'+esc(d)+'">'+shortened+'&#8230;</span>';
};
};
/*
* Natural Sort algorithm for Javascript - Version 0.7 - Released under MIT license
* Author: Jim Palmer (based on chunking idea from Dave Koelle)
* Contributors: Mike Grier (mgrier.com), Clint Priest, Kyle Adams, guillermo
* See: http://js-naturalsort.googlecode.com/svn/trunk/naturalSort.js
*/
function naturalSort (a, b, html) {
var re = /(^-?[0-9]+(\.?[0-9]*)[df]?e?[0-9]?%?$|^0x[0-9a-f]+$|[0-9]+)/gi,
sre = /(^[ ]*|[ ]*$)/g,
dre = /(^([\w ]+,?[\w ]+)?[\w ]+,?[\w ]+\d+:\d+(:\d+)?[\w ]?|^\d{1,4}[\/\-]\d{1,4}[\/\-]\d{1,4}|^\w+, \w+ \d+, \d{4})/,
hre = /^0x[0-9a-f]+$/i,
ore = /^0/,
htmre = /(<([^>]+)>)/ig,
// convert all to strings and trim()
x = a.toString().replace(sre, '') || '',
y = b.toString().replace(sre, '') || '';
// remove html from strings if desired
if (!html) {
x = x.replace(htmre, '');
y = y.replace(htmre, '');
}
// chunk/tokenize
var xN = x.replace(re, '\0$1\0').replace(/\0$/,'').replace(/^\0/,'').split('\0'),
yN = y.replace(re, '\0$1\0').replace(/\0$/,'').replace(/^\0/,'').split('\0'),
// numeric, hex or date detection
xD = parseInt(x.match(hre), 10) || (xN.length !== 1 && x.match(dre) && Date.parse(x)),
yD = parseInt(y.match(hre), 10) || xD && y.match(dre) && Date.parse(y) || null;
// first try and sort Hex codes or Dates
if (yD) {
if ( xD < yD ) {
return -1;
}
else if ( xD > yD ) {
return 1;
}
}
// natural sorting through split numeric strings and default strings
for(var cLoc=0, numS=Math.max(xN.length, yN.length); cLoc < numS; cLoc++) {
// find floats not starting with '0', string or 0 if not defined (Clint Priest)
var oFxNcL = !(xN[cLoc] || '').match(ore) && parseFloat(xN[cLoc], 10) || xN[cLoc] || 0;
var oFyNcL = !(yN[cLoc] || '').match(ore) && parseFloat(yN[cLoc], 10) || yN[cLoc] || 0;
// handle numeric vs string comparison - number < string - (Kyle Adams)
if (isNaN(oFxNcL) !== isNaN(oFyNcL)) {
return (isNaN(oFxNcL)) ? 1 : -1;
}
// rely on string comparison if different types - i.e. '02' < 2 != '02' < '2'
else if (typeof oFxNcL !== typeof oFyNcL) {
oFxNcL += '';
oFyNcL += '';
}
if (oFxNcL < oFyNcL) {
return -1;
}
if (oFxNcL > oFyNcL) {
return 1;
}
}
return 0;
}
jQuery.extend( jQuery.fn.dataTableExt.oSort, {
"natural-asc": function ( a, b ) {
return naturalSort(a,b,true);
},
"natural-desc": function ( a, b ) {
return naturalSort(a,b,true) * -1;
},
"natural-nohtml-asc": function( a, b ) {
return naturalSort(a,b,false);
},
"natural-nohtml-desc": function( a, b ) {
return naturalSort(a,b,false) * -1;
},
"natural-ci-asc": function( a, b ) {
a = a.toString().toLowerCase();
b = b.toString().toLowerCase();
return naturalSort(a,b,true);
},
"natural-ci-desc": function( a, b ) {
a = a.toString().toLowerCase();
b = b.toString().toLowerCase();
return naturalSort(a,b,true) * -1;
}
} );
}
$('#o').blur(function() {
@@ -203,7 +297,14 @@ $(function() {
if ( $('.orders_list').length || $('.rel_orders_list').length ) {
options["columnDefs"] = [ {
targets: 3,
targets: 2,
render: $.fn.dataTable.render.ellipsis(15)
} ];
}
if ( $('.rel_certificates_list').length ) {
options["columnDefs"] = [ {
targets: 2,
render: $.fn.dataTable.render.ellipsis(15)
} ];
}
@@ -212,11 +313,8 @@ $(function() {
options["columnDefs"] = [
{
targets: 0,
render: $.fn.dataTable.render.ellipsis(15)
},
{
targets: 1,
render: $.fn.dataTable.render.ellipsis(40)
render: $.fn.dataTable.render.ellipsis(15),
type: 'natural'
},
];
}
@@ -287,6 +385,14 @@ $(function() {
}
});
$('.auth_show tbody tr').children().each(function () {
if (this.textContent == 'Validation Error' || this.textContent == 'Validation Record') {
console.log(this.nextElementSibling);
this.nextElementSibling.innerText = JSON.stringify(JSON.parse(this.nextElementSibling.innerText), null, 2);
$(this.nextElementSibling).wrapInner('<pre class="json"></pre>');
}
});
$(".datatable").on('draw.dt', positionFooter);
}

91
www/rate-limits.html Normal file
View File

@@ -0,0 +1,91 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="LabCA is a private Certificate Authority for internal (intranet) use, based on the open source ACME Automated Certificate Management Environment implementation from Let's Encrypt (tm).">
<meta name="keywords" content="LabCA PKI CA Certificate Authority ACME Boulder">
<meta name="author" content="Arjan Hakkesteegt">
<title>LabCA</title>
<link href="css/bootstrap.min.css" rel="stylesheet">
<link href="css/sb-admin-2.min.css" rel="stylesheet">
<link href="css/font-awesome.min.css" rel="stylesheet" type="text/css">
<link href="css/labca.css" rel="stylesheet">
<link rel="icon" type="image/png" href="img/fav-public.png">
</head>
<body>
<div id="wrapper">
<nav class="navbar navbar-default navbar-static-top" role="navigation" style="margin-bottom: 0">
<div class="navbar-header">
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="/">LabCA</a>
</div>
<ul class="nav navbar-top-links navbar-right">
<li title="Login to Admin Area"><a href="/admin/"><i class="fa fa-user fa-fw admin-login"></i></a>
</li>
</ul>
<div class="navbar-default sidebar" role="navigation">
<div class="sidebar-nav navbar-collapse">
<ul class="nav" id="side-menu">
<li><a class="public" href="/"><i class="fa fa-home fa-fw"></i> Home</a>
</li>
<li><a class="public" href="/certs/index.html"><i class="fa fa-download fa-fw"></i> Certificates</a>
</li>
<li><a class="public" href="/cps/index.html" title="Certification Practice Statement"><i class="fa fa-book fa-fw"></i> CPS</a>
</li>
<li><a class="public" href="/rate-limits.html" title="Rate Limits"><i class="fa fa-clock-o fa-fw"></i> Rate Limits</a>
</li>
<li><a class="public" href="/terms/v1" title="Usage Terms"><i class="fa fa-edit fa-fw"></i> Terms</a>
</li>
</ul>
</div>
</div>
</nav>
<div id="page-wrapper">
<div class="row">
<div class="col-lg-12">
<h1 class="page-header">Rate Limits</h1>
<p>
It is unlikely that you hit the rate limit mechanism for your selected domain, as it is set to allow 10,000 certificates in LabCA.
</p>
<p>
If your LabCA instance is set up to (also) allow official domains (not recommended), then for the other domains the main limit is
<b>Certificates per Registered Domain</b>: 5 per 24 hours. As per the
<a class="public" href="https://letsencrypt.org/docs/rate-limits/">Let's Encrypt&trade; rate limits page <i class="fa fa-external-link fa-fw ext-link"></i></a>,
a registered domain is, generally speaking, the part of the domain you purchased from your domain name registrar. For instance,
in the name <code>www.example.com</code>, the registered domain is <code>example.com</code>.
In <code>new.blog.example.co.uk</code>, the registered domain is <code>example.co.uk</code>.
</p>
<p>
The other limit is the <b>Duplicate Certificate</b> limit of 2 per 90 days. This applies to renewals when the old dertificate
is still valid.
</p>
<p>
<b>Revoking certificates does not reset rate limits</b>, because the resources used to issue those certificates have already been
consumed.
</p>
</div>
</div>
</div>
</div>
<script src="js/jquery.min.js"></script>
<script src="js/bootstrap.min.js"></script>
<script src="js/metisMenu.min.js"></script>
<script src="js/sb-admin-2.min.js"></script>
<script src="js/labca.js"></script>
</body>
</html>