From 3ec92c8b621effe93c448d187a76c678d9d25121 Mon Sep 17 00:00:00 2001
From: jrandom <jrandom>
Date: Fri, 16 Dec 2005 03:00:48 +0000
Subject: [PATCH] 2005-12-15  jrandom     * Added a first pass to the I2PSnark
 web UI (see /i2psnark/)

---
 apps/i2psnark/java/build.xml                  |  13 +-
 .../org/klomp/snark/ConnectionAcceptor.java   | 102 ++--
 .../src/org/klomp/snark/I2PSnarkUtil.java     |  56 +-
 .../java/src/org/klomp/snark/Peer.java        |   2 +-
 .../src/org/klomp/snark/PeerCheckerTask.java  |   3 +
 .../src/org/klomp/snark/PeerConnectionIn.java |  27 +
 .../org/klomp/snark/PeerConnectionOut.java    |  13 +-
 .../src/org/klomp/snark/PeerCoordinator.java  |  25 +-
 .../java/src/org/klomp/snark/Snark.java       |  39 +-
 .../src/org/klomp/snark/SnarkManager.java     | 149 ++++-
 .../src/org/klomp/snark/TrackerClient.java    |  47 +-
 .../org/klomp/snark/web/I2PSnarkServlet.java  | 524 ++++++++++++++++++
 apps/i2psnark/web.xml                         |  25 +
 apps/routerconsole/jsp/nav.jsp                |   1 +
 build.xml                                     |   6 +-
 history.txt                                   |   5 +-
 16 files changed, 930 insertions(+), 107 deletions(-)
 create mode 100644 apps/i2psnark/java/src/org/klomp/snark/web/I2PSnarkServlet.java
 create mode 100644 apps/i2psnark/web.xml

diff --git a/apps/i2psnark/java/build.xml b/apps/i2psnark/java/build.xml
index 4a447a6f75..3d23a8ba68 100644
--- a/apps/i2psnark/java/build.xml
+++ b/apps/i2psnark/java/build.xml
@@ -1,8 +1,9 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <project basedir="." default="all" name="i2psnark">
     <target name="all" depends="clean, build" />
-    <target name="build" depends="builddep, jar" />
+    <target name="build" depends="builddep, jar, war" />
     <target name="builddep">
+        <ant dir="../../jetty/" target="build" />
         <ant dir="../../ministreaming/java/" target="build" />
 	<!-- ministreaming will build core -->
     </target>
@@ -13,18 +14,24 @@
             srcdir="./src" 
             debug="true" deprecation="on" source="1.3" target="1.3" 
             destdir="./build/obj" 
-            classpath="../../../core/java/build/i2p.jar:../../ministreaming/java/build/mstreaming.jar" />
+            classpath="../../../core/java/build/i2p.jar:../../jetty/jettylib/org.mortbay.jetty.jar:../../jetty/jettylib/javax.servlet.jar:../../ministreaming/java/build/mstreaming.jar" />
     </target>
     <target name="jar" depends="builddep, compile">
-        <jar destfile="./build/i2psnark.jar" basedir="./build/obj" includes="**/*.class">
+        <jar destfile="./build/i2psnark.jar" basedir="./build/obj" includes="**/*.class" excludes="**/*Servlet.class">
             <manifest>
                 <attribute name="Main-Class" value="org.klomp.snark.Snark" />
                 <attribute name="Class-Path" value="i2p.jar mstreaming.jar streaming.jar" />
             </manifest>
         </jar>
+    </target>    
+    <target name="war" depends="jar"> 
+        <war destfile="../i2psnark.war" webxml="../web.xml">
+          <classes dir="./build/obj" includes="**/*" />
+        </war>
     </target>
     <target name="clean">
         <delete dir="./build" />
+        <delete file="../i2psnark.war" />
     </target>
     <target name="cleandep" depends="clean">
         <ant dir="../../ministreaming/java/" target="distclean" />
diff --git a/apps/i2psnark/java/src/org/klomp/snark/ConnectionAcceptor.java b/apps/i2psnark/java/src/org/klomp/snark/ConnectionAcceptor.java
index c5b21d5c34..4a41d63e14 100644
--- a/apps/i2psnark/java/src/org/klomp/snark/ConnectionAcceptor.java
+++ b/apps/i2psnark/java/src/org/klomp/snark/ConnectionAcceptor.java
@@ -32,11 +32,12 @@ import net.i2p.client.streaming.I2PSocket;
  */
 public class ConnectionAcceptor implements Runnable
 {
-  private final I2PServerSocket serverSocket;
+  private I2PServerSocket serverSocket;
   private final PeerAcceptor peeracceptor;
   private Thread thread;
 
   private boolean stop;
+  private boolean socketChanged;
 
   public ConnectionAcceptor(I2PServerSocket serverSocket,
                             PeerAcceptor peeracceptor)
@@ -44,8 +45,10 @@ public class ConnectionAcceptor implements Runnable
     this.serverSocket = serverSocket;
     this.peeracceptor = peeracceptor;
     
+    socketChanged = false;
     stop = false;
-    thread = new Thread(this);
+    thread = new Thread(this, "I2PSnark acceptor");
+    thread.setDaemon(true);
     thread.start();
   }
 
@@ -65,6 +68,14 @@ public class ConnectionAcceptor implements Runnable
     if (t != null)
       t.interrupt();
   }
