/*******************************************************************************
*
* Copyright (c) 2004-2012 Oracle Corporation.
*
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the Eclipse Public License v1.0
* which accompanies this distribution, and is available at
* http://www.eclipse.org/legal/epl-v10.html
*
* Contributors:
*
* Kohsuke Kawaguchi, Winston Prakash, Seiji Sogabe, Andrew Bayer
*
*******************************************************************************/
package hudson.model;
import hudson.PluginManager;
import hudson.PluginWrapper;
import hudson.lifecycle.Lifecycle;
import hudson.model.UpdateCenter.UpdateCenterJob;
import hudson.util.CertificateUtil;
import hudson.util.IOUtils;
import hudson.util.JSONCanonicalUtils;
import hudson.util.SignatureOutputStream;
import hudson.util.TextFile;
import static hudson.util.TimeUnit2.DAYS;
import hudson.util.VersionNumber;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.security.DigestOutputStream;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.security.Signature;
import java.security.cert.CertificateFactory;
import java.security.cert.TrustAnchor;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.concurrent.Future;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.servlet.ServletContext;
import net.sf.json.JSONArray;
import net.sf.json.JSONObject;
import org.apache.commons.codec.binary.Base64;
import org.apache.commons.io.output.NullOutputStream;
import org.apache.commons.io.output.TeeOutputStream;
import org.eclipse.hudson.security.HudsonSecurityManager;
import org.kohsuke.stapler.DataBoundConstructor;
import org.kohsuke.stapler.StaplerRequest;
import org.kohsuke.stapler.StaplerResponse;
/**
* Source of the update center information, like
* "http://hudson-ci.org/update-center3.3.2/update-center.json"
*
* <p> Hudson can have multiple {@link UpdateSite}s registered in the system, so
* that it can pick up plugins from different locations.
*
* @author Andrew Bayer
* @author Kohsuke Kawaguchi
* @since 1.333
*/
public class UpdateSite {
/**
* What's the time stamp of data file?
*/
private transient long dataTimestamp = -1;
/**
* When was the last time we asked a browser to check the data for us?
*
* <p> There's normally some delay between when we send HTML that includes
* the check code, until we get the data back, so this variable is used to
* avoid asking too many browseres all at once.
*/
private transient volatile long lastAttempt = -1;
/**
* ID string for this update source.
*/
private final String id;
/**
* Path to <tt>update-center.json</tt>, like
* <tt>http://hudson-ci.org/update-center3.3.2/update-center.json</tt>.
*/
private final String url;
public UpdateSite(String id, String url) {
this.id = id;
this.url = url;
}
/**
* When read back from XML, initialize them back to -1.
*/
private Object readResolve() {
dataTimestamp = lastAttempt = -1;
return this;
}
/**
* Get ID string.
*/
public String getId() {
return id;
}
public long getDataTimestamp() {
return dataTimestamp;
}
/**
* This is the endpoint that receives the update center data file from the
* browser.
*/
public void doPostBack(StaplerRequest req, StaplerResponse rsp) throws IOException, GeneralSecurityException {
dataTimestamp = System.currentTimeMillis();
String json = IOUtils.toString(req.getInputStream(), "UTF-8");
JSONObject o = JSONObject.fromObject(json);
int v = o.getInt("updateCenterVersion");
if (v != 1) {
LOGGER.warning("Unrecognized update center version: " + v);
return;
}
if (signatureCheck) {
verifySignature(o);
}
LOGGER.info("Obtained the latest update center data file for UpdateSource " + id);
getDataFile().write(json);
rsp.setContentType("text/plain"); // So browser won't try to parse response
}
/**
* Verifies the signature in the update center data file.
*/
private boolean verifySignature(JSONObject o) throws GeneralSecurityException, IOException {
JSONObject signature = o.getJSONObject("signature");
if (signature.isNullObject()) {
LOGGER.severe("No signature block found");
return false;
}
o.remove("signature");
List<X509Certificate> certs = new ArrayList<X509Certificate>();
{// load and verify certificates
CertificateFactory cf = CertificateFactory.getInstance("X509");
for (Object cert : o.getJSONArray("certificates")) {
X509Certificate c = (X509Certificate) cf.generateCertificate(new ByteArrayInputStream(Base64.decodeBase64(cert.toString())));
c.checkValidity();
certs.add(c);
}
// all default root CAs in JVM are trusted, plus certs bundled in Hudson
Set<TrustAnchor> anchors = CertificateUtil.getDefaultRootCAs();
ServletContext context = Hudson.getInstance().servletContext;
for (String cert : (Set<String>) context.getResourcePaths("/WEB-INF/update-center-rootCAs")) {
if (cert.endsWith(".txt")) {
continue; // skip text files that are meant to be documentation
}
anchors.add(new TrustAnchor((X509Certificate) cf.generateCertificate(context.getResourceAsStream(cert)), null));
}
CertificateUtil.validatePath(certs);
}
// this is for computing a digest to check sanity
MessageDigest sha1 = MessageDigest.getInstance("SHA1");
DigestOutputStream dos = new DigestOutputStream(new NullOutputStream(), sha1);
// this is for computing a signature
Signature sig = Signature.getInstance("SHA1withRSA");
sig.initVerify(certs.get(0));
SignatureOutputStream sos = new SignatureOutputStream(sig);
JSONCanonicalUtils.write(o, new OutputStreamWriter(new TeeOutputStream(dos, sos), "UTF-8"));
// did the digest match? this is not a part of the signature validation, but if we have a bug in the c14n
// (which is more likely than someone tampering with update center), we can tell
String computedDigest = new String(Base64.encodeBase64(sha1.digest()));
String providedDigest = signature.getString("digest");
if (!computedDigest.equalsIgnoreCase(providedDigest)) {
LOGGER.severe("Digest mismatch: " + computedDigest + " vs " + providedDigest);
return false;
}
if (!sig.verify(Base64.decodeBase64(signature.getString("signature")))) {
LOGGER.severe("Signature in the update center doesn't match with the certificate");
return false;
}
return true;
}
/**
* Returns true if it's time for us to check for new version.
*/
public boolean isDue() {
if (neverUpdate) {
return false;
}
if (dataTimestamp == -1) {
dataTimestamp = getDataFile().file.lastModified();
}
long now = System.currentTimeMillis();
boolean due = now - dataTimestamp > DAY && now - lastAttempt > 15000;
if (due) {
lastAttempt = now;
}
return due;
}
/**
* Loads the update center data, if any.
*
* @return null if no data is available.
*/
public Data getData() {
TextFile df = getDataFile();
if (df.exists()) {
try {
return new Data(JSONObject.fromObject(df.read()));
} catch (IOException e) {
LOGGER.log(Level.SEVERE, "Failed to parse " + df, e);
df.delete(); // if we keep this file, it will cause repeated failures
return null;
}
} else {
return null;
}
}
/**
* Returns a list of plugins that should be shown in the "available" tab.
* These are "all plugins - installed plugins".
*/
public List<Plugin> getAvailables() {
List<Plugin> r = new ArrayList<Plugin>();
Data data = getData();
if (data == null) {
return Collections.emptyList();
}
for (Plugin p : data.plugins.values()) {
if (p.getInstalled() == null) {
r.add(p);
}
}
return r;
}
/**
* Gets the information about a specific plugin.
*
* @param artifactId The short name of the plugin. Corresponds to
* {@link PluginWrapper#getShortName()}.
*
* @return null if no such information is found.
*/
public Plugin getPlugin(String artifactId) {
Data dt = getData();
if (dt == null) {
return null;
}
return dt.plugins.get(artifactId);
}
/**
* Returns an "always up" server for Internet connectivity testing, or null
* if we are going to skip the test.
*/
public String getConnectionCheckUrl() {
Data dt = getData();
if (dt == null) {
return "http://www.google.com/";
}
return dt.connectionCheckUrl;
}
/**
* This is where we store the update center data.
*/
private TextFile getDataFile() {
return new TextFile(new File(Hudson.getInstance().getRootDir(),
"updates/" + getId() + ".json"));
}
/**
* Returns the list of plugins that are updates to currently installed ones.
*
* @return can be empty but never null.
*/
public List<Plugin> getUpdates() {
Data data = getData();
if (data == null) {
return Collections.emptyList(); // fail to determine
}
List<Plugin> r = new ArrayList<Plugin>();
for (PluginWrapper pw : Hudson.getInstance().getPluginManager().getPlugins()) {
Plugin p = pw.getUpdateInfo();
if (p != null) {
r.add(p);
}
}
return r;
}
/**
* Does any of the plugin has updates?
*/
public boolean hasUpdates() {
Data data = getData();
if (data == null) {
return false;
}
for (PluginWrapper pw : Hudson.getInstance().getPluginManager().getPlugins()) {
if (!pw.isBundled() && pw.getUpdateInfo() != null) // do not advertize updates to bundled plugins, since we generally want users to get them
// as a part of hudson.war updates. This also avoids unnecessary pinning of plugins.
{
return true;
}
}
return false;
}
/**
* Exposed to get rid of hardcoding of the URL that serves up
* update-center.json in Javascript.
*/
public String getUrl() {
return url;
}
/**
* Is this the legacy default update center site?
*/
public boolean isLegacyDefault() {
return id.equals("default") && url.contains("hudson-labs.org");
}
/**
* In-memory representation of the update center data.
*/
public final class Data {
/**
* The {@link UpdateSite} ID.
*/
//TODO: review and check whether we can do it private
public final String sourceId;
/**
* The latest hudson.war.
*/
//TODO: review and check whether we can do it private
public final Entry core;
/**
* Plugins in the repository, keyed by their artifact IDs.
*/
//TODO: review and check whether we can do it private
public final Map<String, Plugin> plugins = new TreeMap<String, Plugin>(String.CASE_INSENSITIVE_ORDER);
/**
* If this is non-null, Hudson is going to check the connectivity to
* this URL to make sure the network connection is up. Null to skip the
* check.
*/
//TODO: review and check whether we can do it private
public final String connectionCheckUrl;
Data(JSONObject o) {
this.sourceId = (String) o.get("id");
if (sourceId.equals("default")) {
core = new Entry(sourceId, o.getJSONObject("core"));
} else {
core = null;
}
for (Map.Entry<String, JSONObject> e : (Set<Map.Entry<String, JSONObject>>) o.getJSONObject("plugins").entrySet()) {
Plugin plugin = new Plugin(sourceId, e.getValue());
if (!"disabled".equals(plugin.type)) {
plugins.put(e.getKey(), plugin);
}
}
connectionCheckUrl = (String) o.get("connectionCheckUrl");
}
public String getSourceId() {
return sourceId;
}
public Entry getCore() {
return core;
}
public Map<String, Plugin> getPlugins() {
return plugins;
}
public String getConnectionCheckUrl() {
return connectionCheckUrl;
}
/**
* Is there a new version of the core?
*/
public boolean hasCoreUpdates() {
return core != null && core.isNewerThan(Hudson.VERSION);
}
/**
* Do we support upgrade?
*/
public boolean canUpgrade() {
return Lifecycle.get().canRewriteHudsonWar();
}
}
public static class Entry {
/**
* {@link UpdateSite} ID.
*/
public final String sourceId;
/**
* Artifact ID.
*/
public final String name;
/**
* The version.
*/
public final String version;
/**
* Download URL.
*/
public final String url;
public Entry(String sourceId, JSONObject o) {
this.sourceId = sourceId;
this.name = o.getString("name");
this.version = o.getString("version");
this.url = o.getString("url");
}
/**
* Checks if the specified "current version" is older than the version
* of this entry.
*
* @param currentVersion The string that represents the version number
* to be compared.
* @return true if the version listed in this entry is newer. false
* otherwise, including the situation where the strings couldn't be
* parsed as version numbers.
*/
public boolean isNewerThan(String currentVersion) {
try {
return new VersionNumber(currentVersion).compareTo(new VersionNumber(version)) < 0;
} catch (IllegalArgumentException e) {
// couldn't parse as the version number.
return false;
}
}
}
public final class Plugin extends Entry {
/**
* Optional URL to the Wiki page that discusses this plugin.
*/
public final String wiki;
/**
* Human readable title of the plugin, taken from Wiki page. Can be
* null.
*
* <p> beware of XSS vulnerability since this data comes from Wiki
*/
public final String title;
/**
* Optional excerpt string.
*/
public final String excerpt;
/**
* Optional version # from which this plugin release is
* configuration-compatible.
*/
public final String compatibleSinceVersion;
/**
* Version of Hudson core this plugin was compiled against.
*/
public final String requiredCore;
/**
* Categories for grouping plugins, taken from labels assigned to wiki
* page. Can be null.
*/
public final String[] categories;
public String type;
/**
* Dependencies of this plugin.
*/
public final Map<String, String> dependencies = new HashMap<String, String>();
@DataBoundConstructor
public Plugin(String sourceId, JSONObject o) {
super(sourceId, o);
this.type = get(o, "type");
if ((type == null) || "".equals(type)) {
type = "others";
}
this.wiki = get(o, "wiki");
this.title = get(o, "title");
this.excerpt = get(o, "excerpt");
this.compatibleSinceVersion = get(o, "compatibleSinceVersion");
this.requiredCore = get(o, "requiredCore");
JSONArray labelsJsonArray = o.getJSONArray("labels");
this.categories = o.has("labels") ? (String[]) labelsJsonArray.toArray(new String[labelsJsonArray.size()]) : null;
for (Object jo : o.getJSONArray("dependencies")) {
JSONObject depObj = (JSONObject) jo;
// Make sure there's a name attribute, that that name isn't maven-plugin - we ignore that one -
// and that the optional value isn't true.
if (get(depObj, "name") != null
&& !get(depObj, "name").equals("maven-plugin")
&& get(depObj, "optional").equals("false")) {
dependencies.put(get(depObj, "name"), get(depObj, "version"));
}
}
}
private String get(JSONObject o, String prop) {
if (o.has(prop)) {
String value = o.getString(prop);
if (!"null".equals(value) && !"\"null\"".equals(value)) {
return value;
}
}
return null;
}
public String getDisplayName() {
if (title != null) {
return title;
}
return name;
}
/**
* If some version of this plugin is currently installed, return
* {@link PluginWrapper}. Otherwise null.
*/
public PluginWrapper getInstalled() {
PluginManager pm = Hudson.getInstance().getPluginManager();
return pm.getPlugin(name);
}
/**
* If the plugin is already installed, and the new version of the plugin
* has a "compatibleSinceVersion" value (i.e., it's only directly
* compatible with that version or later), this will check to see if the
* installed version is older than the compatible-since version. If it
* is older, it'll return false. If it's not older, or it's not
* installed, or it's installed but there's no compatibleSinceVersion
* specified, it'll return true.
*/
public boolean isCompatibleWithInstalledVersion() {
PluginWrapper installedVersion = getInstalled();
if (installedVersion != null) {
if (compatibleSinceVersion != null) {
if (new VersionNumber(installedVersion.getVersion())
.isOlderThan(new VersionNumber(compatibleSinceVersion))) {
return false;
}
}
}
return true;
}
/**
* Returns a list of dependent plugins which need to be installed or
* upgraded for this plugin to work.
*/
public List<Plugin> getNeededDependencies() {
List<Plugin> deps = new ArrayList<Plugin>();
for (Map.Entry<String, String> e : dependencies.entrySet()) {
Plugin depPlugin = Hudson.getInstance().getUpdateCenter().getPlugin(e.getKey());
VersionNumber requiredVersion = new VersionNumber(e.getValue());
// Is the plugin installed already? If not, add it.
PluginWrapper current = depPlugin.getInstalled();
if (current == null) {
deps.add(depPlugin);
} // If the dependency plugin is installed, is the version we depend on newer than
// what's installed? If so, upgrade.
else if (current.isOlderThan(requiredVersion)) {
deps.add(depPlugin);
}
}
return deps;
}
public boolean isForNewerHudson() {
try {
return requiredCore != null && new VersionNumber(requiredCore).isNewerThan(
new VersionNumber(Hudson.VERSION.replaceFirst("SHOT *\\(private.*\\)", "SHOT")));
} catch (NumberFormatException nfe) {
return true; // If unable to parse version
}
}
/**
* @deprecated as of 1.326 Use {@link #deploy()}.
*/
public void install() {
deploy();
}
/**
* Schedules the installation of this plugin.
*
* <p> This is mainly intended to be called from the UI. The actual
* installation work happens asynchronously in another thread.
*/
public Future<UpdateCenterJob> deploy() {
Hudson.getInstance().checkPermission(Hudson.ADMINISTER);
UpdateCenter uc = Hudson.getInstance().getUpdateCenter();
for (Plugin dep : getNeededDependencies()) {
LOGGER.log(Level.WARNING, "Adding dependent install of " + dep.name + " for plugin " + name);
dep.deploy();
}
return uc.addJob(uc.new InstallationJob(this, UpdateSite.this, HudsonSecurityManager.getAuthentication()));
}
/**
* Schedules the downgrade of this plugin.
*/
public Future<UpdateCenterJob> deployBackup() {
Hudson.getInstance().checkPermission(Hudson.ADMINISTER);
UpdateCenter uc = Hudson.getInstance().getUpdateCenter();
return uc.addJob(uc.new PluginDowngradeJob(this, UpdateSite.this, HudsonSecurityManager.getAuthentication()));
}
/**
* Making the installation web bound.
*/
public void doInstall(StaplerResponse rsp) throws IOException {
deploy();
rsp.sendRedirect2("../..");
}
/**
* Performs the downgrade of the plugin.
*/
public void doDowngrade(StaplerResponse rsp) throws IOException {
deployBackup();
rsp.sendRedirect2("../..");
}
}
private static final long DAY = DAYS.toMillis(1);
private static final Logger LOGGER = Logger.getLogger(UpdateSite.class.getName());
// The name uses UpdateCenter for compatibility reason.
public static boolean neverUpdate = Boolean.getBoolean(UpdateCenter.class.getName() + ".never");
/**
* Off by default until we know this is reasonably working.
*/
public static boolean signatureCheck = Boolean.getBoolean(UpdateCenter.class.getName() + ".signatureCheck");
}