build.xml
@@ -766,6 +766,7 @@ <resource file="${basedir}/resources/settings_16x16.png" /> <resource file="${basedir}/resources/settings_32x32.png" /> <resource file="${basedir}/resources/search-icon.png" /> <resource file="${basedir}/resources/mail_16x16.png" /> <resource file="${basedir}/resources/blank.png" /> <resource file="${basedir}/resources/bullet_green.png" /> <resource file="${basedir}/resources/bullet_orange.png" /> distrib/gitblit.properties
@@ -1133,6 +1133,17 @@ # This is provided for convenience, its probably more secure to set this value # using the --storePassword command line parameter. # # If you are using the official JRE or JDK from Oracle you may not have the # JCE Unlimited Strength Jurisdiction Policy files bundled with your JVM. Because # of this, your store/key password can not exceed 7 characters. If you require # longer passwords you may need to install the JCE Unlimited Strength Jurisdiction # Policy files from Oracle. # # http://www.oracle.com/technetwork/java/javase/downloads/index.html # # Gitblit and the Gitblit Certificate Authority will both indicate if Unlimited # Strength encryption is available. # # SINCE 0.5.0 # RESTART REQUIRED server.storePassword = gitblit resources/mail_16x16.png
src/com/gitblit/authority/AuthorityWorker.java
New file @@ -0,0 +1,58 @@ /* * Copyright 2012 gitblit.com. * * 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 com.gitblit.authority; import java.awt.Component; import java.awt.Cursor; import java.io.IOException; import javax.swing.SwingWorker; public abstract class AuthorityWorker extends SwingWorker<Boolean, Void> { private final Component parent; public AuthorityWorker(Component parent) { this.parent = parent; parent.setCursor(Cursor.getPredefinedCursor(Cursor.WAIT_CURSOR)); } @Override protected Boolean doInBackground() throws IOException { return doRequest(); } protected void done() { parent.setCursor(Cursor.getPredefinedCursor(Cursor.DEFAULT_CURSOR)); try { Boolean success = get(); if (success) { onSuccess(); } else { onFailure(); } } catch (Throwable t) { Utils.showException(parent, t); } } protected abstract Boolean doRequest() throws IOException; protected abstract void onSuccess(); protected void onFailure() { } } src/com/gitblit/authority/GitblitAuthority.java
@@ -88,6 +88,7 @@ import com.gitblit.client.HeaderPanel; import com.gitblit.client.Translation; import com.gitblit.models.UserModel; import com.gitblit.utils.ArrayUtils; import com.gitblit.utils.StringUtils; import com.gitblit.utils.TimeUtils; import com.gitblit.utils.X509Utils; @@ -364,7 +365,10 @@ public void newCertificate(UserCertificateModel ucm, X509Metadata metadata, boolean sendEmail) { prepareX509Infrastructure(); Date notAfter = metadata.notAfter; metadata.serverHostname = gitblitSettings.getString(Keys.web.siteName, "localhost"); metadata.serverHostname = gitblitSettings.getString(Keys.web.siteName, Constants.NAME); if (StringUtils.isEmpty(metadata.serverHostname)) { metadata.serverHostname = Constants.NAME; } UserModel user = ucm.user; // set default values from config file @@ -421,38 +425,7 @@ table.getSelectionModel().setSelectionInterval(modelIndex, modelIndex); if (sendEmail) { // send email try { if (mail.isReady()) { Message message = mail.createMessage(user.emailAddress); message.setSubject("Your Gitblit client certificate for " + metadata.serverHostname); // body of email String body = X509Utils.processTemplate(new File(caKeystoreFile.getParentFile(), "mail.tmpl"), metadata); if (StringUtils.isEmpty(body)) { body = MessageFormat.format("Hi {0}\n\nHere is your client certificate bundle.\nInside the zip file are installation instructions.", user.getDisplayName()); } Multipart mp = new MimeMultipart(); MimeBodyPart messagePart = new MimeBodyPart(); messagePart.setText(body); mp.addBodyPart(messagePart); // attach zip MimeBodyPart filePart = new MimeBodyPart(); FileDataSource fds = new FileDataSource(zip); filePart.setDataHandler(new DataHandler(fds)); filePart.setFileName(fds.getName()); mp.addBodyPart(filePart); message.setContent(mp); mail.sendNow(message); } else { JOptionPane.showMessageDialog(GitblitAuthority.this, "Sorry, the mail server settings are not configured properly.\nCan not send email.", Translation.get("gb.error"), JOptionPane.ERROR_MESSAGE); } } catch (Exception e) { Utils.showException(GitblitAuthority.this, e); } sendEmail(user, metadata, zip); } } @@ -527,7 +500,7 @@ certificateDefaultsButton = new JButton(new ImageIcon(getClass().getResource("/settings_16x16.png"))); certificateDefaultsButton.setFocusable(false); certificateDefaultsButton.setToolTipText(Translation.get("gb.certificateDefaults")); certificateDefaultsButton.setToolTipText(Translation.get("gb.newCertificateDefaults")); certificateDefaultsButton.addActionListener(new ActionListener() { @Override public void actionPerformed(ActionEvent e) { @@ -570,7 +543,7 @@ panel.add(oids, BorderLayout.CENTER); int result = JOptionPane.showConfirmDialog(GitblitAuthority.this, panel, Translation.get("gb.certificateDefaults"), JOptionPane.OK_CANCEL_OPTION, panel, Translation.get("gb.newCertificateDefaults"), JOptionPane.OK_CANCEL_OPTION, JOptionPane.QUESTION_MESSAGE, new ImageIcon(getClass().getResource("/settings_32x32.png"))); if (result == JOptionPane.OK_OPTION) { try { @@ -587,33 +560,94 @@ } }); JButton newWebCertificate = new JButton(new ImageIcon(getClass().getResource("/rosette_16x16.png"))); newWebCertificate.setFocusable(false); newWebCertificate.setToolTipText(Translation.get("gb.newWebCertificate")); newWebCertificate.addActionListener(new ActionListener() { JButton newSSLCertificate = new JButton(new ImageIcon(getClass().getResource("/rosette_16x16.png"))); newSSLCertificate.setFocusable(false); newSSLCertificate.setToolTipText(Translation.get("gb.newSSLCertificate")); newSSLCertificate.addActionListener(new ActionListener() { @Override public void actionPerformed(ActionEvent e) { Date defaultExpiration = new Date(System.currentTimeMillis() + 10*TimeUtils.ONEYEAR); NewWebCertificateDialog dialog = new NewWebCertificateDialog(GitblitAuthority.this, defaultExpiration); NewSSLCertificateDialog dialog = new NewSSLCertificateDialog(GitblitAuthority.this, defaultExpiration); dialog.setModal(true); dialog.setVisible(true); if (dialog.isCanceled()) { return; } prepareX509Infrastructure(); Date expires = dialog.getExpiration(); String hostname = dialog.getHostname(); final Date expires = dialog.getExpiration(); final String hostname = dialog.getHostname(); AuthorityWorker worker = new AuthorityWorker(GitblitAuthority.this) { @Override protected Boolean doRequest() throws IOException { prepareX509Infrastructure(); // read CA private key and certificate File caKeystoreFile = new File(folder, X509Utils.CA_KEY_STORE); PrivateKey caPrivateKey = X509Utils.getPrivateKey(X509Utils.CA_ALIAS, caKeystoreFile, caKeystorePassword); X509Certificate caCert = X509Utils.getCertificate(X509Utils.CA_ALIAS, caKeystoreFile, caKeystorePassword); // generate new SSL certificate X509Metadata metadata = new X509Metadata(hostname, caKeystorePassword); metadata.notAfter = expires; File serverKeystoreFile = new File(folder, X509Utils.SERVER_KEY_STORE); X509Certificate cert = X509Utils.newSSLCertificate(metadata, caPrivateKey, caCert, serverKeystoreFile, GitblitAuthority.this); return cert != null; } @Override protected void onSuccess() { JOptionPane.showMessageDialog(GitblitAuthority.this, MessageFormat.format(Translation.get("gb.sslCertificateGenerated"), hostname), Translation.get("gb.newSSLCertificate"), JOptionPane.INFORMATION_MESSAGE); } }; // read CA private key and certificate File caKeystoreFile = new File(folder, X509Utils.CA_KEY_STORE); PrivateKey caPrivateKey = X509Utils.getPrivateKey(X509Utils.CA_ALIAS, caKeystoreFile, caKeystorePassword); X509Certificate caCert = X509Utils.getCertificate(X509Utils.CA_ALIAS, caKeystoreFile, caKeystorePassword); worker.execute(); } }); JButton emailBundle = new JButton(new ImageIcon(getClass().getResource("/mail_16x16.png"))); emailBundle.setFocusable(false); emailBundle.setToolTipText(Translation.get("gb.emailCertificateBundle")); emailBundle.addActionListener(new ActionListener() { @Override public void actionPerformed(ActionEvent e) { int row = table.getSelectedRow(); if (row < 0) { return; } int modelIndex = table.convertRowIndexToModel(row); final UserCertificateModel ucm = tableModel.get(modelIndex); if (ArrayUtils.isEmpty(ucm.certs)) { JOptionPane.showMessageDialog(GitblitAuthority.this, MessageFormat.format(Translation.get("gb.pleaseGenerateClientCertificate"), ucm.user.getDisplayName())); } final File zip = new File(folder, X509Utils.CERTS + File.separator + ucm.user.username + File.separator + ucm.user.username + ".zip"); if (!zip.exists()) { return; } // generate new SSL certificate X509Metadata metadata = new X509Metadata(hostname, caKeystorePassword); metadata.notAfter = expires; File serverKeystoreFile = new File(folder, X509Utils.SERVER_KEY_STORE); X509Utils.newSSLCertificate(metadata, caPrivateKey, caCert, serverKeystoreFile, GitblitAuthority.this); AuthorityWorker worker = new AuthorityWorker(GitblitAuthority.this) { @Override protected Boolean doRequest() throws IOException { X509Metadata metadata = new X509Metadata(ucm.user.username, "whocares"); metadata.serverHostname = gitblitSettings.getString(Keys.web.siteName, Constants.NAME); if (StringUtils.isEmpty(metadata.serverHostname)) { metadata.serverHostname = Constants.NAME; } metadata.userDisplayname = ucm.user.getDisplayName(); sendEmail(ucm.user, metadata, zip); return true; } @Override protected void onSuccess() { JOptionPane.showMessageDialog(GitblitAuthority.this, MessageFormat.format(Translation.get("gb.clientCertificateBundleSent"), ucm.user.getDisplayName())); } }; worker.execute(); } }); @@ -631,7 +665,8 @@ JPanel buttonControls = new JPanel(new FlowLayout(FlowLayout.LEFT, Utils.MARGIN, Utils.MARGIN)); buttonControls.add(certificateDefaultsButton); buttonControls.add(newWebCertificate); buttonControls.add(newSSLCertificate); buttonControls.add(emailBundle); JPanel userControls = new JPanel(new FlowLayout(FlowLayout.RIGHT, Utils.MARGIN, Utils.MARGIN)); userControls.add(new JLabel(Translation.get("gb.filter"))); @@ -708,4 +743,39 @@ } } } private void sendEmail(UserModel user, X509Metadata metadata, File zip) { // send email try { if (mail.isReady()) { Message message = mail.createMessage(user.emailAddress); message.setSubject("Your Gitblit client certificate for " + metadata.serverHostname); // body of email String body = X509Utils.processTemplate(new File(folder, X509Utils.CERTS + File.separator + "mail.tmpl"), metadata); if (StringUtils.isEmpty(body)) { body = MessageFormat.format("Hi {0}\n\nHere is your client certificate bundle.\nInside the zip file are installation instructions.", user.getDisplayName()); } Multipart mp = new MimeMultipart(); MimeBodyPart messagePart = new MimeBodyPart(); messagePart.setText(body); mp.addBodyPart(messagePart); // attach zip MimeBodyPart filePart = new MimeBodyPart(); FileDataSource fds = new FileDataSource(zip); filePart.setDataHandler(new DataHandler(fds)); filePart.setFileName(fds.getName()); mp.addBodyPart(filePart); message.setContent(mp); mail.sendNow(message); } else { JOptionPane.showMessageDialog(GitblitAuthority.this, "Sorry, the mail server settings are not configured properly.\nCan not send email.", Translation.get("gb.error"), JOptionPane.ERROR_MESSAGE); } } catch (Exception e) { Utils.showException(GitblitAuthority.this, e); } } } src/com/gitblit/authority/NewClientCertificateDialog.java
@@ -16,6 +16,7 @@ package com.gitblit.authority; import java.awt.BorderLayout; import java.awt.Dimension; import java.awt.Frame; import java.awt.GridLayout; import java.awt.Insets; @@ -30,6 +31,8 @@ import javax.swing.JOptionPane; import javax.swing.JPanel; import javax.swing.JPasswordField; import javax.swing.JScrollPane; import javax.swing.JTextArea; import javax.swing.JTextField; import org.bouncycastle.util.Arrays; @@ -64,7 +67,6 @@ return Utils.INSETS; } }; content.add(new HeaderPanel(Translation.get("gb.newCertificate") + " (" + displayname + ")", "rosette_16x16.png"), BorderLayout.NORTH); expirationDate = new JDateChooser(defaultExpiration); pw1 = new JPasswordField(20); @@ -91,7 +93,6 @@ panel.add(sendEmail); } content.add(panel, BorderLayout.CENTER); JButton ok = new JButton(Translation.get("gb.ok")); ok.addActionListener(new ActionListener() { @@ -114,8 +115,17 @@ controls.add(ok); controls.add(cancel); JTextArea message = new JTextArea(Translation.get("gb.newClientCertificateMessage")); message.setLineWrap(true); message.setWrapStyleWord(true); message.setEditable(false); message.setPreferredSize(new Dimension(300, 100)); content.add(new JScrollPane(message), BorderLayout.CENTER); content.add(panel, BorderLayout.NORTH); content.add(controls, BorderLayout.SOUTH); getContentPane().add(new HeaderPanel(Translation.get("gb.newCertificate") + " (" + displayname + ")", "rosette_16x16.png"), BorderLayout.NORTH); getContentPane().add(content, BorderLayout.CENTER); pack(); src/com/gitblit/authority/NewSSLCertificateDialog.java
File was renamed from src/com/gitblit/authority/NewWebCertificateDialog.java @@ -35,7 +35,7 @@ import com.gitblit.utils.StringUtils; import com.toedter.calendar.JDateChooser; public class NewWebCertificateDialog extends JDialog { public class NewSSLCertificateDialog extends JDialog { private static final long serialVersionUID = 1L; @@ -43,10 +43,10 @@ JTextField hostname; boolean isCanceled = true; public NewWebCertificateDialog(Frame owner, Date defaultExpiration) { public NewSSLCertificateDialog(Frame owner, Date defaultExpiration) { super(owner); setTitle(Translation.get("gb.newWebCertificate")); setTitle(Translation.get("gb.newSSLCertificate")); JPanel content = new JPanel(new BorderLayout(Utils.MARGIN, Utils.MARGIN)) { private static final long serialVersionUID = 1L; @@ -57,7 +57,6 @@ return Utils.INSETS; } }; content.add(new HeaderPanel(Translation.get("gb.newWebCertificate"), "rosette_16x16.png"), BorderLayout.NORTH); expirationDate = new JDateChooser(defaultExpiration); hostname = new JTextField(20); @@ -69,8 +68,6 @@ panel.add(new JLabel(Translation.get("gb.expires"))); panel.add(expirationDate); content.add(panel, BorderLayout.CENTER); JButton ok = new JButton(Translation.get("gb.ok")); ok.addActionListener(new ActionListener() { @@ -92,9 +89,11 @@ JPanel controls = new JPanel(); controls.add(ok); controls.add(cancel); content.add(panel, BorderLayout.CENTER); content.add(controls, BorderLayout.SOUTH); getContentPane().add(new HeaderPanel(Translation.get("gb.newSSLCertificate"), "rosette_16x16.png"), BorderLayout.NORTH); getContentPane().add(content, BorderLayout.CENTER); pack(); src/com/gitblit/authority/UserCertificateConfig.java
@@ -51,6 +51,7 @@ uc.expires = df.parse(c.getString("user", username, "expires")); } catch (ParseException e) { LoggerFactory.getLogger(UserCertificateConfig.class).error("Failed to parse date!", e); } catch (NullPointerException e) { } uc.notes = c.getString("user", username, "notes"); uc.revoked = new ArrayList<String>(Arrays.asList(c.getStringList("user", username, "revoked"))); src/com/gitblit/authority/UserCertificateModel.java
@@ -27,6 +27,7 @@ import com.gitblit.Constants; import com.gitblit.models.UserModel; import com.gitblit.utils.ArrayUtils; import com.gitblit.utils.StringUtils; import com.gitblit.utils.TimeUtils; import com.gitblit.utils.X509Utils.RevocationReason; @@ -42,14 +43,20 @@ } public void update(Config config) { if (expires != null) { if (expires == null) { config.unset("user", user.username, "expires"); } else { SimpleDateFormat df = new SimpleDateFormat(Constants.ISO8601); config.setString("user", user.username, "expires", df.format(expires)); } if (notes != null) { if (StringUtils.isEmpty(notes)) { config.unset("user", user.username, "notes"); } else { config.setString("user", user.username, "notes", notes); } if (!ArrayUtils.isEmpty(revoked)) { if (ArrayUtils.isEmpty(revoked)) { config.unset("user", user.username, "revoked"); } else { config.setStringList("user", user.username, "revoked", revoked); } } @@ -64,6 +71,16 @@ revoked = new ArrayList<String>(); } revoked.add(serial.toString() + ":" + reason.ordinal()); expires = null; for (X509Certificate cert : certs) { if (!isRevoked(cert.getSerialNumber())) { if (!isExpired(cert.getNotAfter())) { if (expires == null || cert.getNotAfter().after(expires)) { expires = cert.getNotAfter(); } } } } } public boolean isRevoked(BigInteger serial) { src/com/gitblit/authority/UserCertificatePanel.java
@@ -16,14 +16,15 @@ package com.gitblit.authority; import java.awt.BorderLayout; import java.awt.Cursor; import java.awt.FlowLayout; import java.awt.Frame; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; import java.io.IOException; import java.security.cert.X509Certificate; import java.text.MessageFormat; import java.util.Date; import javax.swing.ImageIcon; @@ -156,20 +157,32 @@ if (dialog.isCanceled()) { return; } setCursor(Cursor.getPredefinedCursor(Cursor.WAIT_CURSOR)); UserModel user = ucm.user; X509Metadata metadata = new X509Metadata(user.username, dialog.getPassword()); final boolean sendEmail = dialog.sendEmail(); final UserModel user = ucm.user; final X509Metadata metadata = new X509Metadata(user.username, dialog.getPassword()); metadata.userDisplayname = user.getDisplayName(); metadata.emailAddress = user.emailAddress; metadata.passwordHint = dialog.getPasswordHint(); metadata.notAfter = dialog.getExpiration(); newCertificate(ucm, metadata, dialog.sendEmail()); AuthorityWorker worker = new AuthorityWorker(UserCertificatePanel.this.owner) { @Override protected Boolean doRequest() throws IOException { newCertificate(ucm, metadata, sendEmail); return true; } @Override protected void onSuccess() { JOptionPane.showMessageDialog(UserCertificatePanel.this.owner, MessageFormat.format(Translation.get("gb.clientCertificateGenerated"), user.getDisplayName()), Translation.get("gb.newCertificate"), JOptionPane.INFORMATION_MESSAGE); } }; worker.execute(); } catch (Exception x) { Utils.showException(UserCertificatePanel.this, x); } finally { setCursor(Cursor.getDefaultCursor()); } } }); @@ -184,7 +197,7 @@ return; } int modelIndex = table.convertRowIndexToModel(row); X509Certificate cert = tableModel.get(modelIndex); final X509Certificate cert = tableModel.get(modelIndex); String [] choices = new String[RevocationReason.reasons.length]; for (int i = 0; i < choices.length; i++) { @@ -197,13 +210,14 @@ if (choice == null) { return; } RevocationReason reason = RevocationReason.unspecified; RevocationReason selection = RevocationReason.unspecified; for (int i = 0 ; i < choices.length; i++) { if (choices[i].equals(choice)) { reason = RevocationReason.reasons[i]; selection = RevocationReason.reasons[i]; break; } } final RevocationReason reason = selection; if (!ucm.isRevoked(cert.getSerialNumber())) { if (ucm.certs.size() == 1) { // no other certificates @@ -222,12 +236,27 @@ } ucm.expires = newExpires; } revoke(ucm, cert, reason); AuthorityWorker worker = new AuthorityWorker(UserCertificatePanel.this.owner) { @Override protected Boolean doRequest() throws IOException { revoke(ucm, cert, reason); return true; } @Override protected void onSuccess() { JOptionPane.showMessageDialog(UserCertificatePanel.this.owner, MessageFormat.format(Translation.get("gb.certificateRevoked"), cert.getSerialNumber(), cert.getIssuerDN().getName()), Translation.get("gb.revokeCertificate"), JOptionPane.INFORMATION_MESSAGE); } }; worker.execute(); } } catch (Exception x) { Utils.showException(UserCertificatePanel.this, x); } finally { setCursor(Cursor.getDefaultCursor()); } } }); src/com/gitblit/authority/X509CertificateViewer.java
@@ -56,7 +56,6 @@ return Utils.INSETS; } }; content.add(new HeaderPanel("certificiate", "rosette_16x16.png"), BorderLayout.NORTH); DateFormat df = DateFormat.getDateTimeInstance(); @@ -96,6 +95,7 @@ content.add(controls, BorderLayout.SOUTH); getContentPane().add(new HeaderPanel(Translation.get("gb.certificate"), "rosette_16x16.png"), BorderLayout.NORTH); getContentPane().add(content, BorderLayout.CENTER); pack(); src/com/gitblit/utils/X509Utils.java
@@ -561,7 +561,7 @@ new Certificate[] { cert, caCert }); saveKeyStore(targetStoreFile, serverStore, sslMetadata.password); x509log.log(MessageFormat.format("New web certificate {0,number,0} [{1}]", cert.getSerialNumber(), cert.getSubjectDN().getName())); x509log.log(MessageFormat.format("New SSL certificate {0,number,0} [{1}]", cert.getSerialNumber(), cert.getSubjectDN().getName())); return cert; } catch (Throwable t) { throw new RuntimeException("Failed to generate SSL certificate!", t); @@ -935,10 +935,18 @@ String message = FileUtils.readContent(template, "\n"); if (!StringUtils.isEmpty(message)) { content = message; content = content.replace("$serverHostname", metadata.serverHostname); content = content.replace("$username", metadata.commonName); content = content.replace("$userDisplayname", metadata.userDisplayname); content = content.replace("$storePasswordHint", metadata.passwordHint); if (!StringUtils.isEmpty(metadata.serverHostname)) { content = content.replace("$serverHostname", metadata.serverHostname); } if (!StringUtils.isEmpty(metadata.commonName)) { content = content.replace("$username", metadata.commonName); } if (!StringUtils.isEmpty(metadata.userDisplayname)) { content = content.replace("$userDisplayname", metadata.userDisplayname); } if (!StringUtils.isEmpty(metadata.passwordHint)) { content = content.replace("$storePasswordHint", metadata.passwordHint); } } } return content; src/com/gitblit/wicket/GitBlitWebApp.properties
@@ -417,6 +417,14 @@ gb.time.inDays = in {0} days gb.hostname = hostname gb.hostnameRequired = Please enter a hostname gb.newWebCertificate = new server SSL certificate gb.certificateDefaults = certificate defaults gb.duration = duration gb.newSSLCertificate = new server SSL certificate gb.newCertificateDefaults = new certificate defaults gb.duration = duration gb.certificateRevoked = Certificate {0,number,0} has been revoked gb.clientCertificateGenerated = Successfully generated new client certificate for {0} gb.sslCertificateGenerated = Successfully generated new server SSL certificate for {0} gb.newClientCertificateMessage = NOTE:\nThe 'password' is not the user's password, it is the password to protect the user's keystore. This password is not saved so you must also enter a 'hint' which will be included in the user's README instructions. gb.certificate = certificate gb.emailCertificateBundle = email client certificate bundle gb.pleaseGenerateClientCertificate = Please generate a client certificate for {0} gb.clientCertificateBundleSent = Client certificate bundle for {0} sent