+  
+  public void restart() {
+      serverSocket = I2PSnarkUtil.instance().getServerSocket();
+      socketChanged = true;
+      Thread t = thread;
+      if (t != null)
+          t.interrupt();
+  }
 
   public int getPort()
   {
@@ -75,57 +86,35 @@ public class ConnectionAcceptor implements Runnable
   {
     while(!stop)
       {
+        if (socketChanged) {
+            // ok, already updated
+            socketChanged = false;
+        }
+        if (serverSocket == null) {
+            Snark.debug("Server socket went away.. boo hiss", Snark.ERROR);
+            stop = true;
+            return;
+        }
         try
           {
-            final I2PSocket socket = serverSocket.accept();
-            Thread t = new Thread("Connection-" + socket)
-              {
-                public void run()
-                {
-                  try
-                    {
-                      InputStream in = socket.getInputStream();
-                      OutputStream out = socket.getOutputStream();
-                      BufferedInputStream bis = new BufferedInputStream(in);
-                      BufferedOutputStream bos = new BufferedOutputStream(out);
-                      
-                      // See what kind of connection it is.
-                      /*
-                      if (httpacceptor != null)
-                        {
-                          byte[] scratch = new byte[4];
-                          bis.mark(4);
-                          int len = bis.read(scratch);
-                          if (len != 4)
-                            throw new IOException("Need at least 4 bytes");
-                          bis.reset();
-                          if (scratch[0] == 19 && scratch[1] == 'B'
-                              && scratch[2] == 'i' && scratch[3] == 't')
-                            peeracceptor.connection(socket, bis, bos);
-                          else if (scratch[0] == 'G' && scratch[1] == 'E'
-                                   && scratch[2] == 'T' && scratch[3] == ' ')
-                            httpacceptor.connection(socket, bis, bos);
-                        }
-                      else
-                       */
-                        peeracceptor.connection(socket, bis, bos);
-                    }
-                  catch (IOException ioe)
-                    {
-                      try
-                        {
-                          socket.close();
-                        }
-                      catch (IOException ignored) { }
-                    }
+            I2PSocket socket = serverSocket.accept();
+            if (socket == null) {
+                if (socketChanged) {
+                    continue;
+                } else {
+                    Snark.debug("Null socket accepted, but socket wasn't changed?", Snark.ERROR);
                 }
-              };
-            t.start();
+            } else {
+                Thread t = new Thread(new Handler(socket), "Connection-" + socket);
+                t.start();
+            }
           }
         catch (I2PException ioe)
           {
-            Snark.debug("Error while accepting: " + ioe, Snark.ERROR);
-            stop = true;
+            if (!socketChanged) {
+                Snark.debug("Error while accepting: " + ioe, Snark.ERROR);
+                stop = true;
+            }
           }
         catch (IOException ioe)
           {
@@ -140,4 +129,23 @@ public class ConnectionAcceptor implements Runnable
       }
     catch (I2PException ignored) { }
   }
+  
+  private class Handler implements Runnable {
+      private I2PSocket _socket;
+      public Handler(I2PSocket socket) {
+          _socket = socket;
+      }
+      public void run() {
+          try {
+              InputStream in = _socket.getInputStream();
+              OutputStream out = _socket.getOutputStream();
+              BufferedInputStream bis = new BufferedInputStream(in);
+              BufferedOutputStream bos = new BufferedOutputStream(out);
+
+              peeracceptor.connection(_socket, bis, bos);
+          } catch (IOException ioe) {
+              try { _socket.close(); } catch (IOException ignored) { }
+          }
+      }
+  }
 }
diff --git a/apps/i2psnark/java/src/org/klomp/snark/I2PSnarkUtil.java b/apps/i2psnark/java/src/org/klomp/snark/I2PSnarkUtil.java
index 25da3c2983..339eec8dde 100644
--- a/apps/i2psnark/java/src/org/klomp/snark/I2PSnarkUtil.java
+++ b/apps/i2psnark/java/src/org/klomp/snark/I2PSnarkUtil.java
@@ -13,7 +13,7 @@ import net.i2p.client.streaming.I2PSocketManagerFactory;
 import net.i2p.util.Log;
 
 import java.io.*;
-import java.util.Properties;
+import java.util.*;
 
 /**
  * I2P specific helpers for I2PSnark
@@ -29,14 +29,17 @@ public class I2PSnarkUtil {
     private int _proxyPort;
     private String _i2cpHost;
     private int _i2cpPort;
-    private Properties _opts;
+    private Map _opts;
     private I2PSocketManager _manager;
+    private boolean _configured;
     
     private I2PSnarkUtil() {
         _context = I2PAppContext.getGlobalContext();
         _log = _context.logManager().getLog(Snark.class);
+        _opts = new HashMap();
         setProxy("127.0.0.1", 4444);
         setI2CPConfig("127.0.0.1", 7654, null);
+        _configured = false;
     }
     
     /**
@@ -54,25 +57,55 @@ public class I2PSnarkUtil {
             _proxyHost = null;
             _proxyPort = -1;
         }
+        _configured = true;
     }
     
-    public void setI2CPConfig(String i2cpHost, int i2cpPort, Properties opts) {
+    public boolean configured() { return _configured; }
+    
+    public void setI2CPConfig(String i2cpHost, int i2cpPort, Map opts) {
         _i2cpHost = i2cpHost;
         _i2cpPort = i2cpPort;
         if (opts != null)
-            _opts = opts;
+            _opts.putAll(opts);
+        _configured = true;
     }
     
+    public String getI2CPHost() { return _i2cpHost; }
+    public int getI2CPPort() { return _i2cpPort; }
+    public Map getI2CPOptions() { return _opts; }
+    public String getEepProxyHost() { return _proxyHost; }
+    public int getEepProxyPort() { return _proxyPort; }
+    public boolean getEepProxySet() { return _shouldProxy; }
+    
     /**
      * Connect to the router, if we aren't already
      */
-    boolean connect() {
+    public boolean connect() {
         if (_manager == null) {
-            _manager = I2PSocketManagerFactory.createManager(_i2cpHost, _i2cpPort, _opts);
+            Properties opts = new Properties();
+            if (_opts != null) {
+                for (Iterator iter = _opts.keySet().iterator(); iter.hasNext(); ) {
+                    String key = (String)iter.next();
+                    opts.setProperty(key, _opts.get(key).toString());
+                }
+            }
+            if (opts.getProperty("inbound.nickname") == null)
+                opts.setProperty("inbound.nickname", "I2PSnark");
+            _manager = I2PSocketManagerFactory.createManager(_i2cpHost, _i2cpPort, opts);
         }
         return (_manager != null);
     }
     
+    public boolean connected() { return _manager != null; }
+    /**
+     * Destroy the destination itself
+     */
+    public void disconnect() {
+        I2PSocketManager mgr = _manager;
+        _manager = null;
+        mgr.destroySocketManager();
+    }
+    
     /** connect to the given destination */
     I2PSocket connect(PeerID peer) throws IOException {
         try {
@@ -85,7 +118,8 @@ public class I2PSnarkUtil {
     /**
      * fetch the given URL, returning the file it is stored in, or null on error
      */
-    File get(String url) {
+    public File get(String url) { return get(url, true); }
+    public File get(String url, boolean rewrite) {
         _log.debug("Fetching [" + url + "] proxy=" + _proxyHost + ":" + _proxyPort + ": " + _shouldProxy);
         File out = null;
         try {
@@ -95,8 +129,10 @@ public class I2PSnarkUtil {
             out.delete();
             return null;
         }
-        String fetchURL = rewriteAnnounce(url);
-        _log.debug("Rewritten url [" + fetchURL + "]");
+        String fetchURL = url;
+        if (rewrite)
+            fetchURL = rewriteAnnounce(url);
+        //_log.debug("Rewritten url [" + fetchURL + "]");
         EepGet get = new EepGet(_context, _shouldProxy, _proxyHost, _proxyPort, 1, out.getAbsolutePath(), fetchURL);
         if (get.fetch()) {
             _log.debug("Fetch successful [" + url + "]: size=" + out.length());
@@ -108,7 +144,7 @@ public class I2PSnarkUtil {
         }
     }
     
-    I2PServerSocket getServerSocket() { 
+    public I2PServerSocket getServerSocket() { 
         return _manager.getServerSocket();
     }
     
diff --git a/apps/i2psnark/java/src/org/klomp/snark/Peer.java b/apps/i2psnark/java/src/org/klomp/snark/Peer.java
index aafe529f43..ef3fe2b27d 100644
--- a/apps/i2psnark/java/src/org/klomp/snark/Peer.java
+++ b/apps/i2psnark/java/src/org/klomp/snark/Peer.java
@@ -37,7 +37,7 @@ public class Peer implements Comparable
   private final PeerID peerID;
 
   private final byte[] my_id;
-  private final MetaInfo metainfo;
+  final MetaInfo metainfo;
 
   // The data in/output streams set during the handshake and used by
   // the actual connections.
diff --git a/apps/i2psnark/java/src/org/klomp/snark/PeerCheckerTask.java b/apps/i2psnark/java/src/org/klomp/snark/PeerCheckerTask.java
index 90d772f68c..532d48aab1 100644
--- a/apps/i2psnark/java/src/org/klomp/snark/PeerCheckerTask.java
+++ b/apps/i2psnark/java/src/org/klomp/snark/PeerCheckerTask.java
@@ -70,6 +70,7 @@ class PeerCheckerTask extends TimerTask
               {
                 it.remove();
                 coordinator.removePeerFromPieces(peer);
+                coordinator.peerCount = coordinator.peers.size();
                 continue;
               }
 
@@ -185,6 +186,7 @@ class PeerCheckerTask extends TimerTask
 
             // Put it at the back of the list
             coordinator.peers.remove(worstDownloader);
+            coordinator.peerCount = coordinator.peers.size();
             removed.add(worstDownloader);
           }
         
@@ -193,6 +195,7 @@ class PeerCheckerTask extends TimerTask
 
         // Put peers back at the end of the list that we removed earlier.
         coordinator.peers.addAll(removed);
+        coordinator.peerCount = coordinator.peers.size();
       }
   }
 }
diff --git a/apps/i2psnark/java/src/org/klomp/snark/PeerConnectionIn.java b/apps/i2psnark/java/src/org/klomp/snark/PeerConnectionIn.java
index c3d5f95bef..35b428a9c3 100644
--- a/apps/i2psnark/java/src/org/klomp/snark/PeerConnectionIn.java
+++ b/apps/i2psnark/java/src/org/klomp/snark/PeerConnectionIn.java
@@ -24,8 +24,11 @@ import java.io.*;
 import java.net.*;
 import java.util.*;
 
+import net.i2p.util.Log;
+
 class PeerConnectionIn implements Runnable
 {
+  private Log _log = new Log(PeerConnectionIn.class);
   private final Peer peer;
   private final DataInputStream din;
 
@@ -72,6 +75,8 @@ class PeerConnectionIn implements Runnable
             if (i == 0)
               {
                 ps.keepAliveMessage();
+                if (_log.shouldLog(Log.DEBUG)) 
+                    _log.debug("Received keepalive from " + peer + " on " + peer.metainfo.getName());
                 continue;
               }
             
@@ -82,30 +87,44 @@ class PeerConnectionIn implements Runnable
               {
               case 0:
                 ps.chokeMessage(true);
+                if (_log.shouldLog(Log.DEBUG)) 
+                    _log.debug("Received choke from " + peer + " on " + peer.metainfo.getName());
                 break;
               case 1:
                 ps.chokeMessage(false);
+                if (_log.shouldLog(Log.DEBUG)) 
+                    _log.debug("Received unchoke from " + peer + " on " + peer.metainfo.getName());
                 break;
               case 2:
                 ps.interestedMessage(true);
+                if (_log.shouldLog(Log.DEBUG)) 
+                    _log.debug("Received interested from " + peer + " on " + peer.metainfo.getName());
                 break;
               case 3:
                 ps.interestedMessage(false);
+                if (_log.shouldLog(Log.DEBUG)) 
+                    _log.debug("Received not interested from " + peer + " on " + peer.metainfo.getName());
                 break;
               case 4:
                 piece = din.readInt();
                 ps.haveMessage(piece);
+                if (_log.shouldLog(Log.DEBUG)) 
+                    _log.debug("Received havePiece(" + piece + ") from " + peer + " on " + peer.metainfo.getName());
                 break;
               case 5:
                 byte[] bitmap = new byte[i-1];
                 din.readFully(bitmap);
                 ps.bitfieldMessage(bitmap);
+                if (_log.shouldLog(Log.DEBUG)) 
+                    _log.debug("Received bitmap from " + peer + " on " + peer.metainfo.getName());
                 break;
               case 6:
                 piece = din.readInt();
                 begin = din.readInt();
                 len = din.readInt();
                 ps.requestMessage(piece, begin, len);
+                if (_log.shouldLog(Log.DEBUG)) 
+                    _log.debug("Received request(" + piece + "," + begin + ") from " + peer + " on " + peer.metainfo.getName());
                 break;
               case 7:
                 piece = din.readInt();
@@ -118,12 +137,16 @@ class PeerConnectionIn implements Runnable
                     piece_bytes = req.bs;
                     din.readFully(piece_bytes, begin, len);
                     ps.pieceMessage(req);
+                    if (_log.shouldLog(Log.DEBUG)) 
+                        _log.debug("Received data(" + piece + "," + begin + ") from " + peer + " on " + peer.metainfo.getName());
                   }
                 else
                   {
                     // XXX - Consume but throw away afterwards.
                     piece_bytes = new byte[len];
                     din.readFully(piece_bytes);
+                    if (_log.shouldLog(Log.DEBUG)) 
+                        _log.debug("Received UNWANTED data(" + piece + "," + begin + ") from " + peer + " on " + peer.metainfo.getName());
                   }
                 break;
               case 8:
@@ -131,11 +154,15 @@ class PeerConnectionIn implements Runnable
                 begin = din.readInt();
                 len = din.readInt();
                 ps.cancelMessage(piece, begin, len);
+                if (_log.shouldLog(Log.DEBUG)) 
+                    _log.debug("Received cancel(" + piece + "," + begin + ") from " + peer + " on " + peer.metainfo.getName());
                 break;
               default:
                 byte[] bs = new byte[i-1];
                 din.readFully(bs);
                 ps.unknownMessage(b, bs);
+                if (_log.shouldLog(Log.DEBUG)) 
+                    _log.debug("Received unknown message from " + peer + " on " + peer.metainfo.getName());
               }
           }
       }
diff --git a/apps/i2psnark/java/src/org/klomp/snark/PeerConnectionOut.java b/apps/i2psnark/java/src/org/klomp/snark/PeerConnectionOut.java
index 8d09859a74..817d98927a 100644
--- a/apps/i2psnark/java/src/org/klomp/snark/PeerConnectionOut.java
+++ b/apps/i2psnark/java/src/org/klomp/snark/PeerConnectionOut.java
@@ -24,8 +24,11 @@ import java.io.*;
 import java.net.*;
 import java.util.*;
 
+import net.i2p.util.Log;
+
 class PeerConnectionOut implements Runnable
 {
+  private Log _log = new Log(PeerConnectionOut.class);
   private final Peer peer;
   private final DataOutputStream dout;
 
@@ -34,14 +37,18 @@ class PeerConnectionOut implements Runnable
 
   // Contains Messages.
   private List sendQueue = new ArrayList();
+  
+  private static long __id = 0;
+  private long _id;
 
   public PeerConnectionOut(Peer peer, DataOutputStream dout)
   {
     this.peer = peer;
     this.dout = dout;
+    _id = ++__id;
 
     quit = false;
-    thread = new Thread(this);
+    thread = new Thread(this, "Snark sender " + _id);
     thread.start();
   }
 
@@ -64,6 +71,8 @@ class PeerConnectionOut implements Runnable
                     try
                       {
                         // Make sure everything will reach the other side.
+                        // i2p flushes passively, no need to force it
+                        // ... maybe not though
                         dout.flush();
                         
                         // Wait till more data arrives.
@@ -114,6 +123,8 @@ class PeerConnectionOut implements Runnable
               {
                 if (Snark.debug >= Snark.ALL)
                   Snark.debug("Send " + peer + ": " + m, Snark.ALL);
+                if (_log.shouldLog(Log.DEBUG))
+                    _log.debug("Send " + peer + ": " + m + " on " + peer.metainfo.getName());
                 m.sendMessage(dout);
 
                 // Remove all piece messages after sending a choke message.
diff --git a/apps/i2psnark/java/src/org/klomp/snark/PeerCoordinator.java b/apps/i2psnark/java/src/org/klomp/snark/PeerCoordinator.java
index 7d8e77555c..f98e45d499 100644
--- a/apps/i2psnark/java/src/org/klomp/snark/PeerCoordinator.java
+++ b/apps/i2psnark/java/src/org/klomp/snark/PeerCoordinator.java
@@ -51,6 +51,8 @@ public class PeerCoordinator implements PeerListener
 
   // synchronize on this when changing peers or downloaders
   final List peers = new ArrayList();
+  /** estimate of the peers, without requiring any synchronization */
+  volatile int peerCount;
 
   /** Timer to handle all periodical tasks. */
   private final Timer timer = new Timer(true);
@@ -63,6 +65,9 @@ public class PeerCoordinator implements PeerListener
   private boolean halted = false;
 
   private final CoordinatorListener listener;
+  
+  public String trackerProblems = null;
+  public int trackerSeenPeers = 0;
 
   public PeerCoordinator(byte[] id, MetaInfo metainfo, Storage storage,
                          CoordinatorListener listener)
@@ -97,12 +102,15 @@ public class PeerCoordinator implements PeerListener
     return storage.complete();
   }
 
+  public int getPeerCount() { return peerCount; }
 
   public int getPeers()
   {
     synchronized(peers)
       {
-        return peers.size();
+        int rv = peers.size();
+        peerCount = rv;
+        return rv;
       }
   }
 
@@ -163,6 +171,7 @@ public class PeerCoordinator implements PeerListener
             it.remove();
             removePeerFromPieces(peer);
           }
+        peerCount = peers.size();
       }
   }
 
@@ -187,9 +196,11 @@ public class PeerCoordinator implements PeerListener
             if (Snark.debug >= Snark.INFO)
               Snark.debug("New connection to peer: " + peer, Snark.INFO);
 
+            _log.info("New connection to peer " + peer + " for " + metainfo.getName());
             // Add it to the beginning of the list.
             // And try to optimistically make it a uploader.
             peers.add(0, peer);
+            peerCount = peers.size();
             unchokePeer();
 
             if (listener != null)
@@ -223,7 +234,7 @@ public class PeerCoordinator implements PeerListener
 
     if (need_more)
       {
-        _log.debug("Addng a peer " + peer.getPeerID().getAddress().calculateHash().toBase64(), new Exception("add/run"));
+        _log.debug("Adding a peer " + peer.getPeerID().getAddress().calculateHash().toBase64() + " for " + metainfo.getName(), new Exception("add/run"));
 
         // Run the peer with us as listener and the current bitfield.
         final PeerListener listener = this;
@@ -281,6 +292,7 @@ public class PeerCoordinator implements PeerListener
         // Put peer back at the end of the list.
         peers.remove(peer);
         peers.add(peer);
+        peerCount = peers.size();
       }
   }
 
@@ -422,8 +434,10 @@ public class PeerCoordinator implements PeerListener
    */
   public boolean gotPiece(Peer peer, int piece, byte[] bs)
   {
-    if (halted)
+    if (halted) {
+      _log.info("Got while-halted piece " + piece + "/" + metainfo.getPieces() +" from " + peer + " for " + metainfo.getName());
       return true; // We don't actually care anymore.
+    }
     
     synchronized(wantedPieces)
       {
@@ -433,6 +447,8 @@ public class PeerCoordinator implements PeerListener
             if (Snark.debug >= Snark.INFO)
               Snark.debug(peer + " piece " + piece + " no longer needed",
                           Snark.INFO);
+
+            _log.info("Got unwanted piece " + piece + "/" + metainfo.getPieces() +" from " + peer + " for " + metainfo.getName());
             
             // No need to announce have piece to peers.
             // Assume we got a good piece, we don't really care anymore.
@@ -445,6 +461,7 @@ public class PeerCoordinator implements PeerListener
               {
                 if (Snark.debug >= Snark.INFO)
                   Snark.debug("Recv p" + piece + " " + peer, Snark.INFO);
+                _log.info("Got valid piece " + piece + "/" + metainfo.getPieces() +" from " + peer + " for " + metainfo.getName());
               }
             else
               {
@@ -453,6 +470,7 @@ public class PeerCoordinator implements PeerListener
                 if (Snark.debug >= Snark.NOTICE)
                   Snark.debug("Got BAD piece " + piece + " from " + peer,
                               Snark.NOTICE);
+                _log.warn("Got BAD piece " + piece + "/" + metainfo.getPieces() + " from " + peer + " for " + metainfo.getName());
                 return false; // No need to announce BAD piece to peers.
               }
           }
@@ -524,6 +542,7 @@ public class PeerCoordinator implements PeerListener
             unchokePeer();
             removePeerFromPieces(peer);
           }
+        peerCount = peers.size();
       }
 
     if (listener != null)
diff --git a/apps/i2psnark/java/src/org/klomp/snark/Snark.java b/apps/i2psnark/java/src/org/klomp/snark/Snark.java
index 3f0768c9aa..a5917fd820 100644
--- a/apps/i2psnark/java/src/org/klomp/snark/Snark.java
+++ b/apps/i2psnark/java/src/org/klomp/snark/Snark.java
@@ -209,13 +209,15 @@ public class Snark
       }
   }
 
-  String torrent;
-  MetaInfo meta;
-  Storage storage;
-  PeerCoordinator coordinator;
-  ConnectionAcceptor acceptor;
-  TrackerClient trackerclient;
-  String rootDataDir = ".";
+  public String torrent;
+  public MetaInfo meta;
+  public Storage storage;
+  public PeerCoordinator coordinator;
+  public ConnectionAcceptor acceptor;
+  public TrackerClient trackerclient;
+  public String rootDataDir = ".";
+  public CompleteListener completeListener;
+  public boolean stopped;
 
   Snark(String torrent, String ip, int user_port,
         StorageListener slistener, CoordinatorListener clistener) { 
@@ -233,6 +235,7 @@ public class Snark
     this.torrent = torrent;
     this.rootDataDir = rootDir;
 
+    stopped = true;
     activity = "Network setup";
 
     // "Taking Three as the subject to reason about--
@@ -363,6 +366,7 @@ public class Snark
    * Start up contacting peers and querying the tracker
    */
   public void startTorrent() {
+    stopped = false;
     boolean coordinatorChanged = false;
     if (coordinator.halted()) {
         // ok, we have already started and stopped, but the coordinator seems a bit annoying to
@@ -375,18 +379,21 @@ public class Snark
         coordinator = newCoord;
         coordinatorChanged = true;
     }
-    if (trackerclient.halted() || coordinatorChanged) {
+    if (!trackerclient.started() && !coordinatorChanged) {
+        trackerclient.start();
+    } else if (trackerclient.halted() || coordinatorChanged) {
         TrackerClient newClient = new TrackerClient(coordinator.getMetaInfo(), coordinator);
         if (!trackerclient.halted())
             trackerclient.halt();
         trackerclient = newClient;
+        trackerclient.start();
     }
-    trackerclient.start();
   }
   /**
    * Stop contacting the tracker and talking with peers
    */
   public void stopTorrent() {
+    stopped = true;
     trackerclient.halt();
     coordinator.halt();
     try { 
@@ -417,6 +424,8 @@ public class Snark
     String ip = null;
     String torrent = null;
 
+    boolean configured = I2PSnarkUtil.instance().configured();
+    
     int i = 0;
     while (i < args.length)
       {
@@ -463,7 +472,8 @@ public class Snark
           {
             String proxyHost = args[i+1];
             String proxyPort = args[i+2];
-            I2PSnarkUtil.instance().setProxy(proxyHost, Integer.parseInt(proxyPort));
+            if (!configured)
+                I2PSnarkUtil.instance().setProxy(proxyHost, Integer.parseInt(proxyPort));
             i += 3;
           }
         else if (args[i].equals("--i2cp"))
@@ -484,7 +494,8 @@ public class Snark
                     }
                 }
             }
-            I2PSnarkUtil.instance().setI2CPConfig(i2cpHost, Integer.parseInt(i2cpPort), opts);
+            if (!configured)
+                I2PSnarkUtil.instance().setI2CPConfig(i2cpHost, Integer.parseInt(i2cpPort), opts);
             i += 3 + (opts != null ? 1 : 0);
           }
         else
@@ -654,6 +665,8 @@ public class Snark
     Snark.debug("Completely received " + torrent, Snark.INFO);
     //storage.close();
     System.out.println("Completely received: " + torrent);
+    if (completeListener != null)
+        completeListener.torrentComplete(this);
   }
 
   public void shutdown()
