/*
* The MIT License
*
* Copyright (c) 2004-2009, Sun Microsystems, Inc., Kohsuke Kawaguchi, Yahoo! Inc., Seiji Sogabe,
* Andrew Bayer
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
package hudson.model;
import hudson.PluginWrapper;
import hudson.PluginManager;
import hudson.model.UpdateCenter.UpdateCenterJob;
import hudson.lifecycle.Lifecycle;
import hudson.util.IOUtils;
import hudson.util.JSONCanonicalUtils;
import hudson.util.TextFile;
import hudson.util.VersionNumber;
import static hudson.util.TimeUnit2.DAYS;
import net.sf.json.JSONObject;
import org.kohsuke.stapler.DataBoundConstructor;
import org.kohsuke.stapler.StaplerRequest;
import org.kohsuke.stapler.StaplerResponse;
import org.jvnet.hudson.crypto.CertificateUtil;
import org.jvnet.hudson.crypto.SignatureOutputStream;
import org.apache.commons.io.output.NullOutputStream;
import org.apache.commons.io.output.TeeOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.ByteArrayInputStream;
import java.io.OutputStreamWriter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.HashMap;
import java.util.Set;
import java.util.concurrent.Future;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.security.DigestOutputStream;
import java.security.Signature;
import java.security.cert.X509Certificate;
import java.security.cert.CertificateFactory;
import java.security.cert.TrustAnchor;
import com.trilead.ssh2.crypto.Base64;
import javax.servlet.ServletContext;
/**
* Source of the update center information, like "http://hudson-ci.org/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-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.decode(cert.toString().toCharArray())));
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.encode(sha1.digest()));
String providedDigest = signature.getString("digest");
if (!computedDigest.equalsIgnoreCase(providedDigest)) {
LOGGER.severe("Digest mismatch: "+computedDigest+" vs "+providedDigest);
return false;
}
if (!sig.verify(Base64.decode(signature.getString("signature").toCharArray()))) {
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()) {
plugins.put(e.getKey(),new Plugin(sourceId, e.getValue()));
}
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;
/**
* 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.wiki = get(o,"wiki");
this.title = get(o,"title");
this.excerpt = get(o,"excerpt");
this.compatibleSinceVersion = get(o,"compatibleSinceVersion");
this.requiredCore = get(o,"requiredCore");
this.categories = o.has("labels") ? (String[])o.getJSONArray("labels").toArray(new String[0]) : 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)) {
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, Hudson.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, Hudson.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");
}