package org.openslx.virtualization.configuration; import java.io.ByteArrayInputStream; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; import javax.xml.XMLConstants; import javax.xml.transform.dom.DOMSource; import javax.xml.transform.stream.StreamSource; import javax.xml.validation.Schema; import javax.xml.validation.SchemaFactory; import javax.xml.validation.Validator; import javax.xml.xpath.XPathConstants; import javax.xml.xpath.XPathExpression; import javax.xml.xpath.XPathExpressionException; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.openslx.util.Resources; import org.openslx.util.Util; import org.openslx.util.XmlHelper; import org.openslx.virtualization.Version; import org.openslx.virtualization.configuration.VirtualizationConfiguration.DriveBusType; import org.openslx.virtualization.configuration.VirtualizationConfiguration.HardDisk; import org.w3c.dom.DOMException; import org.w3c.dom.Document; import org.w3c.dom.Element; import org.w3c.dom.Node; import org.w3c.dom.NodeList; import org.xml.sax.SAXException; /** * Class handling the parsing of a .vbox machine description file */ public class VirtualizationConfigurationVirtualboxFileFormat { private static final Logger LOGGER = LogManager.getLogger( VirtualizationConfigurationVirtualboxFileFormat.class ); // key information set during initial parsing of the XML file private String osName = ""; private ArrayList hddsArray = new ArrayList(); // XPath and DOM parsing related members private Document doc = null; /** * Version of the configuration file format. */ private Version version = null; /** * File names of XML schema files for different file format versions. */ private final static HashMap FILE_FORMAT_SCHEMA_VERSIONS = new HashMap() { private static final long serialVersionUID = -3163681758191475625L; { put( Version.valueOf( "1.15" ), "VirtualBox-settings_v1-15.xsd" ); put( Version.valueOf( "1.16" ), "VirtualBox-settings_v1-16.xsd" ); put( Version.valueOf( "1.17" ), "VirtualBox-settings_v1-17.xsd" ); put( Version.valueOf( "1.18" ), "VirtualBox-settings_v1-18.xsd" ); } }; /** * Path to the VirtualBox file format schemas within the *.jar file. */ private final static String FILE_FORMAT_SCHEMA_PREFIX_PATH = Resources.PATH_SEPARATOR + "virtualbox" + Resources.PATH_SEPARATOR + "xsd"; public static final String DUMMY_VALUE = "[dummy]"; // list of nodes to automatically remove when reading the vbox file private static final String[] BLACKLIST = { "/VirtualBox/Machine/Hardware/GuestProperties", "/VirtualBox/Machine/Hardware/VideoCapture", "/VirtualBox/Machine/Hardware/HID", "/VirtualBox/Machine/Hardware/LPT", "/VirtualBox/Machine/Hardware/SharedFolders", "/VirtualBox/Machine/Hardware/Network/Adapter[@slot='0']/*", "/VirtualBox/Machine/ExtraData", "/VirtualBox/Machine/StorageControllers/StorageController/AttachedDevice[not(@type='HardDisk')]", "/VirtualBox/Machine/Hardware/StorageControllers/StorageController/AttachedDevice[not(@type='HardDisk')]", "/VirtualBox/Machine/MediaRegistry/FloppyImages", "/VirtualBox/Machine/MediaRegistry/DVDImages" }; public static enum MatchMode { EXACTLY_ONE, MULTIPLE, FIRST_ONLY, }; /** * Creates a vbox configuration by constructing a DOM from the given VirtualBox machine * configuration file. * Will validate the given file against the VirtualBox XSD schema and only proceed if it is * valid. * * @param file the VirtualBox machine configuration file * @throws IOException if an error occurs while reading the file * @throws VirtualizationConfigurationException if the given file is not a valid VirtualBox * configuration file. */ public VirtualizationConfigurationVirtualboxFileFormat( File file ) throws IOException, VirtualizationConfigurationException { doc = XmlHelper.parseDocumentFromStream( new FileInputStream( file ) ); doc = XmlHelper.removeFormattingNodes( doc ); if ( doc == null ) throw new VirtualizationConfigurationException( "Could not parse given VirtualBox machine configuration file!" ); this.parseConfigurationVersion(); this.init(); } /** * Creates an vbox configuration by constructing a DOM from the XML content given as a byte * array. * * @param machineDescription content of the XML file saved as a byte array. * @param length of the machine description byte array. * @throws VirtualizationConfigurationException creation of VirtualBox configuration file representation failed. */ public VirtualizationConfigurationVirtualboxFileFormat( byte[] machineDescription, int length ) throws VirtualizationConfigurationException { ByteArrayInputStream is = new ByteArrayInputStream( machineDescription ); doc = XmlHelper.parseDocumentFromStream( is ); if ( doc == null ) { final String errorMsg = "Could not parse given VirtualBox machine description from byte array!"; LOGGER.debug( errorMsg ); throw new VirtualizationConfigurationException( errorMsg ); } this.parseConfigurationVersion(); this.init(); } public void validate() throws VirtualizationConfigurationException { this.validateFileFormatVersion( this.getVersion() ); } public void validateFileFormatVersion( Version version ) throws VirtualizationConfigurationException { if ( this.getVersion() != null && this.doc != null ) { // check if specified version is supported final String fileName = FILE_FORMAT_SCHEMA_VERSIONS.get( version ); if ( fileName == null ) { final String errorMsg = "File format version " + version.toString() + " is not supported!"; LOGGER.debug( errorMsg ); throw new VirtualizationConfigurationException( errorMsg ); } else { // specified version is supported, so validate document with corresponding schema file final InputStream schemaResource = VirtualizationConfigurationVirtualboxFileFormat .getSchemaResource( fileName ); if ( schemaResource != null ) { try { final SchemaFactory factory = SchemaFactory.newInstance( XMLConstants.W3C_XML_SCHEMA_NS_URI ); final Schema schema = factory.newSchema( new StreamSource( schemaResource ) ); final Validator validator = schema.newValidator(); validator.validate( new DOMSource( this.doc ) ); } catch ( SAXException | IOException e ) { final String errorMsg = "XML configuration is not a valid VirtualBox v" + version.toString() + " configuration: " + e.getLocalizedMessage(); LOGGER.debug( errorMsg ); throw new VirtualizationConfigurationException( errorMsg ); } } } } } private static InputStream getSchemaResource( String fileName ) { final String schemaFilePath = FILE_FORMAT_SCHEMA_PREFIX_PATH + File.separator + fileName; return VirtualizationConfigurationVirtualboxFileFormat.class.getResourceAsStream( schemaFilePath ); } /** * Main initialization functions parsing the document created during the constructor. * @throws VirtualizationConfigurationException */ private void init() throws VirtualizationConfigurationException { try { this.validate(); } catch ( VirtualizationConfigurationException e ) { // do not print output of failed validation if placeholders are available // since those placeholder values violate the defined UUID pattern if ( !this.checkForPlaceholders() ) { final String errorMsg = "XML configuration is not a valid VirtualBox v" + version.toString() + " configuration: " + e.getLocalizedMessage(); LOGGER.debug( errorMsg ); } } if ( Util.isEmptyString( getDisplayName() ) ) { throw new VirtualizationConfigurationException( "Machine doesn't have a name" ); } try { ensureHardwareUuid(); setOsType(); fixUsb(); // Since we now support selecting specific speed removeUnusedHdds(); if ( checkForPlaceholders() ) { return; } setHdds(); removeBlacklistedElements(); addPlaceHolders(); } catch ( XPathExpressionException e ) { LOGGER.debug( "Could not initialize VBoxConfig", e ); return; } } private void parseConfigurationVersion() throws VirtualizationConfigurationException { String versionText; try { versionText = XmlHelper.compileXPath( "/VirtualBox/@version" ).evaluate( this.doc ); } catch ( XPathExpressionException e ) { throw new VirtualizationConfigurationException( "Failed to parse the version number of the configuration file" ); } if ( versionText == null || versionText.isEmpty() ) { throw new VirtualizationConfigurationException( "Configuration file does not contain any version number!" ); } else { // parse version information from textual description final Pattern versionPattern = Pattern.compile( "^(\\d+\\.\\d+).*$" ); final Matcher versionMatcher = versionPattern.matcher( versionText ); if ( versionMatcher.find() ) { this.version = Version.valueOf( versionMatcher.group( 1 ) ); } if ( this.version == null ) { throw new VirtualizationConfigurationException( "Configuration file version number is not valid!" ); } } } private void fixUsb() { NodeList list = findNodes( "/VirtualBox/Machine/Hardware/USB/Controllers/Controller" ); if ( list != null && list.getLength() != 0 ) { LOGGER.info( "USB present, not fixing anything" ); return; } // If there's no USB section, this can mean two things: // 1) Old config that would always default to USB 2.0 for "USB enabled" or nothing for disabled // 2) New config with USB disabled list = findNodes( "/VirtualBox/OpenSLX/USB[@disabled]" ); if ( list != null && list.getLength() != 0 ) { LOGGER.info( "USB explicitly disabled" ); return; // Explicitly marked as disabled, do nothing } // We assume case 1) and add USB 2.0 LOGGER.info( "Fixing USB: Adding USB 2.0" ); Element controller; Element node = createNodeRecursive( "/VirtualBox/Machine/Hardware/USB/Controllers" ); controller = addNewNode( node, "Controller" ); controller.setAttribute( "name", "OHCI" ); controller.setAttribute( "type", "OHCI" ); controller = addNewNode( node, "Controller" ); controller.setAttribute( "name", "EHCI" ); controller.setAttribute( "type", "EHCI" ); } /** * Remove any HDDs from MediaRegistry that aren't being used */ private void removeUnusedHdds() { Set existing = new HashSet<>(); NodeList list = findNodes( storageControllersPath() + "/StorageController/AttachedDevice/Image" ); if ( list != null && list.getLength() != 0 ) { for ( int i = 0; i < list.getLength(); ++i ) { Node item = list.item( i ); if ( ! ( item instanceof Element ) ) continue; Element e = (Element)item; String uuid = e.getAttribute( "uuid" ); if ( Util.isEmptyString( uuid ) ) continue; existing.add( uuid ); } } // Now check registry list = findNodes( "/VirtualBox/Machine/MediaRegistry/HardDisks/HardDisk" ); if ( list != null && list.getLength() != 0 ) { for ( int i = 0; i < list.getLength(); ++i ) { Node item = list.item( i ); if ( ! ( item instanceof Element ) ) continue; Element e = (Element)item; String uuid = e.getAttribute( "uuid" ); if ( !existing.contains( uuid ) ) { LOGGER.info( "Removing unused HDD " + uuid + " from MediaRegistry" ); e.getParentNode().removeChild( e ); } } } } public String storageControllersPath() { if ( this.getVersion().isSmallerThan( Version.valueOf( "1.17" ) ) ) return "/VirtualBox/Machine/StorageControllers"; return "/VirtualBox/Machine/Hardware/StorageControllers"; } /** * Saves the machine's uuid as hardware uuid to prevent VMs from * believing in a hardware change. * * @throws XPathExpressionException * @throws VirtualizationConfigurationException */ private void ensureHardwareUuid() throws XPathExpressionException, VirtualizationConfigurationException { // we will need the machine uuid, so get it String machineUuid = XmlHelper.compileXPath( "/VirtualBox/Machine/@uuid" ).evaluate( this.doc ); if ( machineUuid.isEmpty() ) { LOGGER.error( "Machine UUID empty, should never happen!" ); throw new VirtualizationConfigurationException( "XML doesn't contain a machine uuid" ); } NodeList hwNodes = findNodes( "/VirtualBox/Machine/Hardware" ); int count = hwNodes.getLength(); if ( count != 1 ) { throw new VirtualizationConfigurationException( "Zero or > 1 '/VirtualBox/Machine/Hardware' node were found, should never happen!" ); } Element hw = (Element)hwNodes.item( 0 ); String hwUuid = hw.getAttribute( "uuid" ); if ( !hwUuid.isEmpty() ) { LOGGER.info( "Found hardware uuid: " + hwUuid ); return; } else { if ( !addAttributeToNode( hw, "uuid", machineUuid ) ) { LOGGER.error( "Failed to set machine UUID '" + machineUuid + "' as hardware UUID." ); return; } LOGGER.info( "Saved machine UUID as hardware UUID." ); } } public Version getVersion() { return this.version; } /** * Self-explanatory. */ public void addPlaceHolders() { // placeholder for the location of the virtual hdd changeAttribute( "/VirtualBox/Machine/MediaRegistry/HardDisks/HardDisk", "location", DUMMY_VALUE, MatchMode.MULTIPLE ); // in case it already has a snapshot changeAttribute( "/VirtualBox/Machine/MediaRegistry/HardDisks/HardDisk/HardDisk", "location", DUMMY_VALUE, MatchMode.MULTIPLE ); changeAttribute( "/VirtualBox/Machine", "snapshotFolder", DUMMY_VALUE, MatchMode.FIRST_ONLY ); } /** * Function checks if the placeholders are present * * @return true if the placeholders are present, false otherwise */ private boolean checkForPlaceholders() { // TODO this should be more robust... NodeList hdds = findNodes( "/VirtualBox/Machine/MediaRegistry/HardDisks/HardDisk" ); for ( int i = 0; i < hdds.getLength(); i++ ) { Element hdd = (Element)hdds.item( i ); if ( hdd == null ) continue; if ( hdd.getAttribute( "location" ).equals( DUMMY_VALUE ) ) { return true; } } return false; } /** * Called during init(), prunes the DOM from the elements blacklisted defined * in the member blacklist, a list of XPath expressions as String * * @throws XPathExpressionException */ private void removeBlacklistedElements() throws XPathExpressionException { // iterate over the blackList for ( String blackedTag : BLACKLIST ) { XPathExpression blackedExpr = XmlHelper.compileXPath( blackedTag ); NodeList blackedNodes = (NodeList)blackedExpr.evaluate( this.doc, XPathConstants.NODESET ); for ( int i = 0; i < blackedNodes.getLength(); i++ ) { // go through the child nodes of the blacklisted ones -> why? Element child = (Element)blackedNodes.item( i ); removeNode( child ); } } } /** * Getter for the display name * * @return the display name of this VM */ public String getDisplayName() { try { return XmlHelper.compileXPath( "/VirtualBox/Machine/@name" ).evaluate( this.doc ); } catch ( XPathExpressionException e ) { return ""; } } /** * Function finds and saves the name of the guest OS * * @throws XPathExpressionException failed to find and retrieve name of the guest OS. */ public void setOsType() throws XPathExpressionException { String os = XmlHelper.compileXPath( "/VirtualBox/Machine/@OSType" ).evaluate( this.doc ); if ( os != null && !os.isEmpty() ) { osName = os; } } /** * Getter for the parsed guest OS name * * @return name of the guest OS */ public String getOsName() { return osName; } /** * Search for attached hard drives and determine their controller and their path. * * @throws XPathExpressionException failed to find attached hard drives and their controllers. */ public void setHdds() throws XPathExpressionException { final XPathExpression hddsExpr = XmlHelper.compileXPath( storageControllersPath() + "/StorageController/AttachedDevice[@type='HardDisk']/Image" ); NodeList nodes = (NodeList)hddsExpr.evaluate( this.doc, XPathConstants.NODESET ); if ( nodes == null ) { LOGGER.error( "Failed to find attached hard drives." ); return; } for ( int i = 0; i < nodes.getLength(); i++ ) { Element hddElement = (Element)nodes.item( i ); if ( hddElement == null ) continue; String uuid = hddElement.getAttribute( "uuid" ); if ( uuid.isEmpty() ) continue; // got uuid, check if it was registered XPathExpression hddsRegistered = XmlHelper.compileXPath( "/VirtualBox/Machine/MediaRegistry/HardDisks/HardDisk[@uuid='" + uuid + "']" ); NodeList hddsRegisteredNodes = (NodeList)hddsRegistered.evaluate( this.doc, XPathConstants.NODESET ); if ( hddsRegisteredNodes == null || hddsRegisteredNodes.getLength() != 1 ) { LOGGER.error( "Found hard disk with uuid '" + uuid + "' which does not appear (unique) in the Media Registry. Skipping." ); continue; } Element hddElementReg = (Element)hddsRegisteredNodes.item( 0 ); if ( hddElementReg == null ) continue; String fileName = hddElementReg.getAttribute( "location" ); String type = hddElementReg.getAttribute( "type" ); if ( !type.equals( "Normal" ) && !type.equals( "Writethrough" ) ) { LOGGER.warn( "Type of the disk file is neither 'Normal' nor 'Writethrough' but: " + type ); LOGGER.warn( "This makes the image not directly modificable, which might lead to problems when editing it locally." ); } // search if it is also attached to a controller Node hddDevice = hddElement.getParentNode(); if ( hddDevice == null ) { LOGGER.error( "HDD node had a null parent, shouldn't happen" ); continue; } Element hddController = (Element)hddDevice.getParentNode(); if ( hddController == null ) { LOGGER.error( "HDD node had a null parent, shouldn't happen" ); continue; } String controllerMode = hddController.getAttribute( "type" ); String controllerType = hddController.getAttribute( "name" ); DriveBusType busType; if ( controllerType.equals( "NVMe" ) ) { busType = DriveBusType.NVME; } else { try { // This assumes the type in the xml matches our enum constants. busType = DriveBusType.valueOf( controllerType ); } catch (Exception e) { LOGGER.warn( "Skipping unknown HDD controller type '" + controllerType + "'" ); continue; } } LOGGER.info( "Adding hard disk with controller: " + busType + " (" + controllerMode + ") from file '" + fileName + "'." ); hddsArray.add( new HardDisk( controllerMode, busType, fileName ) ); } } /** * Getter for the list of detected hard drives. * * @return list of disk drives. */ public ArrayList getHdds() { return hddsArray; } /** * Detect if the vbox file has any machine snapshot by looking at * the existance of '/VirtualBox/Machine/Snapshot' elements. * * @return true if a machine snapshot is present, false otherwise. */ public boolean isMachineSnapshot() { // check if the vbox configuration file contains some machine snapshots. // by looking at the existance of /VirtualBox/Machine/Snapshot NodeList machineSnapshots = findNodes( "/VirtualBox/Machine/Snapshot" ); return machineSnapshots != null && machineSnapshots.getLength() > 0; } /** * Searches the DOM for the elements matching the given XPath expression. * * @param xpath expression to search the DOM with * @return nodes found by evaluating given XPath expression */ public NodeList findNodes( String xpath ) { NodeList nodes = null; try { XPathExpression expr = XmlHelper.compileXPath( xpath ); Object nodesObject = expr.evaluate( this.doc, XPathConstants.NODESET ); nodes = (NodeList)nodesObject; } catch ( XPathExpressionException e ) { LOGGER.error( "Could not build path", e ); } return nodes; } /** * Function used to change the value of an attribute of given element(s). * * @param elementXPath given as an xpath expression * @param attribute attribute to change * @param value to set the attribute to * @param mode what to do if multiple nodes match XPath * @return state of the change operation whether the attribute was changed successfully or not. */ public boolean changeAttribute( String elementXPath, String attribute, String value, MatchMode mode ) { NodeList nodes = findNodes( elementXPath ); if ( nodes == null || nodes.getLength() == 0 ) { if ( mode != MatchMode.MULTIPLE ) { LOGGER.error( "No node could be found for: " + elementXPath ); } return false; } if ( nodes.getLength() != 1 && ( mode == MatchMode.EXACTLY_ONE ) ) { LOGGER.error( "Multiple nodes found for: " + elementXPath ); return false; } boolean ret = true; for ( int i = 0; i < nodes.getLength(); ++i ) { if ( !addAttributeToNode( nodes.item( i ), attribute, value ) ) { ret = false; } if ( mode == MatchMode.FIRST_ONLY ) break; } return ret; } /** * Add given attribute with given value to the given node. * NOTE: this will overwrite the attribute of the node if it already exists. * * @param node to add the attribute to * @param attribute attribute to add to the node * @param value of the attribute * @return true if successful, false otherwise */ public boolean addAttributeToNode( Node node, String attribute, String value ) { if ( node == null || node.getNodeType() != Node.ELEMENT_NODE ) { LOGGER.error( "Trying to change attribute of a non element node!" ); return false; } try { ( (Element)node ).setAttribute( attribute, value ); } catch ( DOMException e ) { LOGGER.error( "Failed set '" + attribute + "' to '" + value + "' of xml node '" + node.getNodeName() + "': ", e ); return false; } return true; } /** * Adds a new node named nameOfNewNode to the given parent found by parentXPath. * * @param parentXPath XPath expression to the parent * @param childName name of the node to be added * @return the newly added Node */ public Element addNewNode( String parentXPath, String childName ) { NodeList possibleParents = findNodes( parentXPath ); if ( possibleParents == null || possibleParents.getLength() != 1 ) { LOGGER.error( "Could not find unique parent node to add new node to: " + parentXPath ); return null; } return addNewNode( possibleParents.item( 0 ), childName ); } public Element createNodeRecursive( String xPath ) { String[] nodeNames = xPath.split( "/" ); Node parent = this.doc; Element latest = null; for ( int nodeIndex = 0; nodeIndex < nodeNames.length; ++nodeIndex ) { if ( nodeNames[nodeIndex].length() == 0 ) continue; Node node = skipNonElementNodes( parent.getFirstChild() ); while ( node != null ) { if ( node.getNodeType() == Node.ELEMENT_NODE && nodeNames[nodeIndex].equals( node.getNodeName() ) ) break; // Found existing // Check next on same level node = skipNonElementNodes( node.getNextSibling() ); } if ( node == null ) { node = doc.createElement( nodeNames[nodeIndex] ); parent.appendChild( node ); } parent = node; latest = (Element)node; } return latest; } private Element skipNonElementNodes( Node nn ) { while ( nn != null && nn.getNodeType() != Node.ELEMENT_NODE ) { nn = nn.getNextSibling(); } return (Element)nn; } public void setExtraData( String key, String value ) { NodeList nl = findNodes( "/VirtualBox/Machine/ExtraData/ExtraDataItem" ); Element e = null; if ( nl != null ) { for ( int i = 0; i < nl.getLength(); ++i ) { Node n = nl.item( i ); if ( n.getNodeType() == Node.ELEMENT_NODE ) { final Element ne = (Element)n; final String keyValue = ne.getAttribute( "name" ); if ( keyValue != null && keyValue.equals( key ) ) { e = ne; break; } } } } if ( e == null ) { Element p = createNodeRecursive( "/VirtualBox/Machine/ExtraData" ); e = addNewNode( p, "ExtraDataItem" ); e.setAttribute( "name", key ); } e.setAttribute( "value", value ); } /** * Creates a new element to the given parent node. * * @param parent to add the new element to * @param childName name of the new element to create * @return the newly created node */ public Element addNewNode( Node parent, String childName ) { if ( parent == null || parent.getNodeType() != Node.ELEMENT_NODE ) { return null; } Element newNode = null; try { newNode = doc.createElement( childName ); parent.appendChild( newNode ); } catch ( DOMException e ) { LOGGER.error( "Failed to add '" + childName + "' to '" + parent.getNodeName() + "'." ); } return newNode; } /** * Helper to remove given node from the DOM. * * @param node Node object to remove. */ private void removeNode( Node node ) { if ( node == null ) return; Node parent = node.getParentNode(); if ( parent != null ) parent.removeChild( node ); } /** * Helper to output the DOM as a String. * * @param prettyPrint sets whether to indent the output * @return (un-)formatted XML */ public String toString( boolean prettyPrint ) { return XmlHelper.getXmlFromDocument( doc, prettyPrint ); } /** * Remove all nodes with name childName from parentPath * @param parentPath XPath to parent node of where child nodes are to be deleted * @param childName Name of nodes to delete */ public void removeNodes( String parentPath, String childName ) { NodeList parentNodes = findNodes( parentPath ); // XPath might match multiple nodes for ( int i = 0; i < parentNodes.getLength(); ++i ) { Node parent = parentNodes.item( i ); List delList = new ArrayList<>( 0 ); // Iterate over child nodes for ( Node child = parent.getFirstChild(); child != null; child = child.getNextSibling() ) { if ( childName.equals( child.getNodeName() ) ) { // Remember all to be deleted (don't delete while iterating) delList.add( child ); } } // Now delete them all for ( Node child : delList ) { parent.removeChild( child ); } } } }