@@ -662,4 +675,8 @@ public class Snark
     // have died. But in reality this does not always happen.
     System.exit(0);
   }
+  
+  public interface CompleteListener {
+    public void torrentComplete(Snark snark);
+  }
 }
diff --git a/apps/i2psnark/java/src/org/klomp/snark/SnarkManager.java b/apps/i2psnark/java/src/org/klomp/snark/SnarkManager.java
index 17cd1db4a7..a091ced1b1 100644
--- a/apps/i2psnark/java/src/org/klomp/snark/SnarkManager.java
+++ b/apps/i2psnark/java/src/org/klomp/snark/SnarkManager.java
@@ -10,7 +10,7 @@ import net.i2p.util.Log;
 /**
  * Manage multiple snarks
  */
-public class SnarkManager {
+public class SnarkManager implements Snark.CompleteListener {
     private static SnarkManager _instance = new SnarkManager();
     public static SnarkManager instance() { return _instance; }
     
@@ -35,6 +35,8 @@ public class SnarkManager {
         _log = _context.logManager().getLog(SnarkManager.class);
         _messages = new ArrayList(16);
         loadConfig("i2psnark.config");
+        int minutes = getStartupDelayMinutes();
+        _messages.add("Starting up torrents in " + minutes + (minutes == 1 ? " minute" : " minutes"));
         I2PThread monitor = new I2PThread(new DirMonitor(), "Snark DirMonitor");
         monitor.setDaemon(true);
         monitor.start();
@@ -50,9 +52,16 @@ public class SnarkManager {
         _log.info("MSG: " + message);
     }
     
-    private boolean shouldAutoStart() { return true; }
+    /** newest last */
+    public List getMessages() {
+        synchronized (_messages) {
+            return new ArrayList(_messages);
+        }
+    }
+    
+    public boolean shouldAutoStart() { return true; }
     private int getStartupDelayMinutes() { return 1; }
-    private File getDataDir() { 
+    public File getDataDir() { 
         String dir = _config.getProperty(PROP_DIR);
         if ( (dir == null) || (dir.trim().length() <= 0) )
             dir = "i2psnark";
@@ -89,14 +98,14 @@ public class SnarkManager {
         String i2cpHost = _config.getProperty(PROP_I2CP_HOST);
         int i2cpPort = getInt(PROP_I2CP_PORT, 7654);
         String opts = _config.getProperty(PROP_I2CP_OPTS);
-        Properties i2cpOpts = new Properties();
+        Map i2cpOpts = new HashMap();
         if (opts != null) {
             StringTokenizer tok = new StringTokenizer(opts, " ");
             while (tok.hasMoreTokens()) {
                 String pair = tok.nextToken();
                 int split = pair.indexOf('=');
-                if (split > 0) 
-                    i2cpOpts.setProperty(pair.substring(0, split), pair.substring(split+1));
+                if (split > 0)
+                    i2cpOpts.put(pair.substring(0, split), pair.substring(split+1));
             }
         }
         if (i2cpHost != null) {
@@ -122,6 +131,108 @@ public class SnarkManager {
         return defaultVal;
     }
     
+    public void updateConfig(String dataDir, boolean autoStart, String seedPct, String eepHost, 
+                             String eepPort, String i2cpHost, String i2cpPort, String i2cpOpts) {
+        boolean changed = false;
+        if (eepHost != null) {
+            int port = I2PSnarkUtil.instance().getEepProxyPort();
+            try { port = Integer.parseInt(eepPort); } catch (NumberFormatException nfe) {}
+            String host = I2PSnarkUtil.instance().getEepProxyHost();
+            if ( (eepHost.trim().length() > 0) && (port > 0) &&
+                 ((!host.equals(eepHost) || (port != I2PSnarkUtil.instance().getEepProxyPort()) )) ) {
+                I2PSnarkUtil.instance().setProxy(eepHost, port);
+                changed = true;
+                _config.setProperty(PROP_EEP_HOST, eepHost);
+                _config.setProperty(PROP_EEP_PORT, eepPort+"");
+                addMessage("EepProxy location changed to " + eepHost + ":" + port);
+            }
+        }
+        if (i2cpHost != null) {
+            int oldI2CPPort = I2PSnarkUtil.instance().getI2CPPort();
+            String oldI2CPHost = I2PSnarkUtil.instance().getI2CPHost();
+            int port = oldI2CPPort;
+            try { port = Integer.parseInt(i2cpPort); } catch (NumberFormatException nfe) {}
+            String host = oldI2CPHost;
+            Map opts = new HashMap();
+            if (i2cpOpts == null) i2cpOpts = "";
+            StringTokenizer tok = new StringTokenizer(i2cpOpts, " \t\n");
+            while (tok.hasMoreTokens()) {
+                String pair = tok.nextToken();
+                int split = pair.indexOf('=');
+                if (split > 0)
+                    opts.put(pair.substring(0, split), pair.substring(split+1));
+            }
+            Map oldOpts = new HashMap();
+            String oldI2CPOpts = _config.getProperty(PROP_I2CP_OPTS);
+            if (oldI2CPOpts == null) oldI2CPOpts = "";
+            tok = new StringTokenizer(oldI2CPOpts, " \t\n");
+            while (tok.hasMoreTokens()) {
+                String pair = tok.nextToken();
+                int split = pair.indexOf('=');
+                if (split > 0)
+                    oldOpts.put(pair.substring(0, split), pair.substring(split+1));
+            }
+            
+            if ( (i2cpHost.trim().length() > 0) && (port > 0) &&
+                 ((!host.equals(i2cpHost) || 
+                  (port != I2PSnarkUtil.instance().getI2CPPort()) ||
+                  (!oldOpts.equals(opts)))) ) {
+                boolean snarksActive = false;
+                Set names = listTorrentFiles();
+                for (Iterator iter = names.iterator(); iter.hasNext(); ) {
+                    Snark snark = getTorrent((String)iter.next());
+                    if ( (snark != null) && (!snark.stopped) ) {
+                        snarksActive = true;
+                        break;
+                    }
+                }
+                if (snarksActive) {
+                    addMessage("Cannot change the I2CP settings while torrents are active");
+                    _log.debug("i2cp host [" + i2cpHost + "] i2cp port " + port + " opts [" + opts 
+                               + "] oldOpts [" + oldOpts + "]");
+                } else {
+                    if (I2PSnarkUtil.instance().connected()) {
+                        I2PSnarkUtil.instance().disconnect();
+                        addMessage("Disconnecting old I2CP destination");
+                    }
+                    Properties p = new Properties();
+                    p.putAll(opts);
+                    addMessage("I2CP settings changed to " + i2cpHost + ":" + port + " (" + i2cpOpts.trim() + ")");
+                    I2PSnarkUtil.instance().setI2CPConfig(i2cpHost, port, p);
+                    boolean ok = I2PSnarkUtil.instance().connect();
+                    if (!ok) {
+                        addMessage("Unable to connect with the new settings, reverting to the old I2CP settings");
+                        I2PSnarkUtil.instance().setI2CPConfig(oldI2CPHost, oldI2CPPort, oldOpts);
+                        ok = I2PSnarkUtil.instance().connect();
+                        if (!ok)
+                            addMessage("Unable to reconnect with the old settings!");
+                    } else {
+                        addMessage("Reconnected on the new I2CP destination");
+                        _config.setProperty(PROP_I2CP_HOST, i2cpHost.trim());
+                        _config.setProperty(PROP_I2CP_PORT, "" + port);
+                        _config.setProperty(PROP_I2CP_OPTS, i2cpOpts.trim());
+                        changed = true;
+                        // no PeerAcceptors/I2PServerSockets to deal with, since all snarks are inactive
+                        for (Iterator iter = names.iterator(); iter.hasNext(); ) {
+                            String name = (String)iter.next();
+                            Snark snark = getTorrent(name);
+                            if ( (snark != null) && (snark.acceptor != null) ) {
+                                snark.acceptor.restart();
+                                addMessage("I2CP listener restarted for " + snark.meta.getName());
+                            }
+                        }
+                    }
+                }
+                changed = true;
+            }
+        }
+        if (changed) {
+            saveConfig();
+        } else {
+            addMessage("Configuration unchanged");
+        }
+    }
+    
     public void saveConfig() {
         try {
             DataHelper.storeProps(_config, new File(_configFile));
@@ -130,6 +241,8 @@ public class SnarkManager {
         }
     }
     
+    public Properties getConfig() { return _config; }
+    
     /** set of filenames that we are dealing with */
     public Set listTorrentFiles() { synchronized (_snarks) { return new HashSet(_snarks.keySet()); } }
     /**
@@ -151,17 +264,19 @@ public class SnarkManager {
             torrent = (Snark)_snarks.get(filename);
             if (torrent == null) {
                 torrent = new Snark(filename, null, -1, null, null, false, dataDir.getPath());
+                torrent.completeListener = this;
                 _snarks.put(filename, torrent);
             } else {
                 return;
             }
         }
         // ok, snark created, now lets start it up or configure it further
+        File f = new File(filename);
         if (shouldAutoStart()) {
             torrent.startTorrent();
-            addMessage("Torrent added and started: '" + filename + "'");
+            addMessage("Torrent added and started: '" + f.getName() + "'");
         } else {
-            addMessage("Torrent added: '" + filename + "'");
+            addMessage("Torrent added: '" + f.getName() + "'");
         }
     }
     
@@ -187,13 +302,15 @@ public class SnarkManager {
             remaining = _snarks.size();
         }
         if (torrent != null) {
+            boolean wasStopped = torrent.stopped;
             torrent.stopTorrent();
             if (remaining == 0) {
                 // should we disconnect/reconnect here (taking care to deal with the other thread's
                 // I2PServerSocket.accept() call properly?)
                 ////I2PSnarkUtil.instance().
             }
-            addMessage("Torrent stopped: '" + filename + "'");
+            if (!wasStopped)
+                addMessage("Torrent stopped: '" + sfile.getName() + "'");
         }
         return torrent;
     }
@@ -206,7 +323,7 @@ public class SnarkManager {
         if (torrent != null) {
             File torrentFile = new File(filename);
             torrentFile.delete();
-            addMessage("Torrent removed: '" + filename + "'");
+            addMessage("Torrent removed: '" + torrentFile.getName() + "'");
         }
     }
     
@@ -222,6 +339,13 @@ public class SnarkManager {
         }
     }
     
+    public void torrentComplete(Snark snark) {
+        File f = new File(snark.torrent);
+        long len = snark.meta.getTotalLength();
+        addMessage("Download complete of " + f.getName() 
+                   + (len < 5*1024*1024 ? " (size: " + (len/1024) + "KB)" : " (size: " + (len/1024*1024) + "MB)"));
+    }
+    
     private void monitorTorrents(File dir) {
         String fileNames[] = dir.list(TorrentFilenameFilter.instance());
         List foundNames = new ArrayList(0);
@@ -241,7 +365,10 @@ public class SnarkManager {
             if (existingNames.contains(foundNames.get(i))) {
                 // already known.  noop
             } else {
-                addTorrent((String)foundNames.get(i));
+                if (I2PSnarkUtil.instance().connect())
+                    addTorrent((String)foundNames.get(i));
+                else
+                    addMessage("Unable to connect to I2P");
             }
         }
         // now lets see which ones have been removed...
diff --git a/apps/i2psnark/java/src/org/klomp/snark/TrackerClient.java b/apps/i2psnark/java/src/org/klomp/snark/TrackerClient.java
index 9a07b476df..df2e243772 100644
--- a/apps/i2psnark/java/src/org/klomp/snark/TrackerClient.java
+++ b/apps/i2psnark/java/src/org/klomp/snark/TrackerClient.java
@@ -48,6 +48,7 @@ public class TrackerClient extends Thread
   private final int port;
 
   private boolean stop;
+  private boolean started;
 
   private long interval;
   private long lastRequestTime;
@@ -62,14 +63,17 @@ public class TrackerClient extends Thread
     this.port = 6881; //(port == -1) ? 9 : port;
 
     stop = false;
+    started = false;
   }
 
   public void start() {
-      stop = false;
+      if (stop) throw new RuntimeException("Dont rerun me, create a copy");
       super.start();
+      started = true;
   }
   
   public boolean halted() { return stop; }
+  public boolean started() { return started; }
   
   /**
    * Interrupts this Thread to stop it.
@@ -107,8 +111,10 @@ public class TrackerClient extends Thread
                 TrackerInfo info = doRequest(announce, infoHash, peerID,
                                              uploaded, downloaded, left,
                                              STARTED_EVENT);
+                Set peers = info.getPeers();
+                coordinator.trackerSeenPeers = peers.size();
                 if (!completed) {
-                    Iterator it = info.getPeers().iterator();
+                    Iterator it = peers.iterator();
                     while (it.hasNext()) {
                       Peer cur = (Peer)it.next();
                       coordinator.addPeer(cur);
@@ -118,6 +124,7 @@ public class TrackerClient extends Thread
                     }
                 }
                 started = true;
+                coordinator.trackerProblems = null;
               }
             catch (IOException ioe)
               {
@@ -125,6 +132,7 @@ public class TrackerClient extends Thread
                 Snark.debug
                   ("WARNING: Could not contact tracker at '"
                    + announce + "': " + ioe, Snark.WARNING);
+                coordinator.trackerProblems = ioe.getMessage();
               }
 
             if (!started && !stop)
@@ -182,10 +190,12 @@ public class TrackerClient extends Thread
                                                  uploaded, downloaded, left,
                                                  event);
 
+                    Set peers = info.getPeers();
+                    coordinator.trackerSeenPeers = peers.size();
                     if ( (left > 0) && (!completed) ) {
                         // we only want to talk to new people if we need things
                         // from them (duh)
-                        Iterator it = info.getPeers().iterator();
+                        Iterator it = peers.iterator();
                         while (it.hasNext()) {
                           Peer cur = (Peer)it.next();
                           coordinator.addPeer(cur);
@@ -244,21 +254,24 @@ public class TrackerClient extends Thread
         throw new IOException("Error fetching " + s);
     }
     
-    fetched.deleteOnExit();
-    InputStream in = new FileInputStream(fetched);
+    try {
+        InputStream in = new FileInputStream(fetched);
 
-    TrackerInfo info = new TrackerInfo(in, coordinator.getID(),
-                                       coordinator.getMetaInfo());
-    if (Snark.debug >= Snark.INFO)
-      Snark.debug("TrackerClient response: " + info, Snark.INFO);
-    lastRequestTime = System.currentTimeMillis();
-    
-    String failure = info.getFailureReason();
-    if (failure != null)
-      throw new IOException(failure);
-    
-    interval = info.getInterval() * 1000;
-    return info;
+        TrackerInfo info = new TrackerInfo(in, coordinator.getID(),
+                                           coordinator.getMetaInfo());
+        if (Snark.debug >= Snark.INFO)
+          Snark.debug("TrackerClient response: " + info, Snark.INFO);
+        lastRequestTime = System.currentTimeMillis();
+
+        String failure = info.getFailureReason();
+        if (failure != null)
+          throw new IOException(failure);
+
+        interval = info.getInterval() * 1000;
+        return info;
+    } finally {
+        fetched.delete();
+    }
   }
 
   /**
diff --git a/apps/i2psnark/java/src/org/klomp/snark/web/I2PSnarkServlet.java b/apps/i2psnark/java/src/org/klomp/snark/web/I2PSnarkServlet.java
new file mode 100644
index 0000000000..5f4bafab6f
--- /dev/null
+++ b/apps/i2psnark/java/src/org/klomp/snark/web/I2PSnarkServlet.java
@@ -0,0 +1,524 @@
+package org.klomp.snark.web;
+
+import java.io.*;
+import java.util.*;
+import javax.servlet.ServletConfig;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import javax.servlet.http.HttpServlet;
+
+import net.i2p.I2PAppContext;
+import net.i2p.data.Base64;
+import net.i2p.data.DataHelper;
+import net.i2p.util.FileUtil;
+import net.i2p.util.I2PThread;
+import net.i2p.util.Log;
+import org.klomp.snark.*;
+
+/**
+ *
+ */
+public class I2PSnarkServlet extends HttpServlet {
+    private I2PAppContext _context;
+    private Log _log;
+    private SnarkManager _manager;
+    private static long _nonce;
+    
+    public static final String PROP_CONFIG_FILE = "i2psnark.configFile";
+    
+    public void init(ServletConfig cfg) throws ServletException {
+        super.init(cfg);
+        _context = I2PAppContext.getGlobalContext();
+        _log = _context.logManager().getLog(I2PSnarkServlet.class);
+        _nonce = _context.random().nextLong();
+        _manager = SnarkManager.instance();
+        String configFile = _context.getProperty(PROP_CONFIG_FILE);
+        if ( (configFile == null) || (configFile.trim().length() <= 0) )
+            configFile = "i2psnark.config";
+        _manager.loadConfig(configFile);
+    }
+    
+    public void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
+        req.setCharacterEncoding("UTF-8");
+        resp.setCharacterEncoding("UTF-8");
+        resp.setContentType("text/html; charset=UTF-8");
+        
+        String nonce = req.getParameter("nonce");
+        if ( (nonce != null) && (nonce.equals(String.valueOf(_nonce))) )
+            processRequest(req);
+        
+        PrintWriter out = resp.getWriter();
+        out.write(HEADER_BEGIN);
+        // we want it to go to the base URI so we don't refresh with some funky action= value
+        out.write("<meta http-equiv=\"refresh\" content=\"60;" + req.getRequestURI() + "\">\n");
+        out.write(HEADER);
+        out.write("<textarea class=\"snarkMessages\" rows=\"2\" cols=\"100\" wrap=\"off\" >");
+        List msgs = _manager.getMessages();
+        for (int i = msgs.size()-1; i >= 0; i--) {
+            String msg = (String)msgs.get(i);
+            out.write(msg + "\n");
+        }
+        out.write("</textarea>\n");
+
+        out.write(TABLE_HEADER);
+
+        List snarks = getSortedSnarks(req);
+        String uri = req.getRequestURI();
+        for (int i = 0; i < snarks.size(); i++) {
+            Snark snark = (Snark)snarks.get(i);
+            displaySnark(out, snark, uri, i);
+        }
+        if (snarks.size() <= 0) {
+            out.write(TABLE_EMPTY);
+        }
+        
+        out.write(TABLE_FOOTER);
+        writeAddForm(out, req);
+        writeConfigForm(out, req);
+        out.write(FOOTER);
+    }
+    
+    /**
+     * Do what they ask, adding messages to _manager.addMessage as necessary
+     */
+    private void processRequest(HttpServletRequest req) {
+        String action = req.getParameter("action");
+        if (action == null) {
+            // noop
+        } else if ("Add torrent".equals(action)) {
+            String newFile = req.getParameter("newFile");
+            String newURL = req.getParameter("newURL");
+            File f = null;
+            if ( (newFile != null) && (newFile.trim().length() > 0) )
+                f = new File(newFile.trim());
+            if ( (f != null) && (!f.exists()) ) {
+                _manager.addMessage("Torrent file " + newFile +" does not exist");
+            }
+            if ( (f != null) && (f.exists()) ) {
+                File local = new File(_manager.getDataDir(), f.getName());
+                String canonical = null;
+                try {
+                    canonical = local.getCanonicalPath();
+                    
+                    if (local.exists()) {
+                        if (_manager.getTorrent(canonical) != null)
+                            _manager.addMessage("Torrent already running: " + newFile);
+                        else
+                            _manager.addMessage("Torrent already in the queue: " + newFile);
+                    } else {
+                        boolean ok = FileUtil.copy(f.getAbsolutePath(), local.getAbsolutePath(), true);
+                        if (ok) {
+                            _manager.addMessage("Copying torrent to " + local.getAbsolutePath());
+                            _manager.addTorrent(canonical);
+                        } else {
+                            _manager.addMessage("Unable to copy the torrent to " + local.getAbsolutePath() + " from " + f.getAbsolutePath());
+                        }
+                    }
+                } catch (IOException ioe) {
+                    _log.warn("hrm: " + local, ioe);
+                }
+            } else if ( (newURL != null) && (newURL.trim().length() > "http://.i2p/".length()) ) {
+                _manager.addMessage("Fetching " + newURL);
+                I2PThread fetch = new I2PThread(new FetchAndAdd(newURL), "Fetch and add");
+                fetch.start();
+            } else {
+                // no file or URL specified
+            }
+        } else if ("Stop".equals(action)) {
+            String torrent = req.getParameter("torrent");
+            if (torrent != null) {
+                byte infoHash[] = Base64.decode(torrent);
+                if ( (infoHash != null) && (infoHash.length == 20) ) { // valid sha1
+                    for (Iterator iter = _manager.listTorrentFiles().iterator(); iter.hasNext(); ) {
+                        String name = (String)iter.next();
+                        Snark snark = _manager.getTorrent(name);
+                        if ( (snark != null) && (DataHelper.eq(infoHash, snark.meta.getInfoHash())) ) {
+                            _manager.stopTorrent(name, false);
+                            break;
+                        }
+                    }
+                }
+            }
+        } else if ("Start".equals(action)) {
+            String torrent = req.getParameter("torrent");
+            if (torrent != null) {
+                byte infoHash[] = Base64.decode(torrent);
+                if ( (infoHash != null) && (infoHash.length == 20) ) { // valid sha1
+                    for (Iterator iter = _manager.listTorrentFiles().iterator(); iter.hasNext(); ) {
+                        String name = (String)iter.next();
+                        Snark snark = _manager.getTorrent(name);
+                        if ( (snark != null) && (DataHelper.eq(infoHash, snark.meta.getInfoHash())) ) {
+                            snark.startTorrent();
+                            _manager.addMessage("Starting up torrent " + name);
+                            break;
+                        }
+                    }
+                }
+            }
+        } else if ("Remove".equals(action)) {
+            String torrent = req.getParameter("torrent");
+            if (torrent != null) {
+                byte infoHash[] = Base64.decode(torrent);
+                if ( (infoHash != null) && (infoHash.length == 20) ) { // valid sha1
+                    for (Iterator iter = _manager.listTorrentFiles().iterator(); iter.hasNext(); ) {
+                        String name = (String)iter.next();
+                        Snark snark = _manager.getTorrent(name);
+                        if ( (snark != null) && (DataHelper.eq(infoHash, snark.meta.getInfoHash())) ) {
+                            _manager.stopTorrent(name, true);
+                            // should we delete the torrent file?
+                            break;
+                        }
+                    }
+                }
+            }
+        } else if ("Delete".equals(action)) {
+            String torrent = req.getParameter("torrent");
+            if (torrent != null) {
+                byte infoHash[] = Base64.decode(torrent);
+                if ( (infoHash != null) && (infoHash.length == 20) ) { // valid sha1
+                    for (Iterator iter = _manager.listTorrentFiles().iterator(); iter.hasNext(); ) {
+                        String name = (String)iter.next();
+                        Snark snark = _manager.getTorrent(name);
+                        if ( (snark != null) && (DataHelper.eq(infoHash, snark.meta.getInfoHash())) ) {
+                            _manager.stopTorrent(name, true);
+                            File f = new File(name);
+                            f.delete();
+                            _manager.addMessage("Torrent file deleted: " + f.getAbsolutePath());
+                            List files = snark.meta.getFiles();
+                            String dataFile = snark.meta.getName();
+                            for (int i = 0; files != null && i < files.size(); i++) {
+                                File df = new File(_manager.getDataDir(), (String)files.get(i));
+                                boolean deleted = FileUtil.rmdir(df, false);
+                                if (deleted)
+                                    _manager.addMessage("Data dir deleted: " + df.getAbsolutePath());
+                                else
+                                    _manager.addMessage("Data dir could not be deleted: " + df.getAbsolutePath());
+                            }
+                            if (dataFile != null) {
+                                f = new File(_manager.getDataDir(), dataFile);
+                                boolean deleted = f.delete();
+                                if (deleted)
+                                    _manager.addMessage("Data file deleted: " + f.getAbsolutePath());
+                                else
+                                    _manager.addMessage("Data file could not be deleted: " + f.getAbsolutePath());
+                            }
+                            break;
+                        }
+                    }
+                }
+            }
+        } else if ("Save configuration".equals(action)) {
+            String dataDir = req.getParameter("dataDir");
+            boolean autoStart = req.getParameter("autoStart") != null;
+            String seedPct = req.getParameter("seedPct");
+            String eepHost = req.getParameter("eepHost");
+            String eepPort = req.getParameter("eepPort");
+            String i2cpHost = req.getParameter("i2cpHost");
+            String i2cpPort = req.getParameter("i2cpPort");
+            String i2cpOpts = req.getParameter("i2cpOpts");
+            _manager.updateConfig(dataDir, autoStart, seedPct, eepHost, eepPort, i2cpHost, i2cpPort, i2cpOpts);
+        }
+    }
+    
+    private class FetchAndAdd implements Runnable {
+        private String _url;
+        public FetchAndAdd(String url) {
+            _url = url;
+        }
+        public void run() {
+            _url = _url.trim();
+            File file = I2PSnarkUtil.instance().get(_url, false);
+            try {
+                if ( (file != null) && (file.exists()) && (file.length() > 0) ) {
+                    _manager.addMessage("Torrent fetched from " + _url);
+                    FileInputStream in = null;
+                    try {
+                        in = new FileInputStream(file);
+                        MetaInfo info = new MetaInfo(in);
+                        String name = info.getName();
+                        name = name.replace('/', '_');
+                        name = name.replace('\\', '_');
+                        name = name.replace('&', '+');
+                        name = name.replace('\'', '_');
+                        name = name.replace('"', '_');
+                        name = name.replace('`', '_');
+                        name = name + ".torrent";
+                        File torrentFile = new File(_manager.getDataDir(), name);
+                        
+                        String canonical = torrentFile.getCanonicalPath();
+                        
+                        if (torrentFile.exists()) {
+                            if (_manager.getTorrent(canonical) != null)
+                                _manager.addMessage("Torrent already running: " + name);
+                            else
+                                _manager.addMessage("Torrent already in the queue: " + name);
+                        } else {
+                            FileUtil.copy(file.getAbsolutePath(), canonical, true);
+                            _manager.addTorrent(canonical);
+                        }
+                    } catch (IOException ioe) {
+                        _manager.addMessage("Torrent at " + _url + " was not valid: " + ioe.getMessage());
+                    } finally {
+                        try { in.close(); } catch (IOException ioe) {}
+                    }
+                } else {
+                    _manager.addMessage("Torrent was not retrieved from " + _url);
+                }
+            } finally {
+                if (file != null) file.delete();
+            }
+        }
+    }
+    
+    private List getSortedSnarks(HttpServletRequest req) {
+        Set files = _manager.listTorrentFiles();
+        TreeSet fileNames = new TreeSet(files); // sorts it alphabetically
+        ArrayList rv = new ArrayList(fileNames.size());
+        for (Iterator iter = fileNames.iterator(); iter.hasNext(); ) {
+            String name = (String)iter.next();
+            Snark snark = _manager.getTorrent(name);
+            if (snark != null)
+                rv.add(snark);
+        }
+        return rv;
+    }
+    
+    private static final int MAX_DISPLAYED_FILENAME_LENGTH = 60;
+    private void displaySnark(PrintWriter out, Snark snark, String uri, int row) throws IOException {
+        String filename = snark.torrent;
+        File f = new File(filename);
+        filename = f.getName(); // the torrent may be the canonical name, so lets just grab the local name
+        if (filename.length() > MAX_DISPLAYED_FILENAME_LENGTH)
+            filename = filename.substring(0, MAX_DISPLAYED_FILENAME_LENGTH) + "...";
+        long total = snark.meta.getTotalLength();
+        long remaining = snark.storage.needed() * snark.meta.getPieceLength(0); 
+        if (remaining > total)
+            remaining = total;
+        int totalBps = 4096; // should probably grab this from the snark...
+        long remainingSeconds = remaining / totalBps;
+        long uploaded = snark.coordinator.getUploaded();
+        
+        boolean isRunning = !snark.stopped;
+        boolean isValid = snark.meta != null;
+        
+        String err = snark.coordinator.trackerProblems;
+        int curPeers = snark.coordinator.getPeerCount();
+        int knownPeers = snark.coordinator.trackerSeenPeers;
+        
+        String statusString = "Unknown";
+        if (err != null) {
+            if (isRunning)
+                statusString = "TrackerErr (" + curPeers + "/" + knownPeers + " peers)";
+            else
+                statusString = "TrackerErr (" + err + ")";
+        } else if (remaining <= 0) {
+            if (isRunning)
+                statusString = "Seeding (" + curPeers + "/" + knownPeers + " peers)";
+            else
+                statusString = "Complete";
+        } else {
+            if (isRunning)
+                statusString = "OK (" + curPeers + "/" + knownPeers + " peers)";
+            else
+                statusString = "Stopped";
+        }
+        
+        String rowClass = (row % 2 == 0 ? "snarkTorrentEven" : "snarkTorrentOdd");
+        out.write("<tr class=\"" + rowClass + "\">");
+        out.write("<td valign=\"top\" align=\"left\" class=\"snarkTorrentStatus " + rowClass + "\">");
+        out.write(statusString + "</td>\n\t");
+        out.write("<td valign=\"top\" align=\"left\" class=\"snarkTorrentName " + rowClass + "\">");
+        out.write(filename + "</td>\n\t");
+        out.write("<td valign=\"top\" align=\"left\" class=\"snarkTorrentDownloaded " + rowClass + "\">");
+        if (remaining > 0) {
+            out.write(formatSize(total-remaining) + "/" + formatSize(total)); // 18MB/3GB
+            // lets hold off on the ETA until we have rates sorted...
+            //out.write(" (eta " + DataHelper.formatDuration(remainingSeconds*1000) + ")"); // (eta 6h)
+        } else {
+            out.write(formatSize(total)); // 3GB
+        }
+        out.write("</td>\n\t");
+        out.write("<td valign=\"top\" align=\"left\" class=\"snarkTorrentUploaded " + rowClass 
+                  + "\">" + formatSize(uploaded) + "</td>\n\t");
+        //out.write("<td valign=\"top\" align=\"left\" class=\"snarkTorrentRate\">");
+        //out.write("n/a"); //2KBps/12KBps/4KBps
+        //out.write("</td>\n\t");
+        out.write("<td valign=\"top\" align=\"left\" class=\"snarkTorrentAction " + rowClass + "\">");
+        if (isRunning) {
+            out.write("<a href=\"" + uri + "?action=Stop&nonce=" + _nonce 
+                      + "&torrent=" + Base64.encode(snark.meta.getInfoHash())
+                      + "\" title=\"Stop the torrent\">Stop</a>");
+        } else {
+            if (isValid)
+                out.write("<a href=\"" + uri + "?action=Start&nonce=" + _nonce 
+                          + "&torrent=" + Base64.encode(snark.meta.getInfoHash())
+                          + "\" title=\"Start the torrent\">Start</a> ");
+            out.write("<a href=\"" + uri + "?action=Remove&nonce=" + _nonce 
+                      + "&torrent=" + Base64.encode(snark.meta.getInfoHash())
+                      + "\" title=\"Remove the torrent from the active list, deleting the .torrent file\">Remove</a><br />");
+            out.write("<a href=\"" + uri + "?action=Delete&nonce=" + _nonce 
+                      + "&torrent=" + Base64.encode(snark.meta.getInfoHash())
+                      + "\" title=\"Delete the .torrent file and the associated data file(s)\">Delete</a> ");
+        }
+        out.write("</td>\n</tr>\n");
+    }
+    
+    private void writeAddForm(PrintWriter out, HttpServletRequest req) throws IOException {
+        String uri = req.getRequestURI();
+        String newURL = req.getParameter("newURL");
+        if ( (newURL == null) || (newURL.trim().length() <= 0) ) newURL = "http://";
+        String newFile = req.getParameter("newFile");
+        if ( (newFile == null) || (newFile.trim().length() <= 0) ) newFile = "";
+        
+        out.write("<span class=\"snarkNewTorrent\">\n");
+        // *not* enctype="multipart/form-data", so that the input type=file sends the filename, not the file
+        out.write("<form action=\"" + uri + "\" method=\"POST\">\n");
+        out.write("<input type=\"hidden\" name=\"nonce\" value=\"" + _nonce + "\" />\n");
+        out.write("From URL&nbsp;: <input type=\"text\" name=\"newURL\" size=\"50\" value=\"" + newURL + "\" /> \n");
+        // not supporting from file at the moment, since the file name passed isn't always absolute (so it may not resolve)
+        //out.write("From file: <input type=\"file\" name=\"newFile\" size=\"50\" value=\"" + newFile + "\" /><br />\n");
+        out.write("<input type=\"submit\" value=\"Add torrent\" name=\"action\" /><br />\n");
+        out.write("Alternately, you can copy .torrent files to " + _manager.getDataDir().getAbsolutePath() + "<br />\n");
+        out.write("</form>\n</span>\n");
+    }
+    
+    private void writeConfigForm(PrintWriter out, HttpServletRequest req) throws IOException {
+        String uri = req.getRequestURI();
+        String dataDir = _manager.getDataDir().getAbsolutePath();
+        boolean autoStart = _manager.shouldAutoStart();
+        int seedPct = 0;
+       
+        out.write("<span class=\"snarkConfig\">\n");
+        out.write("<form action=\"" + uri + "\" method=\"POST\">\n");
+        out.write("<input type=\"hidden\" name=\"nonce\" value=\"" + _nonce + "\" />\n");
+        out.write("<hr /><span class=\"snarkConfigTitle\">Configuration:</span><br />\n");
+        out.write("Data directory: <input type=\"text\" size=\"40\" name=\"dataDir\" value=\"" + dataDir + "\" ");
+        out.write("title=\"Directory to store torrents and data\" disabled=\"true\" /><br />\n");
+        out.write("Auto start: <input type=\"checkbox\" name=\"autoStart\" value=\"true\" " 
+                  + (autoStart ? "checked " : "") 
+                  + "title=\"If true, automatically start torrents that are added\" disabled=\"true\" />");
+        //Auto add: <input type="checkbox" name="autoAdd" value="true" title="If true, automatically add torrents that are found in the data directory" />
+        //Auto stop: <input type="checkbox" name="autoStop" value="true" title="If true, automatically stop torrents that are removed from the data directory" />
+        //out.write("<br />\n");
+        out.write("Seed percentage: <select name=\"seedPct\" disabled=\"true\" >\n\t");
+        if (seedPct <= 0)
+            out.write("<option value=\"0\" selected=\"true\">Unlimited</option>\n\t");
+        else
+            out.write("<option value=\"0\">Unlimited</option>\n\t");
+        if (seedPct == 100)
+            out.write("<option value=\"100\" selected=\"true\">100%</option>\n\t");
+        else
+            out.write("<option value=\"100\">100%</option>\n\t");
+        if (seedPct == 150)
+            out.write("<option value=\"150\" selected=\"true\">150%</option>\n\t");
+        else
+            out.write("<option value=\"150\">150%</option>\n\t");
+        out.write("</select><br />\n");
+        
+        out.write("<hr />\n");
+        out.write("EepProxy host: <input type=\"text\" name=\"eepHost\" value=\""
+                  + I2PSnarkUtil.instance().getEepProxyHost() + "\" size=\"15\" /> ");
+        out.write("EepProxy port: <input type=\"text\" name=\"eepPort\" value=\""
+                  + I2PSnarkUtil.instance().getEepProxyPort() + "\" size=\"5\" /> <br />\n");
+        out.write("I2CP host: <input type=\"text\" name=\"i2cpHost\" value=\"" 
+                  + I2PSnarkUtil.instance().getI2CPHost() + "\" size=\"15\" /> ");
+        out.write("I2CP port: <input type=\"text\" name=\"i2cpPort\" value=\"" +
+                  + I2PSnarkUtil.instance().getI2CPPort() + "\" size=\"5\" /> <br />\n");
+        StringBuffer opts = new StringBuffer(64);
+        Map options = new TreeMap(I2PSnarkUtil.instance().getI2CPOptions());
+        for (Iterator iter = options.keySet().iterator(); iter.hasNext(); ) {
+            String key = (String)iter.next();
+            String val = (String)options.get(key);
+            opts.append(key).append('=').append(val).append(' ');
+        }
+        out.write("I2CP options: <input type=\"text\" name=\"i2cpOpts\" size=\"80\" value=\""
+                  + opts.toString() + "\" /><br />\n");
+        out.write("<input type=\"submit\" value=\"Save configuration\" name=\"action\" />\n");
+        out.write("</form>\n</span>\n");
+    }
+    
+    private String formatSize(long bytes) {
+        if (bytes < 5*1024)
+            return bytes + "B";
+        else if (bytes < 5*1024*1024)
+            return (bytes/1024) + "KB";
+        else if (bytes < 5*1024*1024*1024)
+            return (bytes/(1024*1024)) + "MB";
+        else
+            return (bytes/(1024*1024*1024)) + "GB";
+    }
+    
+    private static final String HEADER_BEGIN = "<html>\n" +
+                                               "<head>\n" +
+                                               "<title>I2PSnark - anonymous bittorrent</title>\n";
+                                         
+    private static final String HEADER = "<style>\n" +
+                                         "body {\n" +
+                                         "	background-color: #C7CFB4;\n" +
+                                         "}\n" +
+                                         ".snarkTitle {\n" +
+                                         "	text-align: left;\n" +
+                                         "	float: left;\n" +
+                                         "	margin: 0px 0px 5px 5px;\n" +
+                                         "	display: inline;\n" +
+                                         "	font-size: 16pt;\n" +
+                                         "	font-weight: bold;\n" +
+                                         "}\n" +
+                                         ".snarkMessages {\n" +
+                                         "	border: none;\n" +
+                                         "                  background-color: #CECFC6;\n" +
+                                         "                  font-family: monospace;\n" +
+                                         "                  font-size: 10pt;\n" +
+                                         "                  font-weight: 100;\n" +
+                                         "}\n" +
+                                         "table {\n" +
+                                         "	margin: 0px 0px 0px 0px;\n" +
+                                         "	border: 0px;\n" +
+                                         "	padding: 0px;\n" +
+                                         "	border-width: 0px;\n" +
+                                         "	border-spacing: 0px;\n" +
+                                         "}\n" +
+                                         "th {\n" +
+                                         "	background-color: #C7D5D5;\n" +
+                                         "	margin: 0px 0px 0px 0px;\n" +
+                                         "}\n" +
+                                         ".snarkTorrentEven {\n" +
+                                         "	background-color: #E7E7E7;\n" +
+                                         "}\n" +
+                                         ".snarkTorrentOdd {\n" +
+                                         "	background-color: #DDDDCC;\n" +
+                                         "}\n" +
+                                         ".snarkNewTorrent {\n" +
+                                         "	font-size: 12pt;\n" +
+                                         "	font-family: monospace;\n" +
+                                         "	background-color: #ADAE9;\n" +
+                                         "}\n" +
+                                         ".snarkConfigTitle {\n" +
+                                         "	font-size: 16pt;\n" +
+                                         "                  font-weight: bold;\n" +
+                                         "}\n" +
+                                         "</style>\n" +
+                                         "</head>\n" +
+                                         "<body>\n" +
+                                         "<p class=\"snarkTitle\">I2PSnark&nbsp;</p>\n";
+
+
+    private static final String TABLE_HEADER = "<table border=\"0\" class=\"snarkTorrents\" width=\"100%\">\n" +
+                                               "<thead>\n" +
+                                               "<tr><th align=\"left\" valign=\"top\">Status</th>\n" +
+                                               "    <th align=\"left\" valign=\"top\">Torrent</th>\n" +
+                                               "    <th align=\"left\" valign=\"top\">Downloaded</th>\n" +
+                                               "    <th align=\"left\" valign=\"top\">Uploaded</th>\n" +
+                                               //"    <th align=\"left\" valign=\"top\">Rate</th>\n" +
+                                               "    <th>&nbsp;</th></tr>\n" +
+                                               "</thead>\n";
+    
+   private static final String TABLE_EMPTY  = "<tr class=\"snarkTorrentEven\">" +
+                                              "<td class=\"snarkTorrentEven\" align=\"left\"" +
+                                              "    valign=\"top\" colspan=\"5\">No torrents</td></tr>\n";
+
+    private static final String TABLE_FOOTER = "</table>\n";
+    
+    private static final String FOOTER = "</body></html>";
+}
\ No newline at end of file
diff --git a/apps/i2psnark/web.xml b/apps/i2psnark/web.xml
new file mode 100644
index 0000000000..71260245f1
--- /dev/null
+++ b/apps/i2psnark/web.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="ISO-8859-1"?>
+<!DOCTYPE web-app
+    PUBLIC "-//Sun Microsystems, Inc.//DTD Web Application 2.2//EN"
+    "http://java.sun.com/j2ee/dtds/web-app_2.2.dtd">
+
+<web-app>
+    <servlet>
+     <servlet-name>org.klomp.snark.web.I2PSnarkServlet</servlet-name>
+     <servlet-class>org.klomp.snark.web.I2PSnarkServlet</servlet-class>
+     <load-on-startup>1</load-on-startup>
+    </servlet>
+     
+    <!-- precompiled servlets -->
+    
+    <servlet-mapping> 
+      <servlet-name>org.klomp.snark.web.I2PSnarkServlet</servlet-name>
+      <url-pattern>/</url-pattern>
+    </servlet-mapping>
+    
+    <session-config>
+        <session-timeout>
+            30
+        </session-timeout>
+    </session-config>
+</web-app>
diff --git a/apps/routerconsole/jsp/nav.jsp b/apps/routerconsole/jsp/nav.jsp
index d555922d1b..1a3aefc6c7 100644
--- a/apps/routerconsole/jsp/nav.jsp
+++ b/apps/routerconsole/jsp/nav.jsp
@@ -25,6 +25,7 @@
  <a href="susimail/susimail">Susimail</a> |
  <a href="susidns/index.jsp">SusiDNS</a> |
  <a href="syndie/">Syndie</a> |
+ <a href="i2psnark/">I2PSnark</a> |
  <a href="http://localhost:7658/">My Eepsite</a> <br>
  <a href="i2ptunnel/index.jsp">I2PTunnel</a> |
  <a href="tunnels.jsp">Tunnels</a> |
diff --git a/build.xml b/build.xml
index 6000d79a5a..ed229b8f46 100644
--- a/build.xml
+++ b/build.xml
@@ -31,7 +31,7 @@
         <ant dir="apps/susimail/" target="war" />
         <ant dir="apps/susidns/src" target="all" />
         <ant dir="apps/syndie/java/" target="jar" />
-        <ant dir="apps/i2psnark/java/" target="jar" />
+        <ant dir="apps/i2psnark/java/" target="war" />
     </target>
     <target name="buildrouter">
         <ant dir="core/java/" target="distclean" />
@@ -108,7 +108,7 @@
         <copy file="apps/syndie/syndie.war" todir="build/" />
         <copy file="apps/syndie/java/build/syndie.jar" todir="build/" />
         <copy file="apps/syndie/java/build/sucker.jar" todir="build/" />
-        <copy file="apps/syndie/syndie.war" todir="build/" />
+        <copy file="apps/i2psnark/i2psnark.war" todir="build/" />
         <copy file="apps/i2psnark/java/build/i2psnark.jar" todir="build/" />
         <copy file="apps/jdom/jdom.jar" todir="build/" />
         <copy file="apps/rome/rome-0.7.jar" todir="build/" />
@@ -250,6 +250,7 @@
         <copy file="build/susimail.war" todir="pkg-temp/webapps/" />
         <copy file="build/susidns.war" todir="pkg-temp/webapps/" />
         <copy file="build/syndie.war" todir="pkg-temp/webapps/" />
+        <copy file="build/i2psnark.war" todir="pkg-temp/webapps/" />
         <copy file="installer/resources/clients.config" todir="pkg-temp/" />
         <copy file="installer/resources/eepget" todir="pkg-temp/" />
         <copy file="installer/resources/i2prouter" todir="pkg-temp/" />
@@ -359,6 +360,7 @@
         <copy file="build/susimail.war" todir="pkg-temp/webapps/" />
         <copy file="build/susidns.war" todir="pkg-temp/webapps/" />
         <copy file="build/syndie.war" todir="pkg-temp/webapps/" />
+        <copy file="build/i2psnark.war" todir="pkg-temp/webapps/" />
         <copy file="history.txt" todir="pkg-temp/" />
         <mkdir dir="pkg-temp/docs/" />
         <copy file="news.xml" todir="pkg-temp/docs/" />
diff --git a/history.txt b/history.txt
index 9f2506acad..e1d33d12b3 100644
--- a/history.txt
+++ b/history.txt
@@ -1,4 +1,7 @@
-$Id: history.txt,v 1.355 2005/12/14 04:32:52 jrandom Exp $
+$Id: history.txt,v 1.356 2005/12/15 03:58:31 jrandom Exp $
+
+2005-12-15  jrandom
+    * Added a first pass to the I2PSnark web UI (see /i2psnark/)
 
 2005-12-15  jrandom
     * Added multitorrent support to I2PSnark, accessible currently by running
-- 
GitLab