Index: trunk/cmd/soju/main.go
===================================================================
--- trunk/cmd/soju/main.go	(revision 177)
+++ trunk/cmd/soju/main.go	(revision 178)
@@ -62,4 +62,5 @@
 	// TODO: load from config/DB
 	srv.Hostname = cfg.Hostname
+	srv.LogPath = cfg.LogPath
 	srv.Debug = debug
 
Index: trunk/config/config.go
===================================================================
--- trunk/config/config.go	(revision 177)
+++ trunk/config/config.go	(revision 178)
@@ -20,4 +20,5 @@
 	SQLDriver string
 	SQLSource string
+	LogPath   string
 }
 
@@ -71,4 +72,8 @@
 		case "sql":
 			if err := d.parseParams(&srv.SQLDriver, &srv.SQLSource); err != nil {
+				return nil, err
+			}
+		case "log":
+			if err := d.parseParams(&srv.LogPath); err != nil {
 				return nil, err
 			}
Index: trunk/doc/soju.1.scd
===================================================================
--- trunk/doc/soju.1.scd	(revision 177)
+++ trunk/doc/soju.1.scd	(revision 178)
@@ -70,4 +70,7 @@
 	source is the path to the SQLite database file.
 
+*log* <path>
+	Path to the bouncer logs root directory, or empty to disable logging.
+
 # IRC SERVICE
 
Index: trunk/server.go
===================================================================
--- trunk/server.go	(revision 177)
+++ trunk/server.go	(revision 178)
@@ -52,4 +52,5 @@
 	Logger   Logger
 	RingCap  int
+	LogPath  string
 	Debug    bool
 
Index: trunk/upstream.go
===================================================================
--- trunk/upstream.go	(revision 177)
+++ trunk/upstream.go	(revision 178)
@@ -8,4 +8,6 @@
 	"io"
 	"net"
+	"os"
+	"path/filepath"
 	"strconv"
 	"strings"
@@ -64,4 +66,11 @@
 	// access is synchronized with user.pendingLISTsLock
 	pendingLISTDownstreamSet map[uint64]struct{}
+
+	logs map[string]entityLog
+}
+
+type entityLog struct {
+	name string
+	file *os.File
 }
 
@@ -98,4 +107,5 @@
 		availableMemberships:     stdMemberships,
 		pendingLISTDownstreamSet: make(map[uint64]struct{}),
+		logs:                     make(map[string]entityLog),
 	}
 
@@ -142,5 +152,7 @@
 	}
 	close(uc.closed)
-
+	for _, log := range uc.logs {
+		log.file.Close()
+	}
 	uc.endPendingLists(true)
 	return nil
@@ -313,4 +325,10 @@
 				return err
 			}
+
+			target := nick
+			if nick == uc.nick {
+				target = msg.Prefix.Name
+			}
+			uc.AppendLog(target, "<%s> %s", msg.Prefix.Name, text)
 
 			uc.forEachDownstream(func(dc *downstreamConn) {
@@ -617,4 +635,5 @@
 				delete(ch.Members, msg.Prefix.Name)
 				ch.Members[newNick] = membership
+				uc.AppendLog(ch.Name, "*** %s is now known as %s", msg.Prefix.Name, newNick)
 			}
 		}
@@ -660,4 +679,6 @@
 			}
 
+			uc.AppendLog(ch, "*** Joins: %s (%s@%s)", msg.Prefix.Name, msg.Prefix.User, msg.Prefix.Host)
+
 			uc.forEachDownstream(func(dc *downstreamConn) {
 				dc.SendMessage(&irc.Message{
@@ -676,4 +697,9 @@
 		if err := parseMessageParams(msg, &channels); err != nil {
 			return err
+		}
+
+		var reason string
+		if len(msg.Params) > 1 {
+			reason = msg.Params[1]
 		}
 
@@ -690,4 +716,6 @@
 			}
 
+			uc.AppendLog(ch, "*** Parts: %s (%s@%s) (%s)", msg.Prefix.Name, msg.Prefix.User, msg.Prefix.Host, reason)
+
 			uc.forEachDownstream(func(dc *downstreamConn) {
 				dc.SendMessage(&irc.Message{
@@ -724,4 +752,6 @@
 		}
 
+		uc.AppendLog(channel, "*** %s was kicked by %s (%s)", user, msg.Prefix.Name, reason)
+
 		uc.forEachDownstream(func(dc *downstreamConn) {
 			params := []string{dc.marshalChannel(uc, channel), dc.marshalNick(uc, user)}
@@ -740,4 +770,9 @@
 		}
 
+		var reason string
+		if len(msg.Params) > 0 {
+			reason = msg.Params[0]
+		}
+
 		if msg.Prefix.Name == uc.nick {
 			uc.logger.Printf("quit")
@@ -745,5 +780,9 @@
 
 		for _, ch := range uc.channels {
-			delete(ch.Members, msg.Prefix.Name)
+			if _, ok := ch.Members[msg.Prefix.Name]; ok {
+				delete(ch.Members, msg.Prefix.Name)
+
+				uc.AppendLog(ch.Name, "*** Quits: %s (%s@%s) (%s)", msg.Prefix.Name, msg.Prefix.User, msg.Prefix.Host, reason)
+			}
 		}
 
@@ -819,4 +858,10 @@
 				}
 			}
+
+			modeMsg := modeStr
+			for _, v := range msg.Params[2:] {
+				modeMsg += " " + v
+			}
+			uc.AppendLog(ch.Name, "*** %s sets mode: %s", msg.Prefix.Name, modeMsg)
 
 			uc.forEachDownstream(func(dc *downstreamConn) {
@@ -1153,6 +1198,6 @@
 		}
 
-		var nick string
-		if err := parseMessageParams(msg, &nick, nil); err != nil {
+		var nick, text string
+		if err := parseMessageParams(msg, &nick, &text); err != nil {
 			return err
 		}
@@ -1166,4 +1211,10 @@
 			break
 		}
+
+		target := nick
+		if nick == uc.nick {
+			target = msg.Prefix.Name
+		}
+		uc.AppendLog(target, "<%s> %s", msg.Prefix.Name, text)
 
 		uc.network.ring.Produce(msg)
@@ -1364,2 +1415,46 @@
 	uc.SendMessage(msg)
 }
+
+// TODO: handle moving logs when a network name changes, when support for this is added
+func (uc *upstreamConn) AppendLog(entity string, format string, a ...interface{}) {
+	if uc.srv.LogPath == "" {
+		return
+	}
+	// TODO: enforce maximum open file handles (LRU cache of file handles)
+	// TODO: handle non-monotonic clock behaviour
+	now := time.Now()
+	year, month, day := now.Date()
+	name := fmt.Sprintf("%04d-%02d-%02d.log", year, month, day)
+	log, ok := uc.logs[entity]
+	if !ok || log.name != name {
+		if ok {
+			log.file.Close()
+			delete(uc.logs, entity)
+		}
+		// TODO: handle/forbid network/entity names with illegal path characters
+		dir := filepath.Join(uc.srv.LogPath, uc.user.Username, uc.network.Name, entity)
+		if err := os.MkdirAll(dir, 0600); err != nil {
+			uc.logger.Printf("failed to log message: could not create logs directory %q: %v", dir, err)
+			return
+		}
+		path := filepath.Join(dir, name)
+		f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0600)
+		if err != nil {
+			uc.logger.Printf("failed to log message: could not open or create log file %q: %v", path, err)
+			return
+		}
+		log = entityLog{
+			name: name,
+			file: f,
+		}
+		uc.logs[entity] = log
+	}
+
+	format = "[%02d:%02d:%02d] " + format + "\n"
+	args := []interface{}{now.Hour(), now.Minute(), now.Second()}
+	args = append(args, a...)
+
+	if _, err := fmt.Fprintf(log.file, format, args...); err != nil {
+		uc.logger.Printf("failed to log message to %q: %v", log.name, err)
+	}
+